diff --git a/.editorconfig b/.editorconfig index 1cd48550..05374e76 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,9 +14,13 @@ ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_name_count_to_use_star_import = 2147483647 ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 ij_kotlin_packages_to_use_import_on_demand = unset +ij_kotlin_line_break_after_multiline_when_entry = false ktlint_code_style = android_studio +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_filename = disabled ktlint_standard_function-expression-body = disabled ktlint_standard_function-signature = disabled ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_blank-line-between-when-conditions = disabled max_line_length = 100 diff --git a/.gitignore b/.gitignore index 43dc9de3..1ad93685 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ keystore.properties *.keystore local.properties /lib/build + +.kotlin + +*.log diff --git a/AndroidManifest.xml b/AndroidManifest.xml index a1698f89..d0f7daa6 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -132,7 +132,7 @@ android:allowEmbedded="true" android:resizeableActivity="true" android:windowSoftInputMode="stateHidden|adjustResize" - android:theme="@style/BugleTheme.ConversationActivity" + android:theme="@style/Theme.Compose" android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"> + bounds.bottom - bounds.top + } + + composeRule.runOnIdle { + isSelected = true + isSelectionMode = true + } + composeRule.waitForIdle() + + val selectedHeight = composeRule + .onNodeWithTag(testTag = MESSAGE_TEST_TAG) + .getUnclippedBoundsInRoot() + .let { bounds -> + bounds.bottom - bounds.top + } + + composeRule.runOnIdle { + assertEquals( + unselectedHeight.value, + selectedHeight.value, + HEIGHT_ASSERTION_DELTA_DP, + ) + } + } +} + +private fun outgoingMessage(text: String): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = MESSAGE_ID, + conversationId = CONVERSATION_ID, + text = text, + parts = listOf( + ConversationMessagePartUiModel.Text( + text = text, + ), + ), + sentTimestamp = TIMESTAMP, + receivedTimestamp = TIMESTAMP, + displayTimestamp = TIMESTAMP, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt new file mode 100644 index 00000000..37c013ee --- /dev/null +++ b/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt @@ -0,0 +1,52 @@ +package com.android.messaging.ui.conversation.metadata.ui + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule +import org.junit.Test + +internal class ConversationTopAppBarTest { + @get:Rule + val composeRule = createComposeRule() + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun titleTouchTargetUsesSmallTopAppBarHeight() { + composeRule.setContent { + AppTheme { + ConversationTopAppBar( + metadata = conversationMetadata(), + isCallVisible = true, + onAddPeopleClick = {}, + onTitleClick = {}, + onNavigateBack = {}, + ) + } + } + + composeRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .assertHeightIsEqualTo(expectedHeight = TopAppBarDefaults.TopAppBarExpandedHeight) + } +} + +private fun conversationMetadata(): ConversationMetadataUiState { + return ConversationMetadataUiState.Present( + title = "+372 5440 0024", + selfParticipantId = "self-participant-id", + avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), + participantCount = 1, + otherParticipantDisplayDestination = "+372 5440 0024", + otherParticipantPhoneNumber = "+37254400024", + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.editable(), + ) +} diff --git a/app/src/debug/assets/seed_video.mp4 b/app/src/debug/assets/seed_video.mp4 new file mode 100644 index 00000000..c915c8d6 Binary files /dev/null and b/app/src/debug/assets/seed_video.mp4 differ diff --git a/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt b/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt new file mode 100644 index 00000000..7f5f1cfa --- /dev/null +++ b/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt @@ -0,0 +1,53 @@ +package com.android.messaging.data.conversation.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.data.ConversationListItemData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +internal class ConversationDraftStoreTest { + + private val databaseWrapper = mockk() + private val dataModel = mockk() + + private val store = ConversationDraftStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(ConversationListItemData::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns databaseWrapper + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun getSelfParticipantIdReturnsNullWhenConversationSelfIdIsMissing() { + val conversation = ConversationListItemData() + every { + ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) + } returns conversation + + val selfParticipantId = store.getSelfParticipantId( + conversationId = CONVERSATION_ID, + ) + + assertNull(selfParticipantId) + } + + private companion object { + private const val CONVERSATION_ID = "conversation-id" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt new file mode 100644 index 00000000..9ed4e249 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt @@ -0,0 +1,645 @@ +package com.android.messaging.data.contact.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.ContactsContract +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.util.PhoneUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ContactsRepositoryImplTest { + + private val contentResolver = mockk() + private val phoneUtilsInstance = mockk(relaxed = true) + + private var nextDataId = 1L + + @Before + fun setUp() { + nextDataId = 1L + mockkStatic(PhoneUtils::class) + mockkStatic(MmsSmsUtils::class) + every { PhoneUtils.getDefault() } returns phoneUtilsInstance + every { MmsSmsUtils.isEmailAddress(any()) } answers { + val raw = firstArg() + "@" in raw + } + every { phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any()) } answers { + firstArg() + } + every { phoneUtilsInstance.countryCandidatesForEnteredPhoneNumber } returns emptyList() + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any(), any>()) + } answers { + firstArg() + } + every { phoneUtilsInstance.formatForDisplay(any()) } answers { + firstArg() + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun multiNumberContactReturnsAllDestinationsWithSuperPrimaryFirst() = runTest { + stubFilterPhoneCursor( + query = "Multi", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + isPrimary = false, + isSuperPrimary = false, + ), + ), + ) + stubFilterEmailCursor(query = "Multi", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550002", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + isSuperPrimary = true, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550003", + type = ContactsContract.CommonDataKinds.Phone.TYPE_WORK, + isPrimary = true, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Multi", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(1L, contact.id) + Assert.assertEquals(3, contact.destinations.size) + Assert.assertEquals( + listOf("+15550002", "+15550003", "+15550001"), + contact.destinations.map { it.value }, + ) + Assert.assertEquals(ContactDestination.Kind.PHONE, contact.destinations[0].kind) + Assert.assertTrue(contact.destinations[0].isSuperPrimary) + Assert.assertNull(page.nextOffset) + } + + @Test + fun nameAndNumberMatchesCollapseToSingleContactExpandedOnce() = runTest { + stubFilterPhoneCursor( + query = "Bob", + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), + ), + ) + stubFilterEmailCursor(query = "Bob", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Bob", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(7L, contact.id) + Assert.assertEquals( + listOf("+17777777", "+17777778"), + contact.destinations.map { it.value }, + ) + } + + @Test + fun twoContactsSharingNumberBothKeepIt() = runTest { + stubFilterPhoneCursor( + query = "Same", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubFilterEmailCursor(query = "Same", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Same", offset = 0).first() + + Assert.assertEquals(listOf(1L, 2L), page.contacts.map(Contact::id)) + page.contacts.forEach { contact -> + Assert.assertEquals("+15551111", contact.destinations.single().value) + } + } + + @Test + fun digitFallbackRecoversContactWhenFilterMatchesNothing() = runTest { + stubFilterPhoneCursor(query = "1234", rows = emptyList()) + stubFilterEmailCursor(query = "1234", rows = emptyList()) + stubDefaultPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "1234", offset = 0).first() + + Assert.assertEquals(3L, page.contacts.single().id) + } + + @Test + fun paginationSplitsContactsAcrossPages() = runTest { + val rows = (1..250).map { id -> + phoneRow( + contactId = id.toLong(), + sortKey = "Person %03d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubDefaultPhoneCursor(rows = rows) + stubExpansionPhoneCursor( + rows = rows, + ) + stubExpansionEmailCursor( + rows = emptyList(), + ) + + val repo = createRepository() + val firstPage = repo.searchContacts(query = "", offset = 0).first() + val secondPage = repo.searchContacts(query = "", offset = 200).first() + + Assert.assertEquals(200, firstPage.contacts.size) + Assert.assertEquals(200, firstPage.nextOffset) + Assert.assertEquals(50, secondPage.contacts.size) + Assert.assertNull(secondPage.nextOffset) + } + + @Test + fun phoneAndEmailDestinationsForSameContactMerge() = runTest { + stubFilterPhoneCursor( + query = "Dee", + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubFilterEmailCursor(query = "Dee", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor( + rows = listOf( + emailRow( + contactId = 4L, + sortKey = "Dee", + address = "dee@example.com", + type = ContactsContract.CommonDataKinds.Email.TYPE_WORK, + ), + ), + ) + + val repo = createRepository() + val page = repo.searchContacts(query = "Dee", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(2, contact.destinations.size) + val kinds = contact.destinations.map { it.kind } + Assert.assertEquals(ContactDestination.Kind.PHONE, kinds[0]) + Assert.assertEquals(ContactDestination.Kind.EMAIL, kinds[1]) + } + + @Test + fun largeContactSetGetsChunkedAndMergedEquivalently() = runTest { + val contactCount = 1100 + val ids = (1..contactCount).map { it.toLong() } + val rows = ids.map { id -> + phoneRow( + contactId = id, + sortKey = "Person %05d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubFilterPhoneCursor(query = "Person", rows = rows) + stubFilterEmailCursor(query = "Person", rows = emptyList()) + stubExpansionPhoneCursor(rows = rows) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Person", offset = 0).first() + + Assert.assertEquals(200, page.contacts.size) + Assert.assertEquals(200, page.nextOffset) + val firstContact = page.contacts.first() + Assert.assertFalse(firstContact.destinations.isEmpty()) + } + + @Test + fun searchExpandsOnlyPageContactsNotAllMatches() = runTest { + val totalMatches = 600 + val ids = (1..totalMatches).map { it.toLong() } + val rows = ids.map { id -> + phoneRow( + contactId = id, + sortKey = "Person %05d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubFilterPhoneCursor(query = "Person", rows = rows) + stubFilterEmailCursor(query = "Person", rows = emptyList()) + + val queriedPhoneContactIds = mutableSetOf() + val queriedEmailContactIds = mutableSetOf() + + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedPhoneContactIds.add(it.toLong()) } + val argSet = selectionArgs.toSet() + val matchingRows = rows.filter { row -> row.contactId.toString() in argSet } + phoneCursor(rows = matchingRows) + } + + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedEmailContactIds.add(it.toLong()) } + emailCursor(rows = emptyList()) + } + + val repo = createRepository() + val page = repo.searchContacts(query = "Person", offset = 0).first() + + Assert.assertEquals(200, page.contacts.size) + Assert.assertEquals(200, page.nextOffset) + Assert.assertEquals((1L..200L).toSet(), queriedPhoneContactIds) + Assert.assertEquals((1L..200L).toSet(), queriedEmailContactIds) + } + + private fun createRepository(): ContactsRepositoryImpl { + return ContactsRepositoryImpl( + formatter = ContactDestinationFormatterImpl(), + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(), + ) + } + + private fun stubFilterPhoneCursor(query: String, rows: List) { + every { + contentResolver.query( + match { uri -> isPhoneFilterUri(uri = uri, query = query) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { phoneCursor(rows = rows) } + } + + private fun stubFilterEmailCursor(query: String, rows: List) { + every { + contentResolver.query( + match { uri -> isEmailFilterUri(uri = uri, query = query) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { emailCursor(rows = rows) } + } + + private fun stubDefaultPhoneCursor(rows: List) { + every { + contentResolver.query( + match { uri -> isDefaultPhoneUri(uri = uri) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { phoneCursor(rows = rows) } + } + + private fun stubExpansionPhoneCursor(rows: List) { + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = (arg?>(3) ?: emptyArray()).toSet() + val matchingRows = rows.filter { row -> + row.contactId.toString() in selectionArgs + } + phoneCursor(rows = matchingRows) + } + } + + private fun stubExpansionEmailCursor(rows: List) { + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = (arg?>(3) ?: emptyArray()).toSet() + val matchingRows = rows.filter { row -> + row.contactId.toString() in selectionArgs + } + emailCursor(rows = matchingRows) + } + } + + private fun isPhoneFilterUri(uri: Uri, query: String): Boolean { + val expectedPrefix = ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI.toString() + return uri.toString().startsWith(expectedPrefix) && + uri.pathSegments.contains(query) + } + + private fun isEmailFilterUri(uri: Uri, query: String): Boolean { + val expectedPrefix = ContactsContract.CommonDataKinds.Email.CONTENT_FILTER_URI.toString() + return uri.toString().startsWith(expectedPrefix) && + uri.pathSegments.contains(query) + } + + private fun isDefaultPhoneUri(uri: Uri): Boolean { + return uri.path == ContactsContract.CommonDataKinds.Phone.CONTENT_URI.path && + uri.getQueryParameter("directory") != null + } + + private fun phoneCursor(rows: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + ContactsContract.CommonDataKinds.Phone._ID, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY, + ContactsContract.CommonDataKinds.Phone.PHOTO_THUMBNAIL_URI, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL, + ContactsContract.CommonDataKinds.Phone.IS_PRIMARY, + ContactsContract.CommonDataKinds.Phone.IS_SUPER_PRIMARY, + ), + ) + rows.forEach { row -> + cursor.addRow( + arrayOf( + row.dataId, + row.contactId, + row.lookupKey, + row.displayName, + row.sortKey, + row.photoUri, + row.value, + row.type, + row.customLabel, + if (row.isPrimary) 1 else 0, + if (row.isSuperPrimary) 1 else 0, + ), + ) + } + return cursor + } + + private fun emailCursor(rows: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + ContactsContract.CommonDataKinds.Email._ID, + ContactsContract.CommonDataKinds.Email.CONTACT_ID, + ContactsContract.CommonDataKinds.Email.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Email.SORT_KEY_PRIMARY, + ContactsContract.CommonDataKinds.Email.PHOTO_THUMBNAIL_URI, + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.TYPE, + ContactsContract.CommonDataKinds.Email.LABEL, + ContactsContract.CommonDataKinds.Email.IS_PRIMARY, + ContactsContract.CommonDataKinds.Email.IS_SUPER_PRIMARY, + ), + ) + rows.forEach { row -> + cursor.addRow( + arrayOf( + row.dataId, + row.contactId, + row.lookupKey, + row.displayName, + row.sortKey, + row.photoUri, + row.value, + row.type, + row.customLabel, + if (row.isPrimary) 1 else 0, + if (row.isSuperPrimary) 1 else 0, + ), + ) + } + return cursor + } + + private fun phoneRow( + contactId: Long, + sortKey: String, + number: String, + type: Int, + customLabel: String? = null, + isPrimary: Boolean = false, + isSuperPrimary: Boolean = false, + dataId: Long = nextDataId++, + ): RawRow { + return RawRow( + dataId = dataId, + contactId = contactId, + lookupKey = "lookup_$contactId", + displayName = sortKey, + sortKey = sortKey, + photoUri = null, + value = number, + type = type, + customLabel = customLabel, + isPrimary = isPrimary, + isSuperPrimary = isSuperPrimary, + ) + } + + private fun emailRow( + contactId: Long, + sortKey: String, + address: String, + type: Int, + customLabel: String? = null, + dataId: Long = nextDataId++, + ): RawRow { + return RawRow( + dataId = dataId, + contactId = contactId, + lookupKey = "lookup_$contactId", + displayName = sortKey, + sortKey = sortKey, + photoUri = null, + value = address, + type = type, + customLabel = customLabel, + isPrimary = false, + isSuperPrimary = false, + ) + } + + private data class RawRow( + val dataId: Long, + val contactId: Long, + val lookupKey: String, + val displayName: String, + val sortKey: String, + val photoUri: String?, + val value: String, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt new file mode 100644 index 00000000..f1b3ea8b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ResolveDraftAttachmentsWithinLimitTest { + + @Test + fun invoke_addsUntilLimitAndDropsOverflow() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/2"), + attachment(contentUri = "content://attachment/3"), + attachment(contentUri = "content://attachment/4"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 3)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals( + listOf( + attachment(contentUri = "content://attachment/2"), + attachment(contentUri = "content://attachment/3"), + ), + result.attachmentsToAdd, + ) + assertTrue(result.didDropAttachments) + } + + @Test + fun invoke_ignoresDuplicatesWithoutWarning() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/1"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 1)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals(emptyList(), result.attachmentsToAdd) + assertFalse(result.didDropAttachments) + } + + @Test + fun invoke_exactLimitDoesNotWarn() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/2"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 2)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals(attachmentsToAdd, result.attachmentsToAdd) + assertFalse(result.didDropAttachments) + } + + private fun attachment(contentUri: String): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = contentUri, + ) + } + + private fun createResolveDraftAttachmentsWithinLimit( + attachmentLimit: Int, + ): ResolveDraftAttachmentsWithinLimit { + val subscriptionsRepository = mockk() + every { subscriptionsRepository.resolveAttachmentLimit() } returns attachmentLimit + + return ResolveDraftAttachmentsWithinLimitImpl( + subscriptionsRepository = subscriptionsRepository, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt new file mode 100644 index 00000000..12c68997 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt @@ -0,0 +1,179 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +private const val SIM_1_ID = "self-sim-1" +private const val SIM_2_ID = "self-sim-2" +private const val SIM_1_NAME = "SIM 1" +private const val SIM_2_NAME = "SIM 2" + +private val SIM_DISPLAY_NAMES = mapOf( + SIM_1_ID to SIM_1_NAME, + SIM_2_ID to SIM_2_NAME, +) + +class ResolveConversationMessageSimDisplayNameTest { + + @Test + fun returnsNullWhenSingleSubscription() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = mapOf(SIM_1_ID to SIM_1_NAME), + ) + + assertNull(result) + } + + @Test + fun returnsNullWhenMessageHasNoSelfParticipantId() { + val message = message(selfParticipantId = null, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } + + @Test + fun returnsNullWhenSelfParticipantIdMissingFromMap() { + val message = message(selfParticipantId = "unknown", isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } + + @Test + fun returnsDisplayNameWhenLastMessageInConversation() { + val message = message(selfParticipantId = SIM_2_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_2_NAME, result) + } + + @Test + fun returnsDisplayNameWhenNextMessageUsesDifferentSim() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_2_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } + + @Test + fun returnsDisplayNameWhenNextMessageHasOppositeDirection() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } + + @Test + fun returnsNullWhenInsideContiguousSimRun() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } + + @Test + fun returnsDisplayNameForMmsDownloadInsideContiguousSimRun() { + val message = message( + selfParticipantId = SIM_1_ID, + isIncoming = true, + mmsDownload = mmsDownload(), + ) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } +} + +private fun message( + selfParticipantId: String?, + isIncoming: Boolean, + mmsDownload: MmsDownloadUiModel? = null, +): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = "id-${selfParticipantId.orEmpty()}-$isIncoming", + conversationId = "conversation", + text = "text", + parts = listOf( + ConversationMessagePartUiModel.Text(text = "text"), + ), + sentTimestamp = 0L, + receivedTimestamp = 0L, + displayTimestamp = 0L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = isIncoming, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = selfParticipantId, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = mmsDownload, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} + +private fun mmsDownload(): MmsDownloadUiModel { + return MmsDownloadUiModel( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + sizeBytes = 0L, + expiryTimestamp = 0L, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt new file mode 100644 index 00000000..5eed8921 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt @@ -0,0 +1,426 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.data.contact.repository.ContactsRepository +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.util.PhoneUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +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 + +@OptIn(ExperimentalCoroutinesApi::class) +internal class RecipientPickerDelegateImplTest { + + private val phoneUtilsInstance = mockk(relaxed = true) + + @Before + fun setUp() { + mockkStatic(PhoneUtils::class) + mockkStatic(MmsSmsUtils::class) + every { PhoneUtils.getDefault() } returns phoneUtilsInstance + every { PhoneUtils.isValidSmsMmsDestination(any()) } answers { + val raw = firstArg() + raw.isNotBlank() && raw.any { character -> character.isDigit() } + } + every { MmsSmsUtils.isEmailAddress(any()) } answers { + val raw = firstArg() + "@" in raw + } + every { phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any()) } answers { + firstArg() + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun emptyQueryAndEmptyRepoYieldsEmptyItems() = runTest { + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to emptyPage()), + ) + + val finalState = bindAndAwait(delegate = delegate) + + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.canLoadMore) + assertTrue(finalState.hasContactsPermission) + assertFalse(finalState.isLoading) + } + + @Test + fun searchReturnsMultiDestinationContact() = runTest { + val multiDestContact = contact( + id = 11L, + displayName = "Multi Dest", + destinations = listOf( + destination(value = "+15550001", contactId = 11L), + destination(value = "+15550002", contactId = 11L), + destination(value = "multi@example.com", contactId = 11L, isEmail = true), + ), + ) + val delegate = createDelegate( + initialQuery = "multi", + pages = mapOf( + searchKey(query = "multi", offset = 0) to pageOf(multiDestContact), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(11L, contactItem.contact.id) + assertEquals(3, contactItem.destinations.size) + assertEquals( + listOf("+15550001", "+15550002", "multi@example.com"), + contactItem.destinations.map { it.value }, + ) + } + + @Test + fun excludedDestinationRemovesMatchingDestinationButKeepsContact() = runTest { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("(555) 000-0001") + } returns "+15550001" + val multiDestContact = contact( + id = 12L, + displayName = "Excluded Sample", + destinations = listOf( + destination( + value = "+15550001", + contactId = 12L, + normalizedValue = "+15550001", + ), + destination( + value = "+15550002", + contactId = 12L, + normalizedValue = "+15550002", + ), + ), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(multiDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("(555) 000-0001")) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(1, contactItem.destinations.size) + assertEquals("+15550002", contactItem.destinations.single().value) + } + + @Test + fun excludedDestinationCanonicalizesIncomingValues() = runTest { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("+1 555-000-0001") + } returns "+15550001" + val singleDestContact = contact( + id = 13L, + displayName = "Cross Format", + destinations = listOf( + destination( + value = "(555) 000-0001", + contactId = 13L, + normalizedValue = "+15550001", + ), + ), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(singleDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("+1 555-000-0001")) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItems = finalState.items + .filterIsInstance() + assertTrue(contactItems.isEmpty()) + } + + @Test + fun excludingAllDestinationsTriggersFallbackToNextPage() = runTest { + val firstPageContact = contact( + id = 21L, + displayName = "All Excluded", + destinations = listOf( + destination(value = "+15550001", contactId = 21L), + destination(value = "+15550002", contactId = 21L), + ), + ) + val secondPageContact = contact( + id = 22L, + displayName = "Has Free Destination", + destinations = listOf(destination(value = "+15550003", contactId = 22L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstPageContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondPageContact), + nextOffset = null, + ), + ), + ) + delegate.onExcludedDestinationsChanged( + destinations = setOf("+15550001", "+15550002"), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(22L, contactItem.contact.id) + assertFalse(finalState.canLoadMore) + } + + @Test + fun loadMoreAppendsNextPage() = runTest { + val firstContact = contact( + id = 31L, + displayName = "Alpha", + destinations = listOf(destination(value = "+11111111", contactId = 31L)), + ) + val secondContact = contact( + id = 32L, + displayName = "Beta", + destinations = listOf(destination(value = "+22222222", contactId = 32L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondContact), + nextOffset = null, + ), + ), + ) + + bindAndAwait(delegate = delegate) + delegate.onLoadMore() + testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) + testScheduler.runCurrent() + + val finalState = delegate.state.value + val contactItems = finalState.items.filterIsInstance() + assertEquals(listOf(31L, 32L), contactItems.map { it.contact.id }) + assertFalse(finalState.canLoadMore) + } + + @Test + fun missingContactsPermissionEmitsEmptyState() = runTest { + val delegate = createDelegate( + initialQuery = "", + pages = emptyMap(), + isPermissionGranted = false, + ) + + val finalState = bindAndAwait(delegate = delegate) + + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.hasContactsPermission) + assertFalse(finalState.canLoadMore) + } + + @Test + fun syntheticPhoneAppearsWhenQueryHasDigitsAndNoMatchingDestination() = runTest { + val contactWithDifferentNumber = contact( + id = 41L, + displayName = "Some Person", + destinations = listOf(destination(value = "+19999999", contactId = 41L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithDifferentNumber), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("5550001", syntheticItem.destination) + assertEquals(2, finalState.items.size) + } + + @Test + fun syntheticPhoneSuppressedWhenContactHasMatchingDestination() = runTest { + val contactWithMatchingNumber = contact( + id = 51L, + displayName = "Match", + destinations = listOf(destination(value = "5550001", contactId = 51L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithMatchingNumber), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val syntheticItems = finalState.items + .filterIsInstance() + assertTrue(syntheticItems.isEmpty()) + assertEquals(1, finalState.items.size) + } + + private fun TestScope.bindAndAwait( + delegate: RecipientPickerDelegateImpl, + ): RecipientPickerUiState { + delegate.bind(scope = backgroundScope) + testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) + testScheduler.runCurrent() + return delegate.state.value + } + + private fun TestScope.createDelegate( + initialQuery: String, + pages: Map, + isPermissionGranted: Boolean = true, + ): RecipientPickerDelegateImpl { + return RecipientPickerDelegateImpl( + contactDestinationFormatter = ContactDestinationFormatterImpl(), + contactsRepository = mockContactsRepository(pages = pages), + isReadContactsPermissionGranted = mockIsReadContactsPermissionGranted( + isPermissionGranted = isPermissionGranted, + ), + savedStateHandle = SavedStateHandle( + initialState = mapOf("search_query" to initialQuery), + ), + defaultDispatcher = UnconfinedTestDispatcher(scheduler = testScheduler), + ) + } + + private fun mockContactsRepository( + pages: Map, + ): ContactsRepository { + val contactsRepository = mockk() + every { + contactsRepository.searchContacts( + query = any(), + offset = any(), + ) + } answers { + val key = searchKey( + query = firstArg(), + offset = secondArg(), + ) + val page = pages[key] ?: emptyPage() + flowOf(page) + } + + return contactsRepository + } + + private fun mockIsReadContactsPermissionGranted( + isPermissionGranted: Boolean, + ): IsReadContactsPermissionGranted { + val isReadContactsPermissionGranted = mockk() + every { isReadContactsPermissionGranted() } returns isPermissionGranted + + return isReadContactsPermissionGranted + } + + private fun searchKey(query: String, offset: Int): SearchKey { + return SearchKey(query = query, offset = offset) + } + + private fun emptyPage(): ContactsPage { + return ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + } + + private fun pageOf(vararg contacts: Contact): ContactsPage { + return ContactsPage( + contacts = contacts.toList().toImmutableList(), + nextOffset = null, + ) + } + + private fun contact( + id: Long, + displayName: String, + destinations: List, + ): Contact { + return Contact( + id = id, + lookupKey = "lookup_$id", + displayName = displayName, + photoUri = null, + destinations = destinations.toImmutableList(), + ) + } + + private fun destination( + value: String, + contactId: Long, + isEmail: Boolean = false, + normalizedValue: String = value, + displayValue: String = value, + ): ContactDestination { + return ContactDestination( + dataId = contactId * 100 + value.hashCode().toLong(), + contactId = contactId, + value = value, + normalizedValue = normalizedValue, + displayValue = displayValue, + kind = when { + isEmail -> ContactDestination.Kind.EMAIL + else -> ContactDestination.Kind.PHONE + }, + type = 1, + customLabel = null, + isPrimary = false, + isSuperPrimary = false, + ) + } + + private data class SearchKey( + val query: String, + val offset: Int, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index 55e7ddaa..8c07aba4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,8 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index a0631a34..0f643760 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -6,6 +6,9 @@ complexity: LongParameterList: ignoreDefaultParameters: true TooManyFunctions: + allowedFunctionsPerClass: 60 + allowedFunctionsPerFile: 15 + allowedFunctionsPerInterface: 50 ignoreAnnotatedFunctions: - Preview @@ -15,9 +18,15 @@ naming: - Composable style: + ForbiddenComment: + active: false + MagicNumber: ignoreCompanionObjectPropertyDeclaration: true ignorePropertyDeclaration: true + ignoreAnnotated: + - Composable + UnusedPrivateFunction: ignoreAnnotated: - Preview diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99fe9844..5ebd90b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,32 @@ [versions] -agp = "9.1.0" -detekt = "2.0.0-alpha.2" +agp = "9.2.1" +detekt = "2.0.0-alpha.3" hilt = "2.59.2" -kotlin = "2.3.20" +kotlin = "2.3.21" +kotlinx-serialization = "1.11.0" ksp = "2.3.6" + +#noinspection UnusedVersionCatalogEntry ktlint = "1.8.0" ktlint-gradle = "14.2.0" activity-compose = "1.13.0" appcompat = "1.7.1" +androidx-hilt = "1.3.0" +camerax = "1.6.1" coil = "3.4.0" -compose-bom = "2026.03.01" -coroutines = "1.10.2" -glide = "5.0.5" -guava = "33.5.0-android" +compose-bom = "2026.05.00" +coroutines = "1.11.0" +glide = "5.0.7" +guava = "33.6.0-android" jsr305 = "3.0.2" -libphonenumber = "9.0.26" +kotlinx-collections-immutable = "0.4.0" +libphonenumber = "9.0.30" lifecycle = "2.10.0" +navigation3 = "1.1.1" +paging = "3.5.0" palette = "1.0.0" +photo-picker = "1.0.0-alpha01" preference = "1.2.1" recyclerview = "1.4.0" @@ -27,9 +36,18 @@ mockk = "1.14.9" robolectric = "4.16.1" turbine = "1.2.1" +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" + [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "camerax" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } @@ -41,17 +59,26 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } + +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" } +androidx-photo-picker = { module = "androidx.photopicker:photopicker-compose", version.ref = "photo-picker" } androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } guava = { module = "com.google.guava:guava", version.ref = "guava" } @@ -61,8 +88,10 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" } @@ -77,13 +106,18 @@ hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -android-library = { id = "com.android.library", version.ref = "agp" } detekt = { id = "dev.detekt", version.ref = "detekt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 96f92386..d162dcd0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -86,6 +86,11 @@ + + + + + @@ -100,14 +105,6 @@ - - - - - - - - @@ -138,6 +135,9 @@ + + + @@ -254,6 +254,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -282,9 +396,6 @@ - - - @@ -313,11 +424,6 @@ - - - - - @@ -337,104 +443,90 @@ - - - + + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - - - - - - + + - - - - - - - - - - - - + + + - - - + + + - - - + + + - - + + - - + + - - + + @@ -488,20 +580,20 @@ - - - + + + - - - + + + - + - - + + @@ -523,285 +615,303 @@ - - - + + + + + + + + - - - + + + + + + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - + + + + + + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - - + + - - - + + + - - + + - + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - - + + - - - + + + - - - + + + - + - - + + + + + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + @@ -823,6 +933,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -848,6 +991,20 @@ + + + + + + + + + + + + + + @@ -936,25 +1093,25 @@ - - - + + + - - + + - + - - - + + + - - + + - + @@ -1017,9 +1174,6 @@ - - - @@ -1096,6 +1250,9 @@ + + + @@ -1119,6 +1276,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1141,6 +1320,11 @@ + + + + + @@ -1155,9 +1339,6 @@ - - - @@ -1174,9 +1355,6 @@ - - - @@ -1200,6 +1378,11 @@ + + + + + @@ -1218,6 +1401,14 @@ + + + + + + + + @@ -1230,6 +1421,11 @@ + + + + + @@ -1239,9 +1435,6 @@ - - - @@ -1296,9 +1489,6 @@ - - - @@ -1320,9 +1510,6 @@ - - - @@ -1339,9 +1526,6 @@ - - - @@ -1378,6 +1562,11 @@ + + + + + @@ -1406,9 +1595,6 @@ - - - @@ -1425,9 +1611,6 @@ - - - @@ -1470,6 +1653,22 @@ + + + + + + + + + + + + + + + + @@ -1484,9 +1683,6 @@ - - - @@ -1535,6 +1731,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1546,23 +1775,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + - + - + - - + + @@ -1570,13 +1842,15 @@ + + + + + - - - @@ -1584,6 +1858,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1595,6 +1946,17 @@ + + + + + + + + + + + @@ -1680,6 +2042,11 @@ + + + + + @@ -1699,11 +2066,6 @@ - - - - - @@ -1714,17 +2076,6 @@ - - - - - - - - - - - @@ -1752,14 +2103,6 @@ - - - - - - - - @@ -1786,9 +2129,6 @@ - - - @@ -1801,53 +2141,15 @@ - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - + + @@ -1861,46 +2163,26 @@ - - - + + + - - + + - - - - - + + - - - - - - + + + - - + + - - - - - - - - - - - - - - - - + + @@ -1914,32 +2196,26 @@ - - - - - - + + + - - + + - - + + - - - + + + - - + + - - - - - + + @@ -1953,15 +2229,50 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + @@ -2119,245 +2430,237 @@ - - + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - - - - - - - - + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -2368,59 +2671,59 @@ - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -2445,324 +2748,267 @@ - - - + + + - - + + - - + + - - - - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - + + + - - - - - + + - - + + - - - - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - - - + + @@ -2778,57 +3024,57 @@ - - - + + + - - + + - - + + - - + + - - + + - + - + - - + + - - + + - - + + - + - - - + + + - - + + - - + + - - + + @@ -2866,6 +3112,9 @@ + + + @@ -2894,6 +3143,11 @@ + + + + + @@ -2901,9 +3155,6 @@ - - - @@ -2917,9 +3168,6 @@ - - - @@ -2946,6 +3194,20 @@ + + + + + + + + + + + + + + @@ -2956,6 +3218,11 @@ + + + + + @@ -2977,6 +3244,12 @@ + + + + + + @@ -3028,9 +3301,6 @@ - - - @@ -3061,8 +3331,10 @@ - - + + + + @@ -3179,14 +3451,6 @@ - - - - - - - - @@ -3254,6 +3518,12 @@ + + + + + + @@ -3291,18 +3561,18 @@ - - - + + + - - + + - - + + - - + + @@ -3335,6 +3605,11 @@ + + + + + @@ -3408,6 +3683,9 @@ + + + @@ -3428,12 +3706,12 @@ - - - + + + - - + + @@ -3461,9 +3739,9 @@ - - - + + + @@ -3495,6 +3773,9 @@ + + + @@ -3589,9 +3870,6 @@ - - - @@ -3600,6 +3878,9 @@ + + + @@ -3619,9 +3900,6 @@ - - - @@ -3630,9 +3908,6 @@ - - - @@ -3661,9 +3936,6 @@ - - - @@ -3672,9 +3944,6 @@ - - - @@ -3683,9 +3952,6 @@ - - - @@ -3694,9 +3960,6 @@ - - - @@ -3705,9 +3968,6 @@ - - - @@ -3716,9 +3976,6 @@ - - - @@ -3744,23 +4001,23 @@ - - - + + + - - + + - - + + - - + + - - - + + + @@ -3936,20 +4193,6 @@ - - - - - - - - - - - - - - @@ -3969,11 +4212,30 @@ + + + + + + + + + + + + + + + + + + + @@ -4084,212 +4346,212 @@ - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + @@ -4297,9 +4559,9 @@ - - - + + + @@ -4313,15 +4575,12 @@ - - - - - - + + + - - + + @@ -4388,6 +4647,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4458,6 +4749,9 @@ + + + @@ -4561,9 +4855,6 @@ - - - @@ -4709,6 +5000,9 @@ + + + @@ -4728,6 +5022,9 @@ + + + @@ -4747,6 +5044,9 @@ + + + @@ -4766,6 +5066,9 @@ + + + @@ -4785,6 +5088,9 @@ + + + @@ -4804,6 +5110,9 @@ + + + @@ -4823,6 +5132,9 @@ + + + @@ -4842,6 +5154,9 @@ + + + @@ -4871,6 +5186,9 @@ + + + @@ -4890,6 +5208,9 @@ + + + @@ -4909,6 +5230,9 @@ + + + @@ -4917,9 +5241,6 @@ - - - @@ -4928,9 +5249,6 @@ - - - @@ -4939,6 +5257,9 @@ + + + @@ -5368,12 +5689,6 @@ - - - - - - @@ -5382,6 +5697,9 @@ + + + @@ -5390,6 +5708,12 @@ + + + + + + @@ -5423,6 +5747,9 @@ + + + @@ -5544,20 +5871,6 @@ - - - - - - - - - - - - - - @@ -5577,55 +5890,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -5677,49 +5941,31 @@ - - - - - - - - - - - - - - - - - - @@ -5760,9 +6006,6 @@ - - - @@ -5799,132 +6042,118 @@ - - - - - - - - - - - - - - - - + + + - - + + - + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - + - + + + + + + - - - + + + - - + + - + - - - + + + - - + + - + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - + + + - - + + - + - - - + + + - - + + @@ -5935,44 +6164,44 @@ - - - + + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + @@ -5984,94 +6213,94 @@ - - - + + + - - + + - + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + @@ -6083,17 +6312,39 @@ - - - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + @@ -6117,9 +6368,6 @@ - - - @@ -6135,18 +6383,26 @@ - - - + + + - - + + - - + + + + + + + - - + + + + + @@ -6157,23 +6413,37 @@ - - - + + + + + + - - + + - - - + + + - - + + - - + + + + + + + + + + + + + @@ -6207,9 +6477,6 @@ - - - @@ -6255,6 +6522,17 @@ + + + + + + + + + + + @@ -6281,16 +6559,16 @@ - - - - - + + + + + @@ -6312,7 +6590,18 @@ - + + + + + + + + + + + + @@ -6330,18 +6619,12 @@ - - - - - - - - - + + + - - + + @@ -6365,7 +6648,18 @@ - + + + + + + + + + + + + @@ -6383,75 +6677,81 @@ - - - - - - + + + - - - - - + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + + + + + + + + + + + - - + + + + @@ -6461,19 +6761,43 @@ - - + + + + + + + + + + - - - + + + - - + + + + + + + + + + + + + + + + + + - - + + @@ -6483,12 +6807,23 @@ + + + + + + + + + + + @@ -6504,17 +6839,19 @@ + + + + + - - - - - - + + + @@ -6531,19 +6868,16 @@ - - - - - - + + + - - + + - - + + @@ -6553,6 +6887,9 @@ + + + @@ -6576,6 +6913,14 @@ + + + + + + + + @@ -6592,15 +6937,20 @@ - - - + + + + + + + + - - + + - - + + @@ -6611,9 +6961,14 @@ - - - + + + + + + + + @@ -6621,9 +6976,15 @@ - - - + + + + + + + + + @@ -6637,17 +6998,6 @@ - - - - - - - - - - - @@ -6800,11 +7150,6 @@ - - - - - @@ -7067,9 +7412,38 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8e61ef12..2b7a686b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/res/drawable-hdpi/ic_camera_front_light.png b/res/drawable-hdpi/ic_camera_front_light.png deleted file mode 100644 index c0ddaf6d..00000000 Binary files a/res/drawable-hdpi/ic_camera_front_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_camera_light.png b/res/drawable-hdpi/ic_camera_light.png deleted file mode 100644 index d0406e4b..00000000 Binary files a/res/drawable-hdpi/ic_camera_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_camera_rear_light.png b/res/drawable-hdpi/ic_camera_rear_light.png deleted file mode 100644 index 6624c55a..00000000 Binary files a/res/drawable-hdpi/ic_camera_rear_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_checkbox_outline_light.png b/res/drawable-hdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 44ed34ef..00000000 Binary files a/res/drawable-hdpi/ic_checkbox_outline_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_image_light.png b/res/drawable-hdpi/ic_image_light.png deleted file mode 100644 index 744f9201..00000000 Binary files a/res/drawable-hdpi/ic_image_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_audio_mic.png b/res/drawable-hdpi/ic_mp_audio_mic.png deleted file mode 100644 index 9c005d6d..00000000 Binary files a/res/drawable-hdpi/ic_mp_audio_mic.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_camera_small_light.png b/res/drawable-hdpi/ic_mp_camera_small_light.png deleted file mode 100644 index 6e3b09b3..00000000 Binary files a/res/drawable-hdpi/ic_mp_camera_small_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_capture_stop_large_light.png b/res/drawable-hdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index 5e29fdd5..00000000 Binary files a/res/drawable-hdpi/ic_mp_capture_stop_large_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_full_screen_light.png b/res/drawable-hdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 9c69eb8f..00000000 Binary files a/res/drawable-hdpi/ic_mp_full_screen_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_video_large_light.png b/res/drawable-hdpi/ic_mp_video_large_light.png deleted file mode 100644 index d6db42cf..00000000 Binary files a/res/drawable-hdpi/ic_mp_video_large_light.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_mp_video_small_light.png b/res/drawable-hdpi/ic_mp_video_small_light.png deleted file mode 100644 index 9816af7e..00000000 Binary files a/res/drawable-hdpi/ic_mp_video_small_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_camera_front_light.png b/res/drawable-mdpi/ic_camera_front_light.png deleted file mode 100644 index a537897b..00000000 Binary files a/res/drawable-mdpi/ic_camera_front_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_camera_light.png b/res/drawable-mdpi/ic_camera_light.png deleted file mode 100644 index 429bef4c..00000000 Binary files a/res/drawable-mdpi/ic_camera_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_camera_rear_light.png b/res/drawable-mdpi/ic_camera_rear_light.png deleted file mode 100644 index 4da3328f..00000000 Binary files a/res/drawable-mdpi/ic_camera_rear_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_checkbox_outline_light.png b/res/drawable-mdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 4c3c7225..00000000 Binary files a/res/drawable-mdpi/ic_checkbox_outline_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_image_light.png b/res/drawable-mdpi/ic_image_light.png deleted file mode 100644 index 3bd919e3..00000000 Binary files a/res/drawable-mdpi/ic_image_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_audio_mic.png b/res/drawable-mdpi/ic_mp_audio_mic.png deleted file mode 100644 index 918d799b..00000000 Binary files a/res/drawable-mdpi/ic_mp_audio_mic.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_camera_small_light.png b/res/drawable-mdpi/ic_mp_camera_small_light.png deleted file mode 100644 index fe1dc7ef..00000000 Binary files a/res/drawable-mdpi/ic_mp_camera_small_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_capture_stop_large_light.png b/res/drawable-mdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index a9705c1b..00000000 Binary files a/res/drawable-mdpi/ic_mp_capture_stop_large_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_full_screen_light.png b/res/drawable-mdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 5462c598..00000000 Binary files a/res/drawable-mdpi/ic_mp_full_screen_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_video_large_light.png b/res/drawable-mdpi/ic_mp_video_large_light.png deleted file mode 100644 index fa8e3e2f..00000000 Binary files a/res/drawable-mdpi/ic_mp_video_large_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_mp_video_small_light.png b/res/drawable-mdpi/ic_mp_video_small_light.png deleted file mode 100644 index 9204827a..00000000 Binary files a/res/drawable-mdpi/ic_mp_video_small_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_camera_front_light.png b/res/drawable-xhdpi/ic_camera_front_light.png deleted file mode 100644 index 9cd577da..00000000 Binary files a/res/drawable-xhdpi/ic_camera_front_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_camera_light.png b/res/drawable-xhdpi/ic_camera_light.png deleted file mode 100644 index 3b74ad65..00000000 Binary files a/res/drawable-xhdpi/ic_camera_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_camera_rear_light.png b/res/drawable-xhdpi/ic_camera_rear_light.png deleted file mode 100644 index 92727afb..00000000 Binary files a/res/drawable-xhdpi/ic_camera_rear_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_checkbox_outline_light.png b/res/drawable-xhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 64f0a2bc..00000000 Binary files a/res/drawable-xhdpi/ic_checkbox_outline_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_image_light.png b/res/drawable-xhdpi/ic_image_light.png deleted file mode 100644 index b2aecfb5..00000000 Binary files a/res/drawable-xhdpi/ic_image_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_audio_mic.png b/res/drawable-xhdpi/ic_mp_audio_mic.png deleted file mode 100644 index b35d0f1e..00000000 Binary files a/res/drawable-xhdpi/ic_mp_audio_mic.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_camera_small_light.png b/res/drawable-xhdpi/ic_mp_camera_small_light.png deleted file mode 100644 index 75094e11..00000000 Binary files a/res/drawable-xhdpi/ic_mp_camera_small_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index 62125b3f..00000000 Binary files a/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_full_screen_light.png b/res/drawable-xhdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 02cbd48d..00000000 Binary files a/res/drawable-xhdpi/ic_mp_full_screen_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_video_large_light.png b/res/drawable-xhdpi/ic_mp_video_large_light.png deleted file mode 100644 index 723b0529..00000000 Binary files a/res/drawable-xhdpi/ic_mp_video_large_light.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_mp_video_small_light.png b/res/drawable-xhdpi/ic_mp_video_small_light.png deleted file mode 100644 index 00fde5f9..00000000 Binary files a/res/drawable-xhdpi/ic_mp_video_small_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_camera_front_light.png b/res/drawable-xxhdpi/ic_camera_front_light.png deleted file mode 100644 index 53616e48..00000000 Binary files a/res/drawable-xxhdpi/ic_camera_front_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_camera_light.png b/res/drawable-xxhdpi/ic_camera_light.png deleted file mode 100644 index 64e9873c..00000000 Binary files a/res/drawable-xxhdpi/ic_camera_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_camera_rear_light.png b/res/drawable-xxhdpi/ic_camera_rear_light.png deleted file mode 100644 index 8e478e33..00000000 Binary files a/res/drawable-xxhdpi/ic_camera_rear_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index cc77c5ed..00000000 Binary files a/res/drawable-xxhdpi/ic_checkbox_outline_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_image_light.png b/res/drawable-xxhdpi/ic_image_light.png deleted file mode 100644 index 16b7e7be..00000000 Binary files a/res/drawable-xxhdpi/ic_image_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_audio_mic.png b/res/drawable-xxhdpi/ic_mp_audio_mic.png deleted file mode 100644 index a01388bb..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_audio_mic.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_camera_small_light.png b/res/drawable-xxhdpi/ic_mp_camera_small_light.png deleted file mode 100644 index 8012fcea..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_camera_small_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index d34667af..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxhdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 483a1a3d..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_full_screen_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_video_large_light.png b/res/drawable-xxhdpi/ic_mp_video_large_light.png deleted file mode 100644 index 0a34b5ed..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_video_large_light.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_mp_video_small_light.png b/res/drawable-xxhdpi/ic_mp_video_small_light.png deleted file mode 100644 index 1cb77983..00000000 Binary files a/res/drawable-xxhdpi/ic_mp_video_small_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_camera_front_light.png b/res/drawable-xxxhdpi/ic_camera_front_light.png deleted file mode 100644 index 3af175b8..00000000 Binary files a/res/drawable-xxxhdpi/ic_camera_front_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_camera_light.png b/res/drawable-xxxhdpi/ic_camera_light.png deleted file mode 100644 index c3484b6d..00000000 Binary files a/res/drawable-xxxhdpi/ic_camera_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_camera_rear_light.png b/res/drawable-xxxhdpi/ic_camera_rear_light.png deleted file mode 100644 index c59b65b6..00000000 Binary files a/res/drawable-xxxhdpi/ic_camera_rear_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxxhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index eb0a8033..00000000 Binary files a/res/drawable-xxxhdpi/ic_checkbox_outline_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_image_light.png b/res/drawable-xxxhdpi/ic_image_light.png deleted file mode 100644 index 0d229e26..00000000 Binary files a/res/drawable-xxxhdpi/ic_image_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_audio_mic.png b/res/drawable-xxxhdpi/ic_mp_audio_mic.png deleted file mode 100644 index 9f51c4b9..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_audio_mic.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_camera_small_light.png b/res/drawable-xxxhdpi/ic_mp_camera_small_light.png deleted file mode 100644 index d6d8c9d7..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_camera_small_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index 30e725ff..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxxhdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 4860109c..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_full_screen_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_video_large_light.png b/res/drawable-xxxhdpi/ic_mp_video_large_light.png deleted file mode 100644 index 32c43175..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_video_large_light.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_mp_video_small_light.png b/res/drawable-xxxhdpi/ic_mp_video_small_light.png deleted file mode 100644 index 6d2a14b1..00000000 Binary files a/res/drawable-xxxhdpi/ic_mp_video_small_light.png and /dev/null differ diff --git a/res/drawable/audio_record_control_button_background.xml b/res/drawable/audio_record_control_button_background.xml deleted file mode 100644 index 02f329ef..00000000 --- a/res/drawable/audio_record_control_button_background.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/res/drawable/gallery_image_background_selector.xml b/res/drawable/gallery_image_background_selector.xml deleted file mode 100644 index 7486fad8..00000000 --- a/res/drawable/gallery_image_background_selector.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/res/drawable/mediapicker_tab_button_background.xml b/res/drawable/mediapicker_tab_button_background.xml deleted file mode 100644 index 10c4474d..00000000 --- a/res/drawable/mediapicker_tab_button_background.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/res/layout/attachment_preview.xml b/res/layout/attachment_preview.xml deleted file mode 100644 index 36ea038d..00000000 --- a/res/layout/attachment_preview.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/res/layout/compose_message_view.xml b/res/layout/compose_message_view.xml deleted file mode 100644 index 8bb82494..00000000 --- a/res/layout/compose_message_view.xml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml deleted file mode 100644 index 88797044..00000000 --- a/res/layout/conversation_activity.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml deleted file mode 100644 index 0bf42f59..00000000 --- a/res/layout/conversation_fragment.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/conversation_message_view.xml b/res/layout/conversation_message_view.xml deleted file mode 100644 index 25d3840d..00000000 --- a/res/layout/conversation_message_view.xml +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/gallery_grid_item_view.xml b/res/layout/gallery_grid_item_view.xml deleted file mode 100644 index f8f39208..00000000 --- a/res/layout/gallery_grid_item_view.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_audio_chooser.xml b/res/layout/mediapicker_audio_chooser.xml deleted file mode 100644 index 795d2f85..00000000 --- a/res/layout/mediapicker_audio_chooser.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_camera_chooser.xml b/res/layout/mediapicker_camera_chooser.xml deleted file mode 100644 index 27d26bd0..00000000 --- a/res/layout/mediapicker_camera_chooser.xml +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_contact_chooser.xml b/res/layout/mediapicker_contact_chooser.xml deleted file mode 100644 index dcace4f5..00000000 --- a/res/layout/mediapicker_contact_chooser.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_fragment.xml b/res/layout/mediapicker_fragment.xml deleted file mode 100644 index 2e414bde..00000000 --- a/res/layout/mediapicker_fragment.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/res/layout/mediapicker_gallery_chooser.xml b/res/layout/mediapicker_gallery_chooser.xml deleted file mode 100644 index b4f3c010..00000000 --- a/res/layout/mediapicker_gallery_chooser.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/res/layout/mediapicker_location_container.xml b/res/layout/mediapicker_location_container.xml deleted file mode 100644 index 6f476d3d..00000000 --- a/res/layout/mediapicker_location_container.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - diff --git a/res/layout/mediapicker_tab_button.xml b/res/layout/mediapicker_tab_button.xml deleted file mode 100644 index 353c871b..00000000 --- a/res/layout/mediapicker_tab_button.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/res/layout/sim_selector_item_view.xml b/res/layout/sim_selector_item_view.xml deleted file mode 100644 index a20c4a98..00000000 --- a/res/layout/sim_selector_item_view.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/res/layout/sim_selector_view.xml b/res/layout/sim_selector_view.xml deleted file mode 100644 index 816a2cc8..00000000 --- a/res/layout/sim_selector_view.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/res/menu/conversation_menu.xml b/res/menu/conversation_menu.xml deleted file mode 100644 index 7817a1f3..00000000 --- a/res/menu/conversation_menu.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/res/menu/gallery_picker_menu.xml b/res/menu/gallery_picker_menu.xml deleted file mode 100644 index 428f1e73..00000000 --- a/res/menu/gallery_picker_menu.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml index 901bd27a..7c57a7cd 100644 --- a/res/values-af/strings.xml +++ b/res/values-af/strings.xml @@ -37,15 +37,7 @@ "Gereeldes" "Alle kontakte" "Stuur na %s" - "Neem foto\'s of video" - "Kies prente van hierdie toestel af" - "Neem oudio op" "Kies foto" - "Die media is gekies." - "Die media is nie gekies nie." - "%d gekies" - "prent %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "prent" "Neem oudio op" "Deel" "Nou net" @@ -138,9 +130,6 @@ "Begin" "Kamera nie beskikbaar nie" - "Kamera nie beskikbaar nie" - "Video-opname nie beskikbaar nie" - "Kan nie media stoor nie" "Kan nie foto neem nie" "Terug" "Geargiveer" @@ -163,13 +152,10 @@ "Vee uit" "Kanselleer" "Aan" - "Kies verskeie prente" - "Bevestig keuse" "+%d" "Kan nie oudio opneem nie. Probeer weer." "Kan nie oudio speel nie. Probeer weer." "Kon nie oudio stoor nie. Probeer weer." - "Raak en hou" ", " " " ": " @@ -218,14 +204,7 @@ "Kon nie boodskap stuur nie. Raak om weer te probeer." "Gesprek met %s" "Vee onderwerp uit" - "Neem video op" - "Vang \'n stilprent vas" - "Neem foto" - "Begin video opneem" - "Skakel na volskerm-kamera oor" "Wissel tussen voorste en agterste kamera" - "Staak opname en heg video aan" - "Stop video-opname" "Foto\'s uit Boodskappe" %d foto\'s is na \"%s\"-album gestoor @@ -410,7 +389,6 @@ "Geblokkeerde kontakte" "DEBLOKKEER" "Geblokkeerde kontakte" - "Kies prent uit dokumentbiblioteek" "Stuur tans boodskap" "Boodskap gestuur" "Sellulêre data is afgeskakel. Gaan jou instellings na." diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml index bea9e6fe..4852f389 100644 --- a/res/values-am/strings.xml +++ b/res/values-am/strings.xml @@ -37,15 +37,7 @@ "ተደጋጋሚዎች" "ሁሉም ዕውቂያዎች" "ወደ %s ላክ" - "ስዕሎችን አንሳ ወይም ቪዲዮ ቅረጽ" - "ከዚህ መሣሪያ ላይ ምስሎችን ይምረጡ" - "ተሰሚ ቅረጽ" "ፎቶ ይምረጡ" - "ሚዲያው ተመርጧል።" - "ሚዲያው አልተመረጠም።" - "%d ተመርጠዋል" - "ምስል %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "ምስል" "ተሰሚ ቅረጽ" "አጋራ" "ገና አሁን" @@ -138,9 +130,6 @@ "ጀምር" "ካሜራ ሊገኝ አይችልም" - "ካሜራ ሊገኝ አይችልም" - "የቪዲዮ ቀረጻ ሊገኝ አይችልም" - "ማህደረ መረጃን ማስቀመጥ አይቻልም" "ስዕል ማንሳት አልተቻለም" "ተመለስ" "በማህደር ተቀምጧል" @@ -163,13 +152,10 @@ "ሰርዝ" "ይቅር" "ለ" - "በርካታ ምስሎችን ምረጥ" - "ምርጫ አረጋግጥ" "+%d" "ድምፅን መቅዳት አልተቻለም። እንደገና ይሞክሩ።" "ድምፅን ማጫወት አልተቻለም። እንደገና ይሞክሩ።" "ድምፅን ማስቀመጥ አልተቻለም። እንደገና ይሞክሩ።" - "ነካ ያድርጉ እና ይያዙ" "፣ " " " "፦ " @@ -218,14 +204,7 @@ "ያልተሳካ መልዕክት። ዳግም ለመሞከር ይንኩ።" "ከ%s ጋር ውይይት" "ርዕሰ ጉዳይ ሰርዝ" - "ቪዲዮ ቅረጽ" - "የቆመ ምስል አንሳ" - "ፎቶ አንሳ" - "ቪዲዮ መቅረጽ ጀምር" - "ወደ የሙሉ ማያ ገጽ ካሜራ ቀይር" "በፊት እና ኋላ ካሜራ መካከል ቀያይር" - "መቅረጽ አቁም እና ቪዲዮ አያይዝ" - "ቪዲዮ መቅዳትን አቁም" "ፎቶዎችን በመልዕክት መላክ" %d ፎቶዎች ወደ የ«%s» አልበም ተቀምጠዋል @@ -410,7 +389,6 @@ "የታገዱ እውቂያዎች" "እገዳ አንሳ" "የታገዱ እውቂያዎች" - "ከሰነዶች ቤተመዛግብት ምስል ይምረጡ" "መልዕክት በመላክ ላይ" "መልዕክት ተልኳል" "የተንቀሳቃሽ ስልክ ውሂብ ጠፍቷል። ቅንብሮችዎን ያረጋግጡ።" diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml index 1a0f4b90..b43bf535 100644 --- a/res/values-ar/strings.xml +++ b/res/values-ar/strings.xml @@ -37,15 +37,7 @@ "من يتم الاتصال بهم بشكل متكرر" "كل جهات الاتصال" "إرسال إلى %s" - "التقاط الصور أو الفيديو" - "اختيار صور من هذا الجهاز" - "تسجيل الصوت" "اختيار صورة" - "تم تحديد الوسائط." - "تم إلغاء تحديد الوسائط." - "تم تحديد %d" - "صورة %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "صورة" "تسجيل الصوت" "المشاركة" "للتو" @@ -174,9 +166,6 @@ "بدء" "لا تتوفر كاميرا" - "لا تتوفر كاميرا" - "ميزة التقاط الفيديو غير متاحة" - "يتعذر حفظ ملف الوسائط" "لا يمكن التقاط الصورة" "رجوع" "المؤرشفة" @@ -203,13 +192,10 @@ "حذف" "إلغاء" "إلى" - "تحديد صور متعددة" - "تأكيد الاختيار" "+%d" "يتعذر تسجيل الصوت. أعد المحاولة." "يتعذر تشغيل الصوت. أعد المحاولة." "تعذر حفظ الصوت. أعد المحاولة." - "اللمس مع الاستمرار" "، " " " ": " @@ -266,14 +252,7 @@ "رسالة تعذر إرسالها. المس لإعادة المحاولة." "محادثة مع %s" "حذف الموضوع" - "التقاط فيديو" - "التقاط صورة ثابتة" - "التقاط صورة" - "بدء تسجيل الفيديو" - "التبديل إلى الكاميرا بملء الشاشة" "التبديل بين الكاميرا الأمامية والخلفية" - "وقف التسجيل وإرفاق مقطع الفيديو" - "إيقاف تسجيل الفيديو" "صور المراسلة" لم يتم حفظ أية صورة (%d) في الألبوم \"%s\" @@ -514,7 +493,6 @@ "جهات الاتصال المحظورة" "إلغاء الحظر" "جهات الاتصال المحظورة" - "اختيار صورة من مكتبة المستندات" "جارٍ إرسال الرسالة" "تم إرسال الرسالة" "تم إيقاف اتصال بيانات الجوّال. تحقق من إعداداتك." diff --git a/res/values-az/strings.xml b/res/values-az/strings.xml index 56b42745..a9dea862 100644 --- a/res/values-az/strings.xml +++ b/res/values-az/strings.xml @@ -37,15 +37,7 @@ "Tez-tez olanlar" "Bütün kontaktlar" "%s ünvanına göndərin" - "Şəkilləri və ya videonu çəkin" - "Bu cihazdan şəkillər seçin" - "Səs yazın" "Foto seçin" - "Media seçildi." - "Media seçilmədi." - "%d seçilib" - "təsvir %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "təsvir" "Səs yazın" "Paylaş" "İndicə" @@ -138,9 +130,6 @@ "Başla" "Kamera əlçatmazdır" - "Kamera əlçatmazdır" - "Video çəkiliş əlçatmazdır" - "Medianı yadda saxlamaq olmur" "Şəkil çəkmək mümkün deyil" "Geri" "Arxivləşmiş" @@ -163,13 +152,10 @@ "Sil" "Ləğv et" "Kimə" - "Çox şəkil seçin" - "Seçimi təsdiq edin" "+%d" "Audionu qeydə almaq olmur. Yenidən cəhd edin." "Audionu oxutmaq olmur. Yenidən cəhd edin." "Audionu saxlamaq olmadı. Yenidən cəhd edin." - "Toxunun və saxlayın" ", " " " ": " @@ -218,14 +204,7 @@ "Mesaj göndərilmədi. Təkrar cəhd etmək üçün toxunun." "%s ilə söhbət" "Mövzunu silin" - "Video çəkin" - "Durğun şəkli çəkin" - "Şəkil çəkin" - "Video çəkilişə başlayın" - "Tam ekranlı kameraya keçin" "Ön və arxa kameralar arasında seçim edin" - "Çəkilişi dayandırın və videonu qoşun" - "Video qeydi dayandırın" "Foto mesajlaşma" %d foto \"%s\" albomunda yadda saxlanıldı @@ -410,7 +389,6 @@ "Bloklanmış kontaktlar" "BLOKDAN ÇIXARIN" "Bloklanmış kontaktlar" - "Sənəd kitabxanasından şəkil seçin" "Mesaj göndərilir" "Mesaj göndərildi" "Mobil data deaktivdir. Ayarları yoxlayın." diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml index c1b8401e..f95be4d6 100644 --- a/res/values-bg/strings.xml +++ b/res/values-bg/strings.xml @@ -37,15 +37,7 @@ "Често търсени" "Всички контакти" "Изпращане до %s" - "Правете снимки или видеоклипове" - "Избиране на изображения от това устройство" - "Запишете звук" "Избиране на снимка" - "Мултимедийният файл е избран." - "Изборът на мултимедиен файл е премахнат." - "Избрахте %d" - "изображение от %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "изображение" "Запис на звук" "Споделяне" "Току-що" @@ -138,9 +130,6 @@ "Стартиране" "Няма достъп до камерата" - "Няма достъп до камерата" - "Функцията за заснемане на видео не е налице" - "Медийният файл не може да се запази" "Не може да се направи снимка" "Назад" "Архивирани" @@ -163,13 +152,10 @@ "Изтриване" "Отказ" "До" - "Избиране на няколко изображения" - "Потвърждаване на избора" "+ %d" "Аудиото не може да се запише. Опитайте отново." "Аудиото не може да се възпроизведе. Опитайте отново." "Аудиото не можа да се запази. Опитайте отново." - "Докоснете и задръжте" ", " " ," ": " @@ -218,14 +204,7 @@ "Неуспешно съобщение. Докоснете, за да опитате отново." "Разговор с/ъс %s" "Изтриване на темата" - "Заснемане на видеоклип" - "Заснемане на кадър" - "Правене на снимка" - "Стартиране на записване на видеоклип" - "Превключване към камера на цял екран" "Превключване между предната и задната камера" - "Спиране на записването и прикачване на видеоклипа" - "Спиране на видеозаписа" "Снимки в Съобщения" %d снимки са запазени в албума „%s @@ -410,7 +389,6 @@ "Блокирани контакти" "ОТБЛОКИРАНЕ" "Блокирани контакти" - "Избиране на изображение от библиотеката с документи" "Съобщението се изпраща" "Съобщението е изпратено" "Мобилните данни са изключени. Проверете настройките си." diff --git a/res/values-bn/strings.xml b/res/values-bn/strings.xml index 96af70b2..0e9d33d5 100644 --- a/res/values-bn/strings.xml +++ b/res/values-bn/strings.xml @@ -37,15 +37,7 @@ "প্রায়শই ব্যবহৃত" "সকল পরিচিতি" "%s এ পাঠান" - "ছবি বা ভিডিও ক্যাপচার করুন" - "এই ডিভাইস থেকে চিত্র নির্বাচন করুন" - "অডিও রেকর্ড করুন" "ফটো চয়ন করুন" - "মিডিয়া নির্বাচিত হয়েছে।" - "মিডিয়া নির্বাচিত হয় নি।" - "%dটি নির্বাচন করা হয়েছে" - "চিত্র %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "চিত্র" "অডিও রেকর্ড করুন" "ভাগ করুন" "এইমাত্র" @@ -138,9 +130,6 @@ "আরম্ভ" "ক্যামেরা উপলব্ধ নয়" - "ক্যামেরা উপলব্ধ নয়" - "ভিডিও ক্যাপচার উপলব্ধ নয়" - "মিডিয়া সংরক্ষণ করা যাবে না" "ছবি তুলতে পারবেন না" "ফিরুন" "সংরক্ষণাগারভুক্ত" @@ -163,13 +152,10 @@ "মুছুন" "বাতিল করুন" "প্রাপক" - "একাধিক চিত্র নির্বাচন করুন" - "নির্বাচন নিশ্চিত করুন" "+%dটি" "অডিও রেকর্ড করা যাবে না। আবার চেষ্টা করুন।" "অডিও প্লে করা যাবে না। আবার চেষ্টা করুন।" "অডিও সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন" - "স্পর্শ করুন এবং ধরে রাখুন" ", " " " ": " @@ -218,14 +204,7 @@ "বার্তা পাঠাতে ব্যর্থ হয়েছে। পুনরায় চেষ্টা করতে স্পর্শ করুন।" "%s এর সাথে কথোপকথন" "বিষয় মুছুন" - "ভিডিও ক্যাপচার করুন" - "একটি স্থির চিত্র ক্যাপচার করুন" - "ছবি তুলুন" - "ভিডিও রেকর্ডিং শুরু করুন" - "সম্পূর্ণ স্ক্রীন ক্যামেরাতে স্যুইচ করুন" "সামনে ও পিছনে ক্যামেরার মধ্যে স্যুইচ করুন" - "রেকর্ডিং করা বন্ধ করুন এবং ভিডিও সংযুক্ত করুন" - "ভিডিও রেকর্ডিং বন্ধ করুন" "বার্তাপ্রেরণ ফটোগুলি" %dটি ফটো \"%s\" অ্যালবামে সংরক্ষিত হয়েছে @@ -410,7 +389,6 @@ "অবরুদ্ধ পরিচিতিগুলি" "অবরোধ মুক্ত করুন" "অবরুদ্ধ পরিচিতিগুলি" - "দস্তাবেজ লাইব্রেরি থেকে চিত্র নির্বাচন করুন" "বার্তা পাঠানো হচ্ছে" "বার্তা পাঠানো হয়েছে" "সেলুলার ডেটা বন্ধ আছে। আপনার সেটিংস চেক করুন।" diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml index 9f099088..8438e6ed 100644 --- a/res/values-ca/strings.xml +++ b/res/values-ca/strings.xml @@ -37,15 +37,7 @@ "Freqüents" "Tots els contactes" "Envia al %s" - "Captura fotos o vídeos" - "Tria imatges d\'aquest dispositiu." - "Enregistra l\'àudio" "Triar una foto" - "S\'ha seleccionat el fitxer multimèdia." - "No hi ha cap fitxer multimèdia seleccionat." - "Seleccionats: %d" - "imatge: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imatge" "Enregistra l\'àudio" "Comparteix" "Ara mateix" @@ -138,9 +130,6 @@ "Inicia" "La càmera no està disponible." - "La càmera no està disponible." - "La captura de vídeo no està disponible." - "No es pot desar el fitxer multimèdia." "No es pot fer una foto." "Enrere" "Arxivades" @@ -163,13 +152,10 @@ "Suprimeix" "Cancel·la" "Per a" - "Selecciona diverses imatges" - "Confirma la selecció" "+%d" "No es pot enregistrar l\'àudio. Torna-ho a provar." "No es pot reproduir l\'àudio. Torna-ho a provar." "No s\'ha pogut desar l\'àudio. Torna-ho a provar." - "Toca i mantén premut" ", " " " ": " @@ -218,14 +204,7 @@ "Hi ha hagut un problema amb aquest missatge. Toca per tornar-ho a provar." "Conversa amb %s" "Suprimeix l\'assumpte" - "Captura un vídeo" - "Captura una imatge fixa" - "Fes una foto" - "Comença a enregistrar vídeo" - "Canvia a la càmera de pantalla completa" "Canvia entre la càmera frontal i la posterior" - "Deixa d\'enregistrar i adjunta un vídeo" - "Atura l\'enregistrament de vídeo" "Fotos de missatges" S\'han desat %d fotos a l\'àlbum %s @@ -410,7 +389,6 @@ "Contactes bloquejats" "DESBLOQUEJA" "Contactes bloquejats" - "Tria una imatge de la biblioteca de documents." "S\'està enviant el missatge" "Missatge enviat" "Les dades del telèfon estan desactivades. Comprova\'n la configuració." diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml index 7e581127..13171c18 100644 --- a/res/values-cs/strings.xml +++ b/res/values-cs/strings.xml @@ -37,15 +37,7 @@ "Časté kontakty" "Všechny kontakty" "Odeslat na číslo %s" - "Pořídit fotky nebo video" - "Vyberte obrázky z tohoto zařízení." - "Nahrát zvuk" "Vybrat fotku" - "Médium je vybráno." - "Výběr média je zrušen." - "Vybráno: %d" - "obrázek %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "obrázek" "Nahrávání zvuku" "Sdílet" "Právě teď" @@ -156,9 +148,6 @@ "Spustit" "Fotoaparát není k dispozici" - "Fotoaparát není k dispozici." - "Záznam videa není k dispozici." - "Média nelze uložit." "Nelze pořídit fotku" "Zpět" "Archivováno" @@ -183,13 +172,10 @@ "Smazat" "Zrušit" "Komu" - "Vybrat několik obrázků" - "Potvrďte výběr" "+%d" "Zvuk nelze zaznamenat. Zkuste to znovu." "Zvuk nelze přehrát. Zkuste to znovu." "Zvuk nelze uložit. Zkuste to znovu." - "Dotyk s podržením" ", " " " ": " @@ -242,14 +228,7 @@ "Neúspěšná zpráva. Klepnutím pokus opakujte." "Konverzace s uživateli %s" "Smazat předmět" - "Natočit video" - "Pořídit statický snímek" - "Vyfotit" - "Začít natáčet video" - "Přepnout na fotoaparát na celou obrazovku" "Přepnout mezi předním a zadním fotoaparátem" - "Zastavit nahrávání a připojit video" - "Zastavit záznam videa" "Fotky z aplikace SMS a MMS" %d fotky byly uloženy do alba %s. @@ -462,7 +441,6 @@ "Blokované kontakty" "ODBLOKOVAT" "Blokované kontakty" - "Vyberte obrázek z knihovny dokumentů." "Odesílání zprávy" "Zpráva byla odeslána" "Mobilní datové přenosy jsou vypnuty. Zkontrolujte svá nastavení." diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml index 6d441da4..a0c0ae2a 100644 --- a/res/values-da/strings.xml +++ b/res/values-da/strings.xml @@ -37,15 +37,7 @@ "Mest brugte" "Alle kontaktpersoner" "Send til %s" - "Optag billeder eller video" - "Vælg billeder fra denne enhed" - "Optag lyd" "Vælg billede" - "Medierne er valgt." - "Medierne er fravalgt." - "Der er valgt %d" - "billede %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "billede" "Optag lyd" "Del" "Lige nu" @@ -138,9 +130,6 @@ "Start" "Kameraet er ikke tilgængeligt" - "Kameraet er ikke tilgængeligt" - "Videooptagelse er ikke tilgængelig" - "Medie kan ikke gemmes" "Der kan ikke tages billeder" "Tilbage" "Arkiveret" @@ -163,13 +152,10 @@ "Slet" "Annuller" "Til" - "Vælg flere billeder" - "Bekræft valg" "+ %d" "Der kan ikke optages lyd. Prøv igen." "Lyden kan ikke afspilles. Prøv igen." "Lyden kunne ikke gemmes. Prøv igen." - "Tryk og hold nede" ", " " " ": " @@ -218,14 +204,7 @@ "Mislykket besked. Tryk for at prøve igen." "Samtale med %s" "Slet emne" - "Optag video" - "Tag et stillbillede" - "Tag et billede" - "Start videooptagelse" - "Skift til kamera i fuld skærm" "Skift mellem front- og bagudvendt kamera" - "Stop optagelsen, og vedhæft video" - "Stands optagelse af video" "Billeder i Beskeder" %d billeder blev gemt i albummet \"%s\" @@ -410,7 +389,6 @@ "Blokerede kontakter" "OPHÆV BLOKERING" "Blokerede kontakter" - "Vælg billede fra dokumentbiblioteket" "Sender besked" "Beskeden er sendt" "Mobildata er slået fra. Kontrollér dine indstillinger." diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml index 4537b8bc..9f04be87 100644 --- a/res/values-de/strings.xml +++ b/res/values-de/strings.xml @@ -37,15 +37,7 @@ "Häufig genutzte Kontakte" "Alle Kontakte" "An %s senden" - "Bilder oder Videos aufnehmen" - "Bilder von diesem Gerät auswählen" - "Audio aufzeichnen" "Foto auswählen" - "Die Medien sind ausgewählt." - "Die Auswahl der Medien ist aufgehoben." - "%d ausgewählt" - "Bild: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "Bild" "Audio aufzeichnen" "Teilen" "Gerade eben" @@ -138,9 +130,6 @@ "Starten" "Kamera nicht verfügbar" - "Kamera nicht verfügbar" - "Videoaufnahme nicht verfügbar" - "Medien können nicht gespeichert werden." "Keine Bildaufnahme möglich" "Zurück" "Archiviert" @@ -163,13 +152,10 @@ "Löschen" "Abbrechen" "An" - "Mehrere Bilder auswählen" - "Auswahl bestätigen" "+%d" "Die Audioaufzeichnung ist nicht möglich. Bitte versuchen Sie es erneut." "Die Audiowiedergabe ist nicht möglich. Bitte versuchen Sie es erneut." "Die Audionachricht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut." - "Berühren und halten" ", " " " ": " @@ -218,14 +204,7 @@ "Fehler bei Nachricht. Zum Wiederholen tippen." "Unterhaltung mit %s" "Betreff löschen" - "Video aufnehmen" - "Standbild aufnehmen" - "Bild aufnehmen" - "Videoaufnahme starten" - "Zur Vollbildkamera wechseln" "Zwischen Kamera auf Vorder- und Rückseite wechseln" - "Aufnahme beenden und Video anhängen" - "Videoaufzeichnung stoppen" "Fotos in der SMS/MMS App" %d Fotos im Album \"%s\" gespeichert @@ -410,7 +389,6 @@ "Blockierte Kontakte" "Blockierung aufheben" "Blockierte Kontakte" - "Bild aus der Dokumentenbibliothek auswählen" "Nachricht wird gesendet" "Nachricht gesendet" "Mobilfunkdaten sind deaktiviert. Bitte überprüfen Sie Ihre Einstellungen." diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml index d74a84b1..49befda5 100644 --- a/res/values-el/strings.xml +++ b/res/values-el/strings.xml @@ -37,15 +37,7 @@ "Συχνή επικοινωνία" "Όλες οι επαφές" "Αποστολή σε %s" - "Λήψη εικόνων ή βίντεο" - "Επιλέξτε εικόνες από αυτήν τη συσκευή" - "Ηχογράφηση" "Επιλογή φωτογραφίας" - "Το μέσο είναι επιλεγμένο." - "Το μέσο δεν είναι επιλεγμένο." - "%d επιλεγμένα" - "εικόνα %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "εικόνα" "Ηχογράφηση" "Κοινή χρήση" "Μόλις τώρα" @@ -138,9 +130,6 @@ "Έναρξη" "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" - "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" - "Η εγγραφή βίντεο δεν είναι διαθέσιμη" - "Δεν είναι δυνατή η αποθήκευση πολυμέσων" "Δεν είναι δυνατή η λήψη φωτογραφίας" "Πίσω" "Αρχειοθετημένο" @@ -163,13 +152,10 @@ "Διαγραφή" "Ακύρωση" "Προς" - "Επιλογή πολλαπλών εικόνων" - "Επιβεβαίωση επιλογής" "+%d" "Δεν είναι δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." "Δεν είναι δυνατή η αναπαραγωγή ήχου. Δοκιμάστε ξανά." "Δεν ήταν δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." - "Άγγιγμα & κράτημα" ", " " " ": " @@ -218,14 +204,7 @@ "Αποτυχία μηνύματος. Αγγίξτε για επανάληψη." "Συνομιλία με %s" "Διαγραφή θέματος" - "Λήψη βίντεο" - "Τραβήξτε μια στατική εικόνα" - "Τραβήξτε μια φωτογραφία" - "Έναρξη εγγραφής βίντεο" - "Εναλλαγή σε κάμερα πλήρους οθόνης" "Εναλλαγή μεταξύ μπροστινής και της πίσω φωτογραφικής μηχανής" - "Διακοπή εγγραφής και επισύναψη βίντεο" - "Διακοπή εγγραφής βίντεο" "Φωτογραφίες Ανταλλαγής μηνυμάτων" %d φωτογραφίες αποθηκεύτηκαν στο λεύκωμα \"%s\" @@ -410,7 +389,6 @@ "Αποκλεισμένες επαφές" "ΚΑΤΑΡΓΗΣΗ ΑΠΟΚΛΕΙΣΜΟΥ" "Αποκλεισμένες επαφές" - "Επιλέξτε μια εικόνα από τη βιβλιοθήκη εγγράφων" "Αποστολή μηνύματος" "Το μήνυμα εστάλη" "Τα δεδομένα κινητής τηλεφωνίας έχουν απενεργοποιηθεί. Ελέγξτε τις ρυθμίσεις σας." diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rAU/strings.xml +++ b/res/values-en-rAU/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rGB/strings.xml +++ b/res/values-en-rGB/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rIN/strings.xml +++ b/res/values-en-rIN/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml index f474514c..19a3e04a 100644 --- a/res/values-es-rUS/strings.xml +++ b/res/values-es-rUS/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos los contactos" "Enviar a %s" - "Capturar imágenes o video" - "Seleccionar imágenes de este dispositivo" - "Grabar audio" "Elegir foto" - "El medio está seleccionado." - "Los medios no están seleccionados." - "Seleccionado: %d" - "imagen %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imagen" "Grabar audio" "Compartir" "Recién" @@ -138,9 +130,6 @@ "Iniciar" "Cámara no disponible" - "Cámara no disponible" - "Captura de video no disponible" - "No se puede guardar el contenido multimedia." "No se puede tomar la foto." "Atrás" "Archivadas" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imágenes" - "Confirmar selección" "%d más" "No se puede grabar el audio. Vuelve a intentarlo." "No se puede reproducir el audio. Vuelve a intentarlo." "No se pudo guardar el audio. Vuelve a intentarlo." - "Mantener presionado" ", " " " ": " @@ -218,14 +204,7 @@ "Se produjo un error en el mensaje. Toca para volver a intentarlo." "Conversación con %s" "Eliminar asunto" - "Capturar video" - "Capturar una imagen fija" - "Tomar fotografía" - "Iniciar grabación de video" - "Cambiar a cámara en pantalla completa" "Alternar entre cámara frontal y trasera" - "Detener la grabación y adjuntar video" - "Detener la grabación de video" "Fotos de Centro de Mensajes" Se guardaron %d fotos en el álbum \"%s\" @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Seleccionar imagen de la galería de documentos" "Enviando el mensaje" "Se envió el mensaje." "Los datos móviles están desactivados. Comprueba la configuración." diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml index 98e0a1d6..7b0b87d8 100644 --- a/res/values-es/strings.xml +++ b/res/values-es/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos los contactos" "Enviar a %s" - "Capturar imágenes o vídeo" - "Seleccionar imágenes de este dispositivo" - "Grabar audio" "Elegir foto" - "Contenido multimedia seleccionado." - "Contenido multimedia no seleccionado." - "Seleccionado: %d" - "imagen del %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imagen" "Grabar audio" "Compartir" "Ahora mismo" @@ -138,9 +130,6 @@ "Iniciar" "Cámara no disponible" - "Cámara no disponible" - "Captura de vídeo no disponible" - "No se puede guardar el archivo multimedia" "No se pueden hacer fotos" "Atrás" "Archivado" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imágenes" - "Confirmar selección" "+%d" "No se puede grabar el audio. Vuelve a intentarlo." "No se puede reproducir el audio. Vuelve a intentarlo." "No se ha podido guardar el audio. Vuelve a intentarlo." - "Mantener pulsado" ", " " " ": " @@ -218,14 +204,7 @@ "Error del mensaje. Toca para volver a intentarlo." "Conversación con %s" "Eliminar asunto" - "Capturar vídeo" - "Capturar una imagen fija" - "Hacer foto" - "Iniciar grabación de vídeo" - "Cambiar a cámara en pantalla completa" "Cambiar de la cámara frontal a la trasera" - "Dejar de grabar y adjuntar vídeo" - "Detener grabación de vídeo" "Fotos de Mensajes" %d fotos guardadas en el álbum %s @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Selecciona imagen de la biblioteca de documentos" "Enviando mensaje" "Mensaje enviado" "Los datos móviles están desactivados. Comprueba los ajustes." diff --git a/res/values-et/strings.xml b/res/values-et/strings.xml index e222a3ea..5a24faaf 100644 --- a/res/values-et/strings.xml +++ b/res/values-et/strings.xml @@ -37,15 +37,7 @@ "Sagedased" "Kõik kontaktid" "Saada numbrile %s" - "Jäädvustage pilte või videoid" - "Kujutiste valimine sellest seadmest" - "Heli salvestamine" "Foto valimine" - "Meedia on valitud." - "Meediat pole valitud." - "%d on valitud" - "pilt kuupäevaga %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "pilt" "Heli salvestamine" "Jagamine" "Hetk tagasi" @@ -138,9 +130,6 @@ "Käivita" "Kaamera pole saadaval" - "Kaamera pole saadaval" - "Video jäädvustamine pole saadaval" - "Meediat ei saa salvestada" "Pilti ei saa jäädvustada" "Tagasi" "Arhiivitud" @@ -163,13 +152,10 @@ "Kustuta" "Tühista" "Sihtkohta" - "Mitme kujutise valimine" - "Valiku kinnitamine" "ja veel %d" "Heli ei õnnestu salvestada. Proovige uuesti." "Heli ei saa esitada. Proovige uuesti." "Heli ei õnnestunud salvestada. Proovige uuesti." - "Puudutage pikalt" ", " " " ": " @@ -218,14 +204,7 @@ "Sõnumi saatmine ebaõnnestus. Puudutage uuesti proovimiseks." "Vestlus osalejatega %s" "Teema kustutamine" - "Video jäädvustamine" - "Foto jäädvustamine" - "Pildistamine" - "Video salvestamise alustamine" - "Lülitamine täisekraaniga kaamerale" "Esi- ja tagakaamera vahetamine" - "Salvestamise peatamine ja video lisamine" - "Lõpeta video salvestamine" "Sõnumside fotod" %d fotot salvestati albumisse „%s @@ -410,7 +389,6 @@ "Blokeeritud kontaktid" "DEBLOKEERIMINE" "Blokeeritud kontaktid" - "Dokumendikogust kujutise valimine" "Sõnumi saatmine" "Sõnum on saadetud" "Mobiilne andmeside on välja lülitatud. Kontrollige seadeid." diff --git a/res/values-eu/strings.xml b/res/values-eu/strings.xml index 17036814..9df7b987 100644 --- a/res/values-eu/strings.xml +++ b/res/values-eu/strings.xml @@ -37,15 +37,7 @@ "Maiz erabilitakoak" "Kontaktu guztiak" "Bidali hona: %s" - "Atera irudiak edo grabatu bideoa" - "Aukeratu gailu honetako irudiak" - "Grabatu audioa" "Aukeratu argazkia" - "Euskarria hautatu da." - "Euskarria desautatu da." - "%d hautatuta" - "Irudiaren data: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "irudia" "Grabatu audioa" "Partekatu" "Oraintxe" @@ -138,9 +130,6 @@ "Hasi" "Kamera ez dago erabilgarri" - "Kamera ez dago erabilgarri" - "Bideo-grabaketa ez dago erabilgarri" - "Ezin da gorde multimedia-elementua" "Ezin da atera argazkia" "Atzera" "Artxibatutakoak" @@ -163,13 +152,10 @@ "Ezabatu" "Utzi" "Hartzailea" - "Hautatu hainbat irudi" - "Berretsi hautapena" "+%d" "Ezin da grabatu audioa. Saiatu berriro." "Ezin da erreproduzitu audioa. Saiatu berriro." "Ezin izan da gorde audioa. Saiatu berriro." - "Eduki ukituta" ", " " " ": " @@ -218,14 +204,7 @@ "Mezuak huts egin du. Ukitu berriro saiatzeko." "Elkarrizketa erabiltzaile hauekin (%s)" "Ezabatu gaia" - "Grabatu bideoa" - "Atera irudi finkoa" - "Atera argazkia" - "Hasi bideoa grabatzen" - "Aldatu pantaila osoko kamerara" "Aldatu aurreko eta atzeko kameren artean" - "Utzi grabatzeari eta erantsi bideoa" - "Utzi bideoa grabatzeari" "Mezuak aplikazioko argazkiak" %d argazki gorde dira \"%s\" albumean @@ -410,7 +389,6 @@ "Blokeatutako kontaktuak" "DESBLOKEATU" "Blokeatutako kontaktuak" - "Aukeratu irudia dokumentuen liburutegitik" "Mezua bidaltzen" "Mezua bidali da" "Mugikorreko datuak desaktibatuta daude. Egiaztatu ezarpenak." diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml index a1bf9d87..387e8ede 100644 --- a/res/values-fa/strings.xml +++ b/res/values-fa/strings.xml @@ -37,15 +37,7 @@ "مکرر" "همه مخاطبین" "ارسال به %s" - "گرفتن عکس یا ویدیو" - "انتخاب تصاویر از این دستگاه" - "ضبط صدا" "انتخاب عکس" - "رسانه انتخاب شده است." - "رسانه انتخاب نشده است." - "%d مورد انتخاب شد" - "تاریخ تصویر %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "تصویر" "ضبط صدا" "اشتراک‌گذاری" "هم‌اکنون" @@ -138,9 +130,6 @@ "شروع" "دوربین در دسترس نیست" - "دوربین در دسترس نیست" - "ضبط ویدیو در دسترس نیست" - "رسانه ذخیره نمی‌شود" "عکس نمی‌گیرد" "برگشت" "بایگانی شده" @@ -163,13 +152,10 @@ "حذف" "لغو" "به" - "انتخاب چند تصویر" - "تأیید انتخاب" "+%d" "صدا ضبط نمی‌شود. دوباره امتحان کنید." "صدا پخش نمی‌شود. دوباره امتحان کنید." "صدا ذخیره نشد. دوباره امتحان کنید." - "لمس کنید و نگه‌دارید" "، " " " ": " @@ -218,14 +204,7 @@ "پیام ناموفق. برای امتحان مجدد، لمس کنید." "مکالمه با %s" "حذف موضوع" - "فیلم‌برداری" - "ضبط تصویر ثابت" - "عکس گرفتن" - "شروع فیلم‌برداری" - "تغییر به دوربین تمام صفحه" "جابه‌جایی بین دوربین جلو و عقب" - "توقف ضبط و پیوست ویدیو" - "توقف ضبط ویدیو" "عکس‌های «پیام‌رسانی»" %d عکس در آلبوم «%s» ذخیره شد @@ -410,7 +389,6 @@ "مخاطبین مسدود شده" "رفع انسداد" "مخاطبین مسدود شده" - "انتخاب تصویر از کتابخانه سند" "در حال ارسال پیام" "پیام ارسال شد" "داده شبکه تلفن همراه خاموش است. تنظیماتتان را بررسی کنید." diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml index a07825b4..1a4c76ba 100644 --- a/res/values-fi/strings.xml +++ b/res/values-fi/strings.xml @@ -37,15 +37,7 @@ "Usein käytetyt" "Yhteystiedot" "Lähetä numeroon %s" - "Tallenna kuva tai video" - "Valitse kuvia tästä laitteesta" - "Tallenna ääntä" "Valitse valokuva" - "Media on valittu." - "Median valinta on poistettu." - "%d valittu" - "kuva %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "kuva" "Tallenna ääntä" "Jaa" "Juuri nyt" @@ -138,9 +130,6 @@ "Käynnistä" "Kamera ei käytettävissä" - "Kamera ei käytettävissä" - "Videon kaappaus ei käytettävissä" - "Mediaa ei voi tallentaa" "Kuvan ottaminen ei onnistu." "Edellinen" "Arkistoitu" @@ -163,13 +152,10 @@ "Poista" "Peruuta" "Vastaanottaja" - "Valitse useita kuvia" - "Vahvista valinta" "+ %d" "Ääntä ei voi tallentaa. Yritä uudelleen." "Ääntä ei voi toistaa. Yritä uudelleen." "Ääntä ei voitu tallentaa. Yritä uudelleen." - "Kosketa pitkään" ", " " " ": " @@ -218,14 +204,7 @@ "Viesti epäonnistui. Yritä uudelleen koskettamalla." "Keskustelu henkilöiden %s kanssa" "Poista aihe" - "Tallenna video" - "Ota kuva" - "Ota kuva" - "Aloita videon tallentaminen" - "Siirry koko näytön kameraan" "Vaihda etu- ja takakameran välillä" - "Lopeta tallennus ja liitä video" - "Lopeta videon tallentaminen" "Viestien valokuvat" %d valokuvaa tallennettu albumiin %s @@ -410,7 +389,6 @@ "Estetyt yhteystiedot" "KUMOA ESTO" "Estetyt yhteystiedot" - "Valitse kuva dokumenttikirjastosta" "Lähetetään viestiä" "Viesti lähetetty" "Matkapuhelindata on kytketty pois. Tarkista asetukset." diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml index a6268326..de6b1933 100644 --- a/res/values-fr-rCA/strings.xml +++ b/res/values-fr-rCA/strings.xml @@ -37,15 +37,7 @@ "Fréquents" "Tous les contacts" "Envoyer à %s" - "Prendre des photos ou filmer une vidéo" - "Choisir des images de cet appareil" - "Enregistrer des fichiers audio" "Choisir une photo" - "Support sélectionné." - "Support désélectionné." - "%d sélectionné(s)" - "Image : %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Enregistrer des fichiers audio" "Partager" "À l\'instant" @@ -138,9 +130,6 @@ "Démarrer" "L\'appareil photo n\'est pas disponible" - "L\'appareil photo n\'est pas disponible" - "L\'enregistrement vidéo n\'est pas disponible" - "Impossible d\'enregistrer le média" "Impossible de prendre la photo." "Précédent" "Archivée" @@ -163,13 +152,10 @@ "Supprimer" "Annuler" "À" - "Sélectionner plusieurs images" - "Confirmer la sélection" "+%d" "Impossible d\'enregistrer l\'audio. Veuillez réessayer." "Impossible de lire l\'audio. Veuillez réessayer." "Impossible d\'enregistrer l\'audio. Veuillez réessayer." - "Maintenez le doigt ici" ", " " " ": " @@ -218,14 +204,7 @@ "Échec du message. Touchez pour réessayer." "Conversation avec %s" "Supprimer l\'objet" - "Filmer une vidéo" - "Capturer une image fixe" - "Prendre une photo" - "Lancer l\'enregistrement vidéo" - "Activer le mode plein écran de l\'appareil photo" "Basculer entre l\'appareil photo avant et arrière" - "Arrêter l\'enregistrement et joindre la vidéo" - "Arrêter l\'enregistrement vidéo" "Photos de Messagerie" %d photo enregistrée dans l\'album « %s » @@ -410,7 +389,6 @@ "Contacts bloqués" "DÉBLOQUER" "Contacts bloqués" - "Choisissez une image dans la bibliothèque de documents" "Envoi du message en cours..." "Message envoyé" "Les données cellulaires sont désactivées. Vérifiez vos paramètres." diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml index b9f22af8..27dafaf7 100644 --- a/res/values-fr/strings.xml +++ b/res/values-fr/strings.xml @@ -37,15 +37,7 @@ "Fréquents" "Tous les contacts" "Envoyer au %s" - "Prendre des photos ou filmer une vidéo" - "Sélectionner des images sur cet appareil" - "Enregistrer un fichier audio" "Sélectionner une photo" - "Le support est sélectionné." - "Le support est désélectionné." - "%d élément(s) sélectionné(s)" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Enregistrer un fichier audio" "Partager" "À l\'instant" @@ -138,9 +130,6 @@ "Démarrer" "Caméra indisponible." - "Caméra indisponible." - "Enregistrement vidéo indisponible." - "Impossible d\'enregistrer le fichier multimédia." "Impossible de prendre la photo." "Retour" "Archivées" @@ -163,13 +152,10 @@ "Supprimer" "Annuler" "À" - "Sélectionner plusieurs images" - "Confirmer la sélection" "+ %d" "Impossible d\'enregistrer l\'audio. Veuillez réessayer." "Impossible de lire l\'audio. Veuillez réessayer." "Impossible d\'enregistrer l\'audio. Veuillez réessayer." - "Appuyez ici de manière prolongée" ", " " " " : " @@ -218,14 +204,7 @@ "Échec du message. Appuyez pour réessayer." "Conversation avec %s" "Supprimer l\'objet" - "Enregistrer une vidéo" - "Capturer une image fixe" - "Prendre une photo" - "Lancer l\'enregistrement vidéo" - "Passer en caméra plein écran" "Passer de la caméra frontale à la caméra arrière, et inversement" - "Arrêter l\'enregistrement et joindre la vidéo" - "Arrêter l\'enregistrement vidéo" "Photos envoyées ou reçues par SMS/MMS" %d photo a été enregistrée dans l\'album \"%s\" @@ -410,7 +389,6 @@ "Contacts bloqués" "DÉBLOQUER" "Contacts bloqués" - "Sélectionner une image dans la bibliothèque de documents" "Envoi du message en cours..." "Message envoyé." "Les données cellulaires sont désactivées. Veuillez vérifier vos paramètres." diff --git a/res/values-gl/strings.xml b/res/values-gl/strings.xml index 8a626c20..17f27acf 100644 --- a/res/values-gl/strings.xml +++ b/res/values-gl/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos os contactos" "Enviar ao %s" - "Capturar fotos ou vídeo" - "Escoller imaxes deste dispositivo" - "Gravar audio" "Escoller foto" - "O ficheiro multimedia está seleccionado." - "O ficheiro multimedia non está seleccionado." - "%d seleccionado" - "imaxe do %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imaxe" "Gravar audio" "Compartir" "Agora mesmo" @@ -138,9 +130,6 @@ "Iniciar" "A cámara non está dispoñible" - "A cámara non está dispoñible" - "A captura de vídeo non está dispoñible" - "Non se pode gardar o ficheiro multimedia" "Non se pode sacar a foto" "Atrás" "Arquivadas" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imaxes" - "Confirmar selección" "+%d" "Non se pode gravar o audio. Téntao de novo." "Non se pode reproducir o audio. Téntao de novo." "Non se puido gardar o audio. Téntao de novo." - "Mantén premido" ", " " " ": " @@ -218,14 +204,7 @@ "Erro na mensaxe. Toca para tentalo de novo." "Conversa con %s" "Borrar asunto" - "Capturar vídeo" - "Capturar unha imaxe estática" - "Sacar foto" - "Comezar a gravar vídeo" - "Cambiar á cámara de pantalla completa" "Cambiar entre a cámara dianteira e traseira" - "Deter a gravación e anexar vídeo" - "Deter a gravación de vídeo" "Fotos de Mensaxería" Gardáronse %d fotos no álbum \"%s\" @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Escolle unha imaxe da biblioteca de documentos" "Enviando a mensaxe" "Mensaxe enviada" "Os datos móbiles están desactivados. Comproba a túa configuración." diff --git a/res/values-gu/strings.xml b/res/values-gu/strings.xml index 8d2adaf9..1ad13388 100644 --- a/res/values-gu/strings.xml +++ b/res/values-gu/strings.xml @@ -37,15 +37,7 @@ "વારંવાર" "તમામ સંપર્કો" "%s પર મોકલો" - "ચિત્રો અથવા વિડિઓ કેપ્ચર કરો" - "આ ઉપકરણથી છબીઓ પસંદ કરો" - "ઑડિઓ રેકોર્ડ કરો" "ફોટો પસંદ કરો" - "મીડિયા પસંદ થયેલ છે." - "મીડિયા પસંદ કરેલ નથી." - "%d પસંદ કર્યાં" - "છબી %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "છબી" "ઑડિઓ રેકોર્ડ કરો" "શેર કરો" "હમણાં જ" @@ -138,9 +130,6 @@ "પ્રારંભ કરો" "કૅમેરો ઉપલબ્ધ નથી" - "કૅમેરો ઉપલબ્ધ નથી" - "વિડિઓ કેપ્ચર ઉપલબ્ધ નથી" - "મીડિયા સાચવી શકતા નથી" "ચિત્ર લઈ શકતા નથી" "પાછળ" "આર્કાઇવ કરેલા" @@ -163,13 +152,10 @@ "કાઢી નાખો" "રદ કરો" "પ્રતિ" - "બહુવિધ છબીઓ પસંદ કરો" - "પસંદગીની પુષ્ટિ કરો" "+%d" "ઑડિઓ રેકોર્ડ કરી શકાતો નથી. ફરીથી પ્રયાસ કરો." "ઑડિઓ ચલાવી શકાતો નથી. ફરીથી પ્રયાસ કરો." "ઑડિઓ સાચવી શકાયો નથી. ફરીથી પ્રયાસ કરો." - "ટચ કરો અને પકડો" ", " " " ": " @@ -218,14 +204,7 @@ "નિષ્ફળ સંદેશ. ફરી પ્રયાસ કરવા ટચ કરો." "%s સાથે વાર્તાલાપ" "વિષય કાઢી નાખો" - "વિડિઓ કેપ્ચર કરો" - "એક સ્થિર છબી કેપ્ચર કરો" - "ફોટો લો" - "વિડિઓ રેકોર્ડિંગ પ્રારંભ કરો" - "પૂર્ણ સ્ક્રીન કૅમેરા પર સ્વિચ કરો" "આગળનાં અને પાછળનાં કૅમેરા વચ્ચે સ્વિચ કરો" - "રેકોર્ડિંગ બંધ કરો અને વિડિઓ જોડો" - "વિડિઓ રેકોર્ડિંગ બંધ કરો" "મેસેજિંગ ફોટા" \"%s\" આલ્બમમાં %d ફોટા સાચવ્યાં @@ -410,7 +389,6 @@ "અવરોધિત કરેલા સંપર્કો" "અનાવરોધિત કરો" "અવરોધિત કરેલા સંપર્કો" - "દસ્તાવેજ લાઇબ્રેરીમાંથી છબી પસંદ કરો" "સંદેશ મોકલી રહ્યાં છીએ" "સંદેશ મોકલ્યો" "સેલ્યુલર ડેટા બંધ છે. તમારી સેટિંગ્સ તપાસો." diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml index 7e7a780e..6672c254 100644 --- a/res/values-hi/strings.xml +++ b/res/values-hi/strings.xml @@ -37,15 +37,7 @@ "अक्सर" "सभी संपर्क" "%s पर भेजें" - "चित्र या वीडियो कैप्चर करें" - "इस डिवाइस के चित्र चुनें" - "ऑडियो रिकॉर्ड करें" "फ़ोटो चुनें" - "मीडिया को चुना है." - "मीडिया को नहीं चुना है." - "%d चुने गए" - "चित्र %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "चित्र" "ऑडियो रिकॉर्ड करें" "साझा करें" "अभी-अभी" @@ -138,9 +130,6 @@ "प्रारंभ करें" "कैमरा उपलब्ध नहीं है" - "कैमरा उपलब्ध नहीं है" - "वीडियो कैप्‍चर उपलब्‍ध नहीं है" - "मीडिया सहेजा नहीं जा सकता" "चित्र नहीं लिया जा सकता" "पीछे" "संग्रहीत" @@ -163,13 +152,10 @@ "हटाएं" "अभी नहीं" "प्रति" - "एकाधिक चित्र चुनें" - "चयन की दुबारा पूछें" "+%d" "ऑडियो रिकॉर्ड नहीं किया जा सकता. पुनः प्रयास करें." "ऑडियो नहीं चलाया जा सकता. पुन: प्रयास करें." "ऑडियो सहेजा नहीं जा सका. पुनः प्रयास करें." - "स्पर्श करके रखें" ", " " " ": " @@ -218,14 +204,7 @@ "विफल रहा संदेश. पुन: प्रयास करने के लिए स्पर्श करें." "%s के साथ बातचीत" "विषय हटाएं" - "वीडियो कैप्चर करें" - "स्थिर चित्र कैप्चर करें" - "चित्र लें" - "वीडियो रिकॉर्ड करना प्रारंभ करें" - "पूर्ण स्क्रीन कैमरा पर स्विच करें" "सामने और पीछे वाले कैमरे के बीच स्‍विच करें" - "रिकॉर्डिंग रोकें और वीडियो अटैच करें" - "वीडियो रिकॉर्ड करना बंद करें" "संदेश सेवा फ़ोटो" %d फ़ोटो \"%s\" एल्बम में सहेजी गईं @@ -410,7 +389,6 @@ "अवरोधित संपर्क" "अनवरोधित करें" "अवरोधित संपर्क" - "दस्‍तावेज़ लाइब्रेरी से चित्र चुनें" "संदेश भेजा जा रहा है" "संदेश भेजा गया" "सेलुलर डेटा बंद हो गया है. अपनी सेटिंग जांचें." diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml index b8bb490b..b0c62b06 100644 --- a/res/values-hr/strings.xml +++ b/res/values-hr/strings.xml @@ -37,15 +37,7 @@ "Često kontaktirani" "Svi kontakti" "Pošalji na %s" - "Snimite fotografije ili videozapis" - "Odaberite slike s ovog uređaja" - "Snimanje zvuka" "Odaberite fotografiju" - "Medij je odabran." - "Odabir medija poništen je." - "Odabrano: %d" - "slika %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "slika" "Snimanje zvuka" "Dijeljenje" "Upravo sada" @@ -147,9 +139,6 @@ "Započni" "Fotoaparat nije dostupan" - "Fotoaparat nije dostupan" - "Snimanje videozapisa nije dostupno" - "Medij nije spremljen" "Nije moguće snimiti sliku" "Natrag" "Arhivirano" @@ -173,13 +162,10 @@ "Izbriši" "Odustani" "Odredište" - "Odaberi više slika" - "Potvrdite odabir" "+%d" "Audioporuka nije snimljena. Pokušajte ponovo." "Audioporuku nije moguće reproducirati. Pokušajte ponovo." "Audioporuka nije spremljena. Pokušajte ponovo." - "Dodirnite i zadržite" ", " " " ": " @@ -230,14 +216,7 @@ "Poruka nije uspjela. Dodirnite za ponovni pokušaj." "Razgovor sa sljedećima: %s" "Brisanje predmeta" - "Snimi videozapis" - "Snimi fotografiju" - "Snimi sliku" - "Početak snimanja videozapisa" - "Prebacivanje na fotoaparat na punom zaslonu" "Prebacivanje između prednjeg i stražnjeg fotoaparata" - "Zaustavi snimanje i priloži videozapis" - "Zaustavi snimanje videozapisa" "Fotografije Slanja poruka" %d fotografija spremljena je u album \"%s\" @@ -436,7 +415,6 @@ "Blokirani kontakti" "DEBLOKIRAJ" "Blokirani kontakti" - "Odaberite sliku iz knjižnice dokumenata" "Slanje poruke" "Poruka je poslana" "Mobilni su podaci isključeni. Provjerite postavke." diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml index 92035140..7b1668ee 100644 --- a/res/values-hu/strings.xml +++ b/res/values-hu/strings.xml @@ -37,15 +37,7 @@ "Gyakran használt névjegyek" "Az összes névjegy" "Küldés: %s" - "Kép vagy videó rögzítése" - "Képek kiválasztása erről az eszközről" - "Hanganyag rögzítése" "Fotó kiválasztása" - "Kijelölte a médiaelemet." - "Visszavonta a médiaelem kijelölését." - "%d kiválasztva" - "kép %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "kép" "Hanganyag rögzítése" "Megosztás" "Éppen most" @@ -138,9 +130,6 @@ "Indítás" "A kamera nem érhető el" - "A kamera nem érhető el" - "A videorögzítés nem érhető el" - "Nem lehetett menteni a médiát" "Nem lehet képet készíteni" "Vissza" "Archiválva" @@ -163,13 +152,10 @@ "Törlés" "Mégse" "Címzett" - "Több kép kiválasztása" - "Kiválasztás megerősítése" "+%d" "Nem sikerült rögzíteni a hangot. Próbálja újra." "A hanganyag nem játszható le. Próbálja újra." "Nem sikerült elmenteni a hangot. Próbálja újra." - "Érintés és tartás" ", " " " ": " @@ -218,14 +204,7 @@ "Sikertelen üzenet. Érintse meg az újrapróbáláshoz." "Beszélgetés a következőkkel: %s" "Tárgy törlése" - "Videó rögzítése" - "Állókép rögzítése" - "Kép készítése" - "Videofelvétel megkezdése" - "Váltás teljes képernyős kameranézetre" "Váltás az elülső és a hátulsó kamera között" - "Rögzítés leállítása és a videó csatolása" - "Videofelvétel leállítása" "Messaging-fotók" %d fotó mentve a következő albumba: %s @@ -410,7 +389,6 @@ "Letiltott ismerősök" "FELOLD" "Letiltott ismerősök" - "Kép választása a dokumentumtárból" "Üzenet küldése" "Üzenet elküldve" "A mobiladat-kapcsolat ki van kapcsolva. Ellenőrizze beállításait." diff --git a/res/values-hy/strings.xml b/res/values-hy/strings.xml index 4dd99b09..1cdc8fc7 100644 --- a/res/values-hy/strings.xml +++ b/res/values-hy/strings.xml @@ -37,15 +37,7 @@ "Հաճախակի օգտագործվող կոնտակտներ" "Բոլոր կոնտակտները" "Ուղարկել %s համարին" - "Լուսանկարել կամ տեսագրել" - "Ընտրեք պատկերներ այս սարքից" - "Ձայնագրել ձայնանյութ" "Ընտրել լուսանկար" - "Մեդիա ֆայլն ընտրված է:" - "Մեդիա ֆայլն ապընտրված է:" - "%d ընտրված" - "պատկեր %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "պատկեր" "Ձայնագրել ձայնանյութ" "Տարածել" "Հենց հիմա" @@ -138,9 +130,6 @@ "Սկսել" "Խցիկն անհասանելի է" - "Խցիկն անհասանելի է" - "Տեսանյութը հասանելի չէ" - "Հնարավոր չէ պահել մեդիա ֆայլը" "Չհաջողվեց լուսանկարել" "Հետ" "Արխիվացված" @@ -163,13 +152,10 @@ "Ջնջել" "Չեղարկել" "Ստացող`" - "Ընտրել մի քանի պատկեր" - "Հաստատել ընտրությունը" "+%d" "Հնարավոր չէ ձայնագրել: Կրկին փորձեք:" "Հնարավոր չէ նվագարկել աուդիո ֆայլը: Կրկին փորձեք:" "Հնարավոր չէր պահել աուդիո ֆայլը: Կրկին փորձեք:" - "Հպել և պահել" ", " " " ": " @@ -218,14 +204,7 @@ "Հաղորդագրությունը չի առաքվել: Հպեք՝ նորից փորձելու համար:" "Զրույց %s-ի հետ" "Ջնջել վերնագիրը" - "Տեսագրել" - "Նկարահանել ստատիկ պատկեր" - "Լուսանկարել" - "Սկսել տեսաձայնագրումը" - "Անցնել լիաէկրան ֆոտոխցիկի" "Փոխարկել դիմացի և հետևի տեսախցիկները" - "Կանգնեցնել ձայնագրումը և կցել տեսանյութը" - "Դադարեցնել տեսանկարահանումը" "Հաղորդակցման լուսանկարներ" «%s» ալբոմում պահվել է %d լուսանկար @@ -410,7 +389,6 @@ "Արգելափակված կոնտակտներ" "ԱՐԳԵԼԱԲԱՑԵԼ" "Արգելափակված կոնտակտներ" - "Ընտրեք պատկեր՝ փաստաթղթերի գրադարանից" "Հաղորդագրությունն ուղարկվում է" "Հաղորդագրությունն ուղարկվել է" "Բջջային տվյալների կապն անջատված է: Ստուգեք ձեր կարգավորումները:" diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml index 3b65bb25..5216dcee 100644 --- a/res/values-in/strings.xml +++ b/res/values-in/strings.xml @@ -37,15 +37,7 @@ "Sering" "Semua kontak" "Kirim ke %s" - "Jepret gambar atau rekam video" - "Pilih gambar dari perangkat ini" - "Rekam audio" "Pilih foto" - "Media ini dipilih." - "Media ini batal dipilih." - "%d dipilih" - "gambar %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "gambar" "Rekam audio" "Bagikan" "Baru saja" @@ -138,9 +130,6 @@ "Mulai" "Tidak ada kamera" - "Tidak ada kamera" - "Tidak ada rekaman video" - "Tidak dapat menyimpan media" "Tidak dapat memfoto" "Kembali" "Diarsipkan" @@ -163,13 +152,10 @@ "Hapus" "Batal" "Kepada" - "Pilih beberapa gambar" - "Konfirmasi pilihan" "+%d" "Tidak dapat merekam audio. Coba lagi." "Tidak dapat memutar audio. Coba lagi." "Tidak dapat menyimpan audio. Coba lagi." - "Sentuh & tahan" ", " " " ": " @@ -218,14 +204,7 @@ "Pesan gagal. Sentuh untuk mencoba lagi." "Percakapan dengan %s" "Hapus subjek" - "Ambil video" - "Ambil gambar diam" - "Ambil gambar" - "Mulai merekam video" - "Beralih ke kamera layar penuh" "Beralih antara kamera depan dan belakang" - "Hentikan perekaman dan lampirkan video" - "Berhenti merekam video" "Foto Perpesanan" %d foto disimpan ke album \"%s\" @@ -410,7 +389,6 @@ "Kontak yang diblokir" "BEBASKAN" "Kontak yang diblokir" - "Pilih gambar dari perpustakaan dokumen" "Mengirim pesan" "Pesan terkirim" "Data seluler dinonaktifkan. Periksa setelan." diff --git a/res/values-is/strings.xml b/res/values-is/strings.xml index f02a11c0..f7e142f6 100644 --- a/res/values-is/strings.xml +++ b/res/values-is/strings.xml @@ -37,15 +37,7 @@ "Algengir" "Allir tengiliðir" "Senda í %s" - "Taka myndir eða myndskeið" - "Velja myndir úr þessu tæki" - "Taka upp hljóð" "Velja mynd" - "Efnið er valið." - "Efnið er ekki valið." - "%d valin" - "mynd %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "mynd" "Taka upp hljóð" "Deila" "Rétt í þessu" @@ -138,9 +130,6 @@ "Byrja" "Myndavélin er ekki tiltæk" - "Myndavélin er ekki tiltæk" - "Upptaka myndskeiða er ekki tiltæk" - "Ekki er hægt að vista efni" "Ekki er hægt að taka mynd" "Til baka" "Geymsla" @@ -163,13 +152,10 @@ "Eyða" "Hætta við" "Til" - "Velja margar myndir" - "Staðfesta val" "+%d" "Ekki er hægt að taka upp hljóð. Reyndu aftur." "Ekki er hægt að spila hljóð. Reyndu aftur." "Ekki var hægt að vista hljóð. Reyndu aftur." - "Haltu inni" ", " " " ": " @@ -218,14 +204,7 @@ "Misheppnuð skilaboð. Snertu til að reyna aftur." "Samtal við %s" "Eyða efni" - "Taka upp myndskeið" - "Taka kyrrmynd" - "Taka mynd" - "Byrja að taka upp myndskeið" - "Skipta yfir í myndavél á öllum skjánum" "Skipt á milli fremri og aftari myndavélar" - "Stöðva upptöku og hengja myndskeið við" - "Stöðva upptöku myndskeiðs" "Skilaboðamyndir" %d mynd vistuð í „%s“ möppu @@ -410,7 +389,6 @@ "Útilokaðir tengiliðir" "TAKA AF BANNLISTA" "Útilokaðir tengiliðir" - "Velja mynd úr skjalasafni" "Sendir skilaboð" "Skilaboð send" "Slökkt er á farsímagögnum. Athugaðu stillingarnar." diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml index f0417936..b13743af 100644 --- a/res/values-it/strings.xml +++ b/res/values-it/strings.xml @@ -37,15 +37,7 @@ "Frequenti" "Tutti i contatti" "Invia a %s" - "Scatta foto o registra video" - "Scegli immagini su questo dispositivo" - "Registra audio" "Scegli foto" - "Il contenuto multimediale è selezionato." - "Il contenuto multimediale è deselezionato." - "%d selezionati" - "immagine: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "immagine" "Registrazione audio" "Condividi" "Adesso" @@ -138,9 +130,6 @@ "Avvia" "Fotocamera non disponibile" - "Fotocamera non disponibile" - "Acquisizione video non disponibile" - "Impossibile salvare i contenuti multimediali" "Impossibile scattare una foto" "Indietro" "Archiviate" @@ -163,13 +152,10 @@ "Elimina" "Annulla" "A" - "Seleziona diverse immagini" - "Conferma selezione" "e altri %d" "Impossibile registrare l\'audio. Riprova." "Impossibile riprodurre l\'audio. Riprova." "Impossibile salvare l\'audio. Riprova." - "Tocca e tieni premuto" ", " " " ": " @@ -218,14 +204,7 @@ "Messaggio non recapitato. Tocca per riprovare." "Conversazione con %s" "Elimina oggetto" - "Registra video" - "Scatta una foto in posa" - "Scatta una foto" - "Inizia a registrare il video" - "Passa alla fotocamera a schermo intero" "Passa dalla fotocamera frontale a quella posteriore e viceversa" - "Interrompi registrazione e aggiungi video" - "Interrompi registrazione video" "Foto in Messaggi" %d foto salvate nell\'album \"%s\" @@ -410,7 +389,6 @@ "Contatti bloccati" "SBLOCCA" "Contatti bloccati" - "Scegli immagine da raccolta documenti" "Invio messaggio" "Messaggio inviato" "Dati mobili non attivi. Controlla le impostazioni." diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml index 3f80e3c4..93845a2a 100644 --- a/res/values-iw/strings.xml +++ b/res/values-iw/strings.xml @@ -37,15 +37,7 @@ "בתדירות גבוהה" "כל אנשי הקשר" "שלח אל %s" - "צלם תמונות או סרטון" - "בחר תמונות ממכשיר זה" - "הקלט אודיו" "בחר תמונה" - "בחרת במדיה." - "ביטלת את הבחירה במדיה." - "%d נבחרו" - "תמונה %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "תמונה" "הקלט אודיו" "שיתוף" "ממש עכשיו" @@ -156,9 +148,6 @@ "התחל" "המצלמה לא זמינה" - "המצלמה לא זמינה" - "לכידת וידאו אינה זמינה" - "לא ניתן לשמור מדיה" "לא ניתן לצלם את התמונה" "הקודם" "הועברו לארכיון" @@ -183,13 +172,10 @@ "מחק" "בטל" "אל" - "בחר תמונות מרובות" - "אשר את הבחירה" "+%d" "לא ניתן להקליט אודיו. נסה שוב." "לא ניתן להפעיל אודיו. נסה שוב." "לא ניתן לשמור אודיו. נסה שוב." - "גע והחזק" ", " " " ": " @@ -242,14 +228,7 @@ "ההודעה נכשלה. גע כדי לנסות שוב." "שיחה עם %s" "מחק נושא" - "צלם סרטון" - "צלם תמונת סטילס" - "צלם תמונה" - "התחל הקלטת סרטון" - "עבור למצלמה במסך מלא" "החלף בין המצלמה הקדמית לאחורית" - "הפסק להקליט וצרף סרטון" - "עצור הקלטת וידאו" "תמונות ב\'העברת הודעות\'" %d תמונות נשמרו באלבום \"%s\" @@ -462,7 +441,6 @@ "אנשי קשר חסומים" "אפשר" "אנשי קשר חסומים" - "בחר תמונה מספריית המסמכים" "שולח את ההודעה" "ההודעה נשלחה" "נתונים סלולריים כבויים. בדוק את ההגדרות." diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml index a3a513ec..48077884 100644 --- a/res/values-ja/strings.xml +++ b/res/values-ja/strings.xml @@ -37,15 +37,7 @@ "よく使う連絡先" "すべての連絡先" "%sに送信" - "写真や動画を撮影" - "この端末から画像を選択" - "音声の録音" "写真を選択" - "メディアを選択しました。" - "メディアの選択を解除しました。" - "%d件選択済み" - "画像%1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "画像" "音声の録音" "共有" "たった今" @@ -138,9 +130,6 @@ "開始" "カメラは利用できません" - "カメラは利用できません" - "動画撮影は利用できません" - "メディアを保存できません" "写真を撮ることはできません" "戻る" "アーカイブ済み" @@ -163,13 +152,10 @@ "削除" "キャンセル" "宛先" - "複数の画像を選択" - "選択の確認" "+%d" "音声を録音できません。もう一度お試しください。" "音声を再生できません。もう一度お試しください。" "音声を保存できませんでした。もう一度お試しください。" - "長押し" "、 " " " ": " @@ -218,14 +204,7 @@ "失敗したメッセージ。再試行するにはタップしてください。" "%sとのスレッド" "件名を削除" - "動画を撮影" - "静止画を撮影" - "画像を撮影" - "動画の録画を開始" - "全画面カメラに切り替える" "前面カメラと背面カメラを切り替える" - "録画を停止し、動画を添付する" - "動画の録画を停止" "SMSの写真" %d枚の写真を「%s」アルバムに保存しました @@ -410,7 +389,6 @@ "ブロック中の連絡先" "ブロックを解除" "ブロック中の連絡先" - "ドキュメントライブラリから画像を選択" "メールを送信しています" "メッセージを送信しました" "モバイルデータがOFFになっています。設定を確認してください。" diff --git a/res/values-ka/strings.xml b/res/values-ka/strings.xml index 8a2cd5ec..b59c96ff 100644 --- a/res/values-ka/strings.xml +++ b/res/values-ka/strings.xml @@ -37,15 +37,7 @@ "ხშირად გამოყენებული კონტაქტები" "ყველა კონტაქტი" "%s-ზე გაგზავნა" - "სურათების ან ვიდეოს გადაღება" - "ამ მოწყობილობიდან სურათების არჩევა" - "აუდიოს ჩაწერა" "ფოტოს არჩევა" - "მედია შერჩეულია." - "მედია არ არის შერჩეული." - "%d შერჩეულია" - "სურათის %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "სურათი" "აუდიოს ჩაწერა" "გაზიარება" "ახლახანს" @@ -138,9 +130,6 @@ "დაწყება" "კამერა მიუწვდომელია" - "კამერა მიუწვდომელია" - "ვიდეო ჩაწერა მიუწვდომელია" - "მედიას შენახვა ვერ ვერხდება" "ვერ ხორციელდება სურათის გადაღება" "უკან" "დაარქივებული" @@ -163,13 +152,10 @@ "წაშლა" "გაუქმება" "მიმღები:" - "მრავალი სურათის არჩევა" - "არჩევანის დადასტურება" "+%d" "ხმის ჩაწერა ვერ ხერხდენა. სცადეთ ისევ." "ხმის დაკვრა ვერ ხერხდება. სცადეთ ისევ." "ხმის ჩაწერა ვერ მოხერხდა. სცადეთ ისევ." - "შეეხეთ & დააყოვნეთ" ", " " " ": " @@ -218,14 +204,7 @@ "შეტყობინების შეცდომა. შეეხეთ, რათა ხელახლა სცადოთ." "საუბარი %s -სთან" "თემის წაშლა" - "ვიდეოს გადაღება" - "ფოტოსურათის აღბეჭდვა" - "სურათის გადაღება" - "ვიდეოს ჩაწერის დაწყება" - "სრულეკრანიან კამერაზე გადართვა" "წინა და უკანა კამერებს შორის გადართვა" - "ჩაწერის შეჩერება და ვიდეოს მიბმა" - "ვიდეოს ჩაწერის შეჩერება" "შეტყობინებების ფოტოები" %d ფოტო შენახულია ალბომში „%s @@ -410,7 +389,6 @@ "დაბლოკილი კონტაქტები" "განბლოკვა" "დაბლოკილი კონტაქტები" - "აირჩიეთ სურათი დოკუმენტების ბიბლიოთეკიდან" "შეტყობინება იგზავნება" "შეტყობინება გაიგზავნა" "მობილური ინტერნეტი გამორთულია. გადაამოწმეთ პარამეტრები." diff --git a/res/values-kk/strings.xml b/res/values-kk/strings.xml index 386e8ef7..62a659f5 100644 --- a/res/values-kk/strings.xml +++ b/res/values-kk/strings.xml @@ -37,15 +37,7 @@ "Жиі қолданылатын" "Барлық контактілер" "%s орнына жіберу" - "Суреттер немесе бейне түсіру" - "Осы құрылғыдан кескіндерді таңдау" - "Аудио жазу" "Фотосуретті таңдау" - "Медиа таңдалды." - "Медиадан таңдау алынды." - "%d таңдалды" - "кескін %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "кескін" "Аудио жазу" "Бөлісу" "Қазір ғана" @@ -138,9 +130,6 @@ "Бастау" "Камера қол жетімді емес" - "Камера қол жетімді емес" - "Бейне түсіру қол жетімді емес" - "Медианы сақтау мүмкін емес" "Сурет түсіру мүмкін емес" "Артқа" "Мұрағатталды" @@ -163,13 +152,10 @@ "Жою" "Бас тарту" "Кімге" - "Бірнеше кескінді таңдау" - "Таңдауды растау" "+%d" "Аудионы жазу мүмкін емес. Әрекетті қайталаңыз." "Аудионы ойнату мүмкін емес. Әрекетті қайталаңыз." "Аудионы сақтау мүмкін болмады. Әрекетті қайталаңыз." - "Түрту және ұстап тұру" ", " " " ": " @@ -218,14 +204,7 @@ "Сәтсіз хабар. Қайталау үшін түртіңіз." "%s адаммен сөйлесу" "Тақырыпты жою" - "Бейне түсіру" - "Фотосурет түсіру" - "Сурет түсіру" - "Бейне жазуды бастау" - "Толық экранды камераға ауысу" "Алдыңғы және артқы камера арасында ауысу" - "Жазуды тоқтату және бейне тіркеу" - "Бейне жазуды тоқтату" "Messaging фотосуреттері" %d фотосурет «%s» альбомына сақталды @@ -410,7 +389,6 @@ "Бөгелген контактілер" "БӨГЕУДЕН ШЫҒАРУ" "Бөгелген контактілер" - "Құжаттар кітапханасынан кескінді таңдау" "Хабар жіберілуде" "Хабар жіберілді" "Ұялы деректер өшірілген. Параметрлерді тексеріңіз." diff --git a/res/values-km/strings.xml b/res/values-km/strings.xml index fba58e2e..ecd69874 100644 --- a/res/values-km/strings.xml +++ b/res/values-km/strings.xml @@ -37,15 +37,7 @@ "ញឹកញាប់" "ទំនាក់ទំនងទាំងអស់" "ផ្ញើ​ទៅ %s" - "ថត​រូបភាព ឬ​វីដេអូ" - "ជ្រើស​រូបភាព​ពី​ឧបករណ៍​នេះ" - "ថត​សំឡេង" "ជ្រើស​រូបថត" - "បានជ្រើសឯកសារកំសាន្ត។" - "បានលុបការជ្រើសឯកសារកំសាន្ត។" - "បាន​ជ្រើស %d" - "រូបភាព %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "រូបភាព" "ថត​សំឡេង" "ចែករំលែក​" "ថ្មីៗ​នេះ" @@ -138,9 +130,6 @@ "ចាប់ផ្ដើម" "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" - "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" - "មិន​អាច​ថត​វីដេអូ​បាន" - "មិន​អាច​រក្សាទុក​​មេឌៀ" "មិនអាចថតរូបភាព" "ថយក្រោយ" "ទុក​ក្នុង​ប័ណ្ណសារ" @@ -163,13 +152,10 @@ "លុប" "បោះបង់" "ជូន​ចំពោះ" - "ជ្រើស​រូបភាព​ច្រើន" - "បញ្ជាក់​​ការ​ជ្រើស" "+%d" "មិន​អាច​ថត​សំឡេង។ ព្យាយាម​ម្ដង​ទៀត។" "មិន​អាច​ចាក់​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" "មិន​អាច​រក្សាទុក​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" - "ប៉ះ & សង្កត់" ", " " " ": " @@ -218,14 +204,7 @@ "សារបរាជ័យ។ ប៉ះដើម្បីព្យាយាមម្តងទៀត។" "ការសន្ទនាជាមួយ %s" "លុប​ប្រធានបទ" - "ថត​វីដេអូ" - "ថត​រូបភាព​ថេរ" - "ថតរូប" - "ចាប់ផ្ដើម​ថត​វីដេអូ" - "ប្ដូរ​ទៅ​ម៉ាស៊ីន​ថត​ពេញ​អេក្រង់" "ប្ដូរ​រវាង​ម៉ាស៊ីន​ថត​មុន និង​ក្រោយ" - "បញ្ឈប់​ការ​ថត និង​ភ្ជាប់​វីដេអូ" - "បញ្ឈប់​ការ​ថត​វីដេអូ" "រូបភាពសារ" បានរក្សាទុករូបថត %d ទៅក្នុងអាល់ប៊ុម \"%s\" @@ -410,7 +389,6 @@ "បាន​ទប់ស្កាត់​ទំនាក់ទំនង" "មិន​ទប់ស្កាត់" "បាន​ទប់ស្កាត់​ទំនាក់ទំនង​" - "ជ្រើស​រូបភាព​ពី​បណ្ណាល័យ​ឯកសារ" "កំពុងផ្ញើសារ" "បានផ្ញើសារ" "ទិន្នន័យ​ចល័ត​ត្រូវ​បាន​បិទ។ ពិនិត្យ​ការ​កំណត់​របស់​អ្នក។" diff --git a/res/values-kn/strings.xml b/res/values-kn/strings.xml index b4840093..831b5c2d 100644 --- a/res/values-kn/strings.xml +++ b/res/values-kn/strings.xml @@ -37,15 +37,7 @@ "ಪುನರಾವರ್ತನೆಗಳು" "ಎಲ್ಲ ಸಂಪರ್ಕಗಳು" "%s ಗೆ ಕಳುಹಿಸಿ" - "ಚಿತ್ರಗಳು ಅಥವಾ ವೀಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" - "ಈ ಸಾಧನದಿಂದ ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" - "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" "ಫೋಟೋ ಆಯ್ಕೆಮಾಡಿ" - "ಮಾಧ್ಯಮ ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ." - "ಮಾಧ್ಯಮ ಆಯ್ಕೆ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ." - "%d ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ" - "ಚಿತ್ರ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "ಚಿತ್ರ" "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" "ಹಂಚಿಕೊಳ್ಳಿ" "ಈಗ ತಾನೇ" @@ -138,9 +130,6 @@ "ಪ್ರಾರಂಭಿಸು" "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" - "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" - "ವಿಡಿಯೋ ಸೆರೆಹಿಡಿಯುವಿಕೆ ಲಭ್ಯವಿಲ್ಲ" - "ಮಾಧ್ಯಮ ಉಳಿಸಲಾಗಲಿಲ್ಲ" "ಫೋಟೋ ತೆಗೆದುಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ" "ಹಿಂದೆ" "ಆರ್ಕೈವ್ ಮಾಡಲಾಗಿದೆ" @@ -163,13 +152,10 @@ "ಅಳಿಸಿ" "ರದ್ದುಮಾಡು" "ಇವರಿಗೆ" - "ಬಹು ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" - "ಆಯ್ಕೆ ಖಚಿತಪಡಿಸಿ" "+%d" "ಆಡಿಯೋ ರೆಕಾರ್ಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." "ಆಡಿಯೋವನ್ನು ಪ್ಲೇ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." "ಆಡಿಯೋವನ್ನು ಉಳಿಸಲಾಗಲಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." - "ಸ್ಪರ್ಶಿಸಿ & ಹೋಲ್ಡ್‌ ಮಾಡಿ" ", " " " ": " @@ -218,14 +204,7 @@ "ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ. ಮರುಪ್ರಯತ್ನಿಸಲು ಸ್ಪರ್ಶಿಸಿ." "%s ಅವರೊಂದಿಗೆ ಸಂಭಾಷಣೆ" "ವಿಷಯವನ್ನು ಅಳಿಸಿ" - "ವೀಡಿಯೊ ಸೆರೆಹಿಡಿಯಿರಿ" - "ಸ್ಥಿರ ಚಿತ್ರವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" - "ಫೋಟೋ ತೆಗೆಯಿರಿ" - "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ" - "ಪೂರ್ಣ ಪರದೆ ಕ್ಯಾಮರಾಗೆ ಬದಲಿಸಿ" "ಮುಂದಿನ ಮತ್ತು ಹಿಂದಿನ ಕ್ಯಾಮರಾ ನಡುವೆ ಬದಲಾಯಿಸಿ" - "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ ಮತ್ತು ವೀಡಿಯೊ ಲಗತ್ತಿಸಿ" - "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ" "ಫೋಟೋಗಳ ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಫೋಟೋಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ @@ -410,7 +389,6 @@ "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" "ಅನಿರ್ಬಂಧಿಸು" "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" - "ಡಾಕ್ಯುಮೆಂಟ್ ಲೈಬ್ರರಿಯಿಂದ ಚಿತ್ರವನ್ನು ಆಯ್ಕೆಮಾಡಿ" "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ" "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗಿದೆ" "ಸೆಲ್ಯುಲಾರ್ ಡೇಟಾ ಆಫ್ ಮಾಡಲಾಗಿದೆ. ನಿಮ್ಮ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಪರೀಕ್ಷಿಸಿ." diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml index 1faec2ab..e69957cf 100644 --- a/res/values-ko/strings.xml +++ b/res/values-ko/strings.xml @@ -37,15 +37,7 @@ "자주 연락하는 사람" "모든 연락처" "받는사람: %s" - "사진 또는 동영상 캡처" - "이 기기에서 이미지 선택" - "오디오 녹음" "사진 선택" - "미디어가 선택되었습니다." - "미디어가 선택 취소되었습니다." - "%d개 선택됨" - "이미지 - %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "이미지" "오디오 녹음" "공유" "방금 전" @@ -138,9 +130,6 @@ "시작" "카메라를 사용할 수 없습니다." - "카메라를 사용할 수 없습니다." - "동영상을 캡처할 수 없습니다." - "미디어를 저장할 수 없습니다." "사진을 찍을 수 없음" "뒤로" "보관처리됨" @@ -163,13 +152,10 @@ "삭제" "취소" "받는사람" - "여러 이미지 선택" - "선택항목 확인" "+%d" "오디오를 녹음할 수 없습니다. 다시 시도해 주세요." "오디오를 재생할 수 없습니다. 다시 시도해 주세요." "오디오를 저장할 수 없습니다. 다시 시도해 주세요." - "길게 터치" ", " " " ": " @@ -218,14 +204,7 @@ "메시지를 전달하지 못했습니다. 다시 시도하려면 터치하세요." "%s님과 대화" "제목 삭제" - "동영상 촬영" - "정지 이미지 캡처" - "사진 촬영" - "동영상 녹화 시작" - "전체화면 카메라로 전환" "전면 및 후면 카메라 전환" - "녹화 중지 및 비디오 첨부" - "동영상 녹화 중지" "메시지 사진" 사진 %d장이 \"%s\" 앨범에 저장됨 @@ -410,7 +389,6 @@ "차단된 연락처" "차단 해제" "차단된 연락처" - "문서 라이브러리에서 이미지 선택" "메시지를 전송하는 중입니다." "메시지가 전송되었습니다." "이동통신 데이터 사용이 중지되어 있습니다. 설정을 확인하세요." diff --git a/res/values-ky/strings.xml b/res/values-ky/strings.xml index 96b6d86c..6b8a0744 100644 --- a/res/values-ky/strings.xml +++ b/res/values-ky/strings.xml @@ -37,15 +37,7 @@ "Көп байланышкандар" "Бардык байланыштар" "Төмөнкүгө жөнөтүү %s" - "Сүрөттөрдү же видео тартып алуу" - "Бул түзмөктөн сүрөттөрдү тандаңыз" - "Аудио жаздыруу" "Сүрөт тандаңыз" - "Медиа файл тандалды." - "Медиа файл тандоодон чыгарылды." - "%d тандалды" - "сүрөт %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "сүрөт" "Аудио жаздыруу" "Бөлүшүү" "Жаңы эле" @@ -138,9 +130,6 @@ "Баштоо" "Камера жеткиликтүү эмес" - "Камера жеткиликтүү эмес" - "Видеону тартып алуу жеткиликтүү эмес" - "Медиа сакталбай жатат" "Сүрөткө тарта албайт" "Артка" "Архивделди" @@ -163,13 +152,10 @@ "Жок кылуу" "Жокко чыгаруу" "Кимге" - "Бир нече сүрөт тандаңыз" - "Тандалгандарды ырастаңыз" "+%d" "Аудио жазылбай жатат. Дагы аракет кылыңыз." "Аудио ойнотулбай жатат. Дагы аракет кылыңыз." "Аудио сакталган жок. Дагы аракет кылыңыз." - "Коё бербей amp; тийип туруу" ", " " " ": " @@ -218,14 +204,7 @@ "Билдирүү жөнөтүлбөй койду. Кайра аракет кылуу үчүн тийип коюңуз." "%s менен сүйлөшүү" "Темасын жок кылуу" - "Видео жаздыруу" - "Кыймылдабаган сүрөттү тартып алуу" - "Сүрөт тартуу" - "Видео тартып баштоо" - "Камераны толук экранга которуу" "Алдыңкы жана арткы камераны которуштуруу" - "Тартылып жаткан видеону токтотуп, тиркеңиз" - "Видео жаздырууну токтотуу" "SMS/MMS сүрөттөрү" %d сүрөт \"%s\" альбомуна сакталды @@ -410,7 +389,6 @@ "Бөгөттөлгөн байланыштар" "БӨГӨТТӨН ЧЫГАРУУ" "Бөгөттөлгөн байланыштар" - "Документтер китепканасынан сүрөт тандаңыз" "Билдирүү жөнөтүлүүдө…" "Билдирүү жөнөтүлдү" "Уюлдук дайындар өчүрүлгөн. Жөндөөлөрүңүздү текшериңиз." diff --git a/res/values-ldrtl/styles.xml b/res/values-ldrtl/styles.xml index 67dbc3dd..5820eeef 100644 --- a/res/values-ldrtl/styles.xml +++ b/res/values-ldrtl/styles.xml @@ -86,10 +86,6 @@ @dimen/fab_left_right_margin - - - - - - - - diff --git a/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt b/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt new file mode 100644 index 00000000..32bdde43 --- /dev/null +++ b/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt @@ -0,0 +1,66 @@ +package com.android.messaging.data.contact.formatter + +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.util.PhoneUtils +import java.util.Locale +import javax.inject.Inject + +internal interface ContactDestinationFormatter { + + fun canonicalize(value: String): String + + fun canonicalize(value: String, countryCandidates: List): String + + fun formatPhoneForDisplay(value: String): String + + fun countryCandidates(): List +} + +internal class ContactDestinationFormatterImpl @Inject constructor() : ContactDestinationFormatter { + + override fun canonicalize(value: String): String { + return canonicalize(value = value) { trimmed -> + PhoneUtils + .getDefault() + .getCanonicalForEnteredPhoneNumber(trimmed) + } + } + + override fun canonicalize( + value: String, + countryCandidates: List, + ): String { + return canonicalize(value = value) { trimmed -> + PhoneUtils + .getDefault() + .getCanonicalForEnteredPhoneNumber(trimmed, countryCandidates) + } + } + + override fun formatPhoneForDisplay(value: String): String { + return PhoneUtils.getDefault().formatForDisplay(value) + } + + override fun countryCandidates(): List { + val phoneUtils = PhoneUtils + .getDefault() + .apply { + warmUp() + } + + return phoneUtils.countryCandidatesForEnteredPhoneNumber + } + + private inline fun canonicalize( + value: String, + canonicalizePhoneNumber: (trimmed: String) -> String, + ): String { + val trimmed = value.trim() + + return when { + trimmed.isEmpty() -> trimmed + MmsSmsUtils.isEmailAddress(trimmed) -> trimmed.lowercase(Locale.ROOT) + else -> canonicalizePhoneNumber(trimmed) + } + } +} diff --git a/src/com/android/messaging/data/contact/model/Contact.kt b/src/com/android/messaging/data/contact/model/Contact.kt new file mode 100644 index 00000000..a1d7aae1 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/Contact.kt @@ -0,0 +1,13 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class Contact( + val id: Long, + val lookupKey: String, + val displayName: String, + val photoUri: String?, + val destinations: ImmutableList, +) diff --git a/src/com/android/messaging/data/contact/model/ContactDestination.kt b/src/com/android/messaging/data/contact/model/ContactDestination.kt new file mode 100644 index 00000000..edc37cf9 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/ContactDestination.kt @@ -0,0 +1,22 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ContactDestination( + val dataId: Long, + val contactId: Long, + val value: String, + val normalizedValue: String, + val displayValue: String, + val kind: Kind, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, +) { + enum class Kind { + PHONE, + EMAIL, + } +} diff --git a/src/com/android/messaging/data/contact/model/ContactsPage.kt b/src/com/android/messaging/data/contact/model/ContactsPage.kt new file mode 100644 index 00000000..ad58a165 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/ContactsPage.kt @@ -0,0 +1,10 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ContactsPage( + val contacts: ImmutableList, + val nextOffset: Int?, +) diff --git a/src/com/android/messaging/data/contact/repository/ContactsRepository.kt b/src/com/android/messaging/data/contact/repository/ContactsRepository.kt new file mode 100644 index 00000000..938eb153 --- /dev/null +++ b/src/com/android/messaging/data/contact/repository/ContactsRepository.kt @@ -0,0 +1,573 @@ +package com.android.messaging.data.contact.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.Directory +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.core.extension.typedFlow +import javax.inject.Inject +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +internal interface ContactsRepository { + + fun searchContacts( + query: String, + offset: Int, + ): Flow +} + +internal class ContactsRepositoryImpl @Inject constructor( + private val formatter: ContactDestinationFormatter, + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ContactsRepository { + + override fun searchContacts( + query: String, + offset: Int, + ): Flow { + return typedFlow { + queryContactsPage( + query = query, + offset = offset, + ) + }.flowOn(ioDispatcher) + } + + private fun queryContactsPage( + query: String, + offset: Int, + ): ContactsPage { + return when { + query.isBlank() -> queryAllContactsByDefaultPhoneOrder(offset = offset) + else -> queryMatchedContacts(query = query, offset = offset) + } + } + + private fun queryAllContactsByDefaultPhoneOrder(offset: Int): ContactsPage { + val phoneRows = readDestinationRows( + uri = createDefaultPhoneQueryUri(), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val contactGroups = when { + phoneRows.isEmpty() -> emptyList() + else -> groupRowsByContactPreservingOrder(rows = phoneRows) + } + + return paginateAndBuildContacts( + contactGroups = contactGroups, + offset = offset, + ) + } + + private fun queryMatchedContacts(query: String, offset: Int): ContactsPage { + val phoneMatches = readDestinationRows( + uri = createPhoneFilterUri(query = query), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val emailMatches = readDestinationRows( + uri = createEmailFilterUri(query = query), + kind = ContactDestination.Kind.EMAIL, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val mergedMatches = (phoneMatches + emailMatches).sortedBy { it.sortKey } + + if (mergedMatches.isEmpty()) { + val hasDigits = query.any { character -> character.isDigit() } + + return when { + hasDigits -> queryDigitsFallback(query = query, offset = offset) + else -> emptyContactsPage() + } + } + + return paginateMatchedContactIds(matchRows = mergedMatches, offset = offset) + } + + private fun queryDigitsFallback(query: String, offset: Int): ContactsPage { + val queryDigits = extractDigits(value = query) + + val phoneRows = readDestinationRows( + uri = createDefaultPhoneQueryUri(), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val matchingRows = phoneRows.filter { row -> + extractDigits(value = row.value).contains(queryDigits) + } + + return when { + matchingRows.isEmpty() -> emptyContactsPage() + else -> paginateMatchedContactIds(matchRows = matchingRows, offset = offset) + } + } + + private fun paginateMatchedContactIds( + matchRows: List, + offset: Int, + ): ContactsPage { + val contactSortKeys = LinkedHashMap() + + matchRows.forEach { row -> + contactSortKeys.putIfAbsent(row.contactId, row.sortKey) + } + + val orderedContactIds = contactSortKeys.keys.toList() + + val pageStart = offset.coerceAtMost(maximumValue = orderedContactIds.size) + val pageEndExclusive = (pageStart + PAGE_SIZE) + .coerceAtMost(maximumValue = orderedContactIds.size) + + val pageContactIds = orderedContactIds.subList( + fromIndex = pageStart, + toIndex = pageEndExclusive, + ) + + val pageGroups = when { + pageContactIds.isEmpty() -> emptyList() + else -> { + loadAndGroupDestinationsForContacts( + contactIds = pageContactIds, + sortKeysByContactId = contactSortKeys, + ) + } + } + + val pagedContacts = when { + pageGroups.isEmpty() -> persistentListOf() + else -> buildPageContacts(pageGroups = pageGroups) + } + + val nextOffset = pageEndExclusive.takeIf { it < orderedContactIds.size } + + return ContactsPage( + contacts = pagedContacts, + nextOffset = nextOffset, + ) + } + + private fun loadAndGroupDestinationsForContacts( + contactIds: List, + sortKeysByContactId: Map, + ): List> { + val phoneRows = readDestinationRowsForContacts( + uri = Phone.CONTENT_URI, + kind = ContactDestination.Kind.PHONE, + contactIds = contactIds, + ) + + val emailRows = readDestinationRowsForContacts( + uri = Email.CONTENT_URI, + kind = ContactDestination.Kind.EMAIL, + contactIds = contactIds, + ) + + val rowsByContact = LinkedHashMap>() + contactIds.forEach { contactId -> + rowsByContact[contactId] = mutableListOf() + } + + (phoneRows + emailRows).forEach { row -> + rowsByContact[row.contactId]?.add(row) + } + + return rowsByContact.entries + .asSequence() + .filter { (_, rows) -> rows.isNotEmpty() } + .sortedWith( + compareBy>> { + sortKeysByContactId[it.key].orEmpty() + } + .thenBy { it.value.first().displayName } + .thenBy { it.key }, + ) + .map { it.value } + .toList() + } + + private fun emptyContactsPage(): ContactsPage { + return ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + } + + private fun groupRowsByContactPreservingOrder( + rows: List, + ): List> { + val rowsByContact = LinkedHashMap>() + rows.forEach { row -> + rowsByContact.getOrPut(row.contactId) { mutableListOf() }.add(row) + } + + return rowsByContact.values.toList() + } + + private fun readDestinationRowsForContacts( + uri: Uri, + kind: ContactDestination.Kind, + contactIds: Collection, + ): List { + if (contactIds.isEmpty()) { + return emptyList() + } + + val rows = mutableListOf() + + contactIds.chunked(size = CONTACT_ID_CHUNK_SIZE).forEach { chunk -> + val placeholders = chunk.joinToString(separator = ",") { "?" } + val selection = "${Phone.CONTACT_ID} IN ($placeholders)" + val selectionArgs = chunk.map { it.toString() }.toTypedArray() + + rows.addAll( + readDestinationRows( + uri = uri, + kind = kind, + selection = selection, + selectionArgs = selectionArgs, + sortOrder = null, + ), + ) + } + + return rows + } + + private fun readDestinationRows( + uri: Uri, + kind: ContactDestination.Kind, + selection: String? = null, + selectionArgs: Array? = null, + sortOrder: String?, + ): List { + val projection = when (kind) { + ContactDestination.Kind.PHONE -> phoneProjection + ContactDestination.Kind.EMAIL -> emailProjection + } + + val destinationColumnName = when (kind) { + ContactDestination.Kind.PHONE -> Phone.NUMBER + ContactDestination.Kind.EMAIL -> Email.ADDRESS + } + + return contentResolver + .query( + uri, + projection, + selection, + selectionArgs, + sortOrder, + ) + ?.use { cursor -> + mapDestinationRows( + cursor = cursor, + kind = kind, + destinationColumnName = destinationColumnName, + ) + } + ?: emptyList() + } + + private fun mapDestinationRows( + cursor: Cursor, + kind: ContactDestination.Kind, + destinationColumnName: String, + ): List { + val columns = DestinationCursorColumns( + dataIdIndex = cursor.getColumnIndexOrThrow(Phone._ID), + contactIdIndex = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID), + destinationIndex = cursor.getColumnIndexOrThrow(destinationColumnName), + displayNameIndex = cursor.getColumnIndexOrThrow(Phone.DISPLAY_NAME_PRIMARY), + sortKeyIndex = cursor.getColumnIndexOrThrow(Phone.SORT_KEY_PRIMARY), + photoUriIndex = cursor.getColumnIndexOrThrow(Phone.PHOTO_THUMBNAIL_URI), + lookupKeyIndex = cursor.getColumnIndexOrThrow(Phone.LOOKUP_KEY), + typeIndex = cursor.getColumnIndexOrThrow(Phone.TYPE), + labelIndex = cursor.getColumnIndexOrThrow(Phone.LABEL), + isPrimaryIndex = cursor.getColumnIndexOrThrow(Phone.IS_PRIMARY), + isSuperPrimaryIndex = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY), + ) + + val rows = mutableListOf() + + while (cursor.moveToNext()) { + mapDestinationRow( + cursor = cursor, + kind = kind, + columns = columns, + )?.let(rows::add) + } + + return rows + } + + private fun mapDestinationRow( + cursor: Cursor, + kind: ContactDestination.Kind, + columns: DestinationCursorColumns, + ): DestinationRow? { + val value = cursor + .getString(columns.destinationIndex) + ?.trim() + .orEmpty() + + val contactId = cursor.getLong(columns.contactIdIndex) + + val isInvalidRow = value.isBlank() || contactId <= 0L + + if (isInvalidRow) { + return null + } + + val displayName = cursor + .getString(columns.displayNameIndex) + ?.trim() + .orEmpty() + .ifBlank { value } + + val photoUri = cursor + .getString(columns.photoUriIndex) + ?.takeIf { it.isNotBlank() } + + return DestinationRow( + dataId = cursor.getLong(columns.dataIdIndex), + contactId = contactId, + lookupKey = cursor.getString(columns.lookupKeyIndex).orEmpty(), + displayName = displayName, + sortKey = cursor.getString(columns.sortKeyIndex)?.trim().orEmpty(), + photoUri = photoUri, + kind = kind, + value = value, + type = cursor.getInt(columns.typeIndex), + customLabel = cursor.getString(columns.labelIndex)?.takeIf { it.isNotBlank() }, + isPrimary = cursor.getInt(columns.isPrimaryIndex) != 0, + isSuperPrimary = cursor.getInt(columns.isSuperPrimaryIndex) != 0, + ) + } + + private fun buildContact( + rows: List, + canonicalize: (String) -> String, + formatPhoneForDisplay: (String) -> String, + ): Contact { + val first = rows.first() + val orderedRows = rows.sortedWith( + compareByDescending { it.isSuperPrimary } + .thenByDescending { it.isPrimary } + .thenBy { it.kind.ordinal } + .thenBy { it.dataId }, + ) + + val destinations = persistentListOf().builder() + val seenNormalizedValues = LinkedHashSet() + orderedRows.forEach { row -> + val normalizedValue = canonicalize(row.value) + if (seenNormalizedValues.add(normalizedValue)) { + val displayValue = when (row.kind) { + ContactDestination.Kind.EMAIL -> row.value + ContactDestination.Kind.PHONE -> formatPhoneForDisplay(row.value) + } + + destinations.add( + ContactDestination( + dataId = row.dataId, + contactId = row.contactId, + value = row.value, + normalizedValue = normalizedValue, + displayValue = displayValue, + kind = row.kind, + type = row.type, + customLabel = row.customLabel, + isPrimary = row.isPrimary, + isSuperPrimary = row.isSuperPrimary, + ), + ) + } + } + + return Contact( + id = first.contactId, + lookupKey = first.lookupKey, + displayName = first.displayName, + photoUri = first.photoUri, + destinations = destinations.build(), + ) + } + + private fun paginateAndBuildContacts( + contactGroups: List>, + offset: Int, + ): ContactsPage { + val pageStart = offset.coerceAtMost(maximumValue = contactGroups.size) + val pageEndExclusive = (pageStart + PAGE_SIZE) + .coerceAtMost(maximumValue = contactGroups.size) + + val pageGroups = contactGroups.subList(fromIndex = pageStart, toIndex = pageEndExclusive) + + val pagedContacts = when { + pageGroups.isEmpty() -> persistentListOf() + else -> buildPageContacts(pageGroups = pageGroups) + } + + val nextOffset = pageEndExclusive.takeIf { it < contactGroups.size } + + return ContactsPage( + contacts = pagedContacts, + nextOffset = nextOffset, + ) + } + + private fun buildPageContacts( + pageGroups: List>, + ): PersistentList { + val countryCandidates = formatter.countryCandidates() + val canonicalCache = HashMap() + val displayCache = HashMap() + + val canonicalize: (String) -> String = { value -> + canonicalCache.getOrPut(value) { + formatter.canonicalize( + value = value, + countryCandidates = countryCandidates, + ) + } + } + + val formatPhoneForDisplay: (String) -> String = { value -> + displayCache.getOrPut(value) { formatter.formatPhoneForDisplay(value) } + } + + return pageGroups + .map { rows -> + buildContact( + rows = rows, + canonicalize = canonicalize, + formatPhoneForDisplay = formatPhoneForDisplay, + ) + } + .toPersistentList() + } + + private fun createDefaultPhoneQueryUri(): Uri { + return Phone.CONTENT_URI + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createPhoneFilterUri(query: String): Uri { + return Phone.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createEmailFilterUri(query: String): Uri { + return Email.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun extractDigits(value: String): String { + return value.filter { character -> character.isDigit() } + } + + private data class DestinationRow( + val dataId: Long, + val contactId: Long, + val lookupKey: String, + val displayName: String, + val sortKey: String, + val photoUri: String?, + val kind: ContactDestination.Kind, + val value: String, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, + ) + + private data class DestinationCursorColumns( + val dataIdIndex: Int, + val contactIdIndex: Int, + val destinationIndex: Int, + val displayNameIndex: Int, + val sortKeyIndex: Int, + val photoUriIndex: Int, + val lookupKeyIndex: Int, + val typeIndex: Int, + val labelIndex: Int, + val isPrimaryIndex: Int, + val isSuperPrimaryIndex: Int, + ) + + private companion object { + private const val PAGE_SIZE = 200 + private const val CONTACT_ID_CHUNK_SIZE = 500 + + private const val SORT_BY_SORT_KEY_PRIMARY_ASC = "${Phone.SORT_KEY_PRIMARY} ASC" + + private val phoneProjection by lazy { + arrayOf( + Phone._ID, + Phone.CONTACT_ID, + Phone.LOOKUP_KEY, + Phone.DISPLAY_NAME_PRIMARY, + Phone.SORT_KEY_PRIMARY, + Phone.PHOTO_THUMBNAIL_URI, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL, + Phone.IS_PRIMARY, + Phone.IS_SUPER_PRIMARY, + ) + } + + private val emailProjection by lazy { + arrayOf( + Email._ID, + Email.CONTACT_ID, + Email.LOOKUP_KEY, + Email.DISPLAY_NAME_PRIMARY, + Email.SORT_KEY_PRIMARY, + Email.PHOTO_THUMBNAIL_URI, + Email.ADDRESS, + Email.TYPE, + Email.LABEL, + Email.IS_PRIMARY, + Email.IS_SUPER_PRIMARY, + ) + } + } +} diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt new file mode 100644 index 00000000..d2a66f7e --- /dev/null +++ b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt @@ -0,0 +1,93 @@ +package com.android.messaging.data.conversation.mapper + +import androidx.core.net.toUri +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 javax.inject.Inject + +internal interface ConversationDraftMessageDataMapper { + fun map( + conversationId: String, + draft: ConversationDraft, + forceMms: Boolean = false, + ): MessageData +} + +internal class ConversationDraftMessageDataMapperImpl @Inject constructor() : + ConversationDraftMessageDataMapper { + + override fun map( + conversationId: String, + draft: ConversationDraft, + forceMms: Boolean, + ): MessageData { + val selfParticipantId = draft.selfParticipantId.takeIf { it.isNotBlank() } + val messageParts = draft.attachments.mapNotNull(::createMessagePartDataOrNull) + val isMms = forceMms || draft.subjectText.isNotBlank() || messageParts.isNotEmpty() + + val message = when { + isMms -> MessageData.createDraftMmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + draft.subjectText, + ) + + else -> MessageData.createDraftSmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + ) + } + + messageParts.forEach(message::addPart) + + return message + } + + private fun createMessagePartDataOrNull( + attachment: ConversationDraftAttachment, + ): MessagePartData? { + if (attachment.contentType.isBlank() || attachment.contentUri.isBlank()) { + LogUtil.w(TAG, "Dropping draft attachment with blank contentType or contentUri") + return null + } + + val captionText = attachment.captionText.takeIf { it.isNotBlank() } + val contentUri = attachment.contentUri.toUri() + val width = toLegacyPartDimension(size = attachment.width) + val height = toLegacyPartDimension(size = attachment.height) + + return when { + captionText != null -> { + MessagePartData.createMediaMessagePart( + captionText, + attachment.contentType, + contentUri, + width, + height, + ) + } + + else -> { + MessagePartData.createMediaMessagePart( + attachment.contentType, + contentUri, + width, + height, + ) + } + } + } + + private fun toLegacyPartDimension(size: Int?): Int { + return size ?: MessagePartData.UNSPECIFIED_SIZE + } + + private companion object { + private const val TAG = "ConversationDraftMessageDataMapper" + } +} diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt new file mode 100644 index 00000000..55b8729d --- /dev/null +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -0,0 +1,86 @@ +package com.android.messaging.data.conversation.mapper + +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 javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList + +internal interface ConversationMessageDataDraftMapper { + fun map( + messageData: MessageData, + fallbackSelfParticipantId: String? = null, + ): ConversationDraft +} + +internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : + ConversationMessageDataDraftMapper { + + override fun map( + messageData: MessageData, + fallbackSelfParticipantId: String?, + ): ConversationDraft { + return ConversationDraft( + messageText = messageData.messageText, + subjectText = messageData.mmsSubject.orEmpty(), + selfParticipantId = messageData + .selfId + ?.takeIf { it.isNotBlank() } + ?: fallbackSelfParticipantId.orEmpty(), + attachments = messageData.parts + .asSequence() + .filter { it.isAttachment } + .mapNotNull(::createDraftAttachmentOrNull) + .toImmutableList(), + ) + } + + private fun createDraftAttachmentOrNull( + part: MessagePartData, + ): ConversationDraftAttachment? { + val contentType = part.contentType?.takeIf { it.isNotBlank() } + val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } + + return when { + contentUri?.isPhotoPickerUri == true -> { + LogUtil.w(TAG, "Dropping draft attachment backed by photo picker URI") + null + } + + contentType != null && contentUri != null -> { + ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = part.text.orEmpty(), + width = normalizePartDimension(size = part.width), + height = normalizePartDimension(size = part.height), + ) + } + + else -> { + LogUtil.w( + TAG, + "Dropping draft attachment with blank contentType or contentUri", + ) + + null + } + } + } + + private fun normalizePartDimension(size: Int): Int? { + return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } + } + + private val String.isPhotoPickerUri: Boolean + get() { + return startsWith(prefix = PHOTO_PICKER_URI_PREFIX) + } + + 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/conversation/mapper/ConversationVCardMetadataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt new file mode 100644 index 00000000..f8a887c6 --- /dev/null +++ b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt @@ -0,0 +1,51 @@ +package com.android.messaging.data.conversation.mapper + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.datamodel.data.VCardContactItemData +import com.android.messaging.datamodel.media.VCardResourceEntry +import javax.inject.Inject + +internal interface ConversationVCardMetadataMapper { + fun map(vCardContactItemData: VCardContactItemData): ConversationVCardAttachmentMetadata +} + +internal class ConversationVCardMetadataMapperImpl @Inject constructor() : + ConversationVCardMetadataMapper { + + override fun map( + vCardContactItemData: VCardContactItemData, + ): ConversationVCardAttachmentMetadata { + val firstEntry = vCardContactItemData + .vCardResource + ?.vCards + ?.singleOrNull() + + val isLocation = firstEntry + ?.getKind() + ?.equals( + VCardResourceEntry.KIND_LOCATION, + ignoreCase = true, + ) == true + + return ConversationVCardAttachmentMetadata.Loaded( + type = when { + isLocation -> ConversationVCardAttachmentType.LOCATION + else -> ConversationVCardAttachmentType.CONTACT + }, + avatarUri = vCardContactItemData + .avatarUri + ?.toString() + ?.takeIf { avatarUri -> avatarUri.isNotBlank() }, + displayName = vCardContactItemData + .displayName + ?.takeIf { title -> title.isNotBlank() }, + details = vCardContactItemData + .details + ?.takeIf { subtitle -> subtitle.isNotBlank() }, + locationAddress = firstEntry + ?.displayAddress + ?.takeIf { subtitle -> subtitle.isNotBlank() }, + ) + } +} diff --git a/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt new file mode 100644 index 00000000..20bf46ed --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt @@ -0,0 +1,25 @@ +package com.android.messaging.data.conversation.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationVCardAttachmentMetadata { + + @Immutable + data object Missing : ConversationVCardAttachmentMetadata + + @Immutable + data object Loading : ConversationVCardAttachmentMetadata + + @Immutable + data object Failed : ConversationVCardAttachmentMetadata + + @Immutable + data class Loaded( + val type: ConversationVCardAttachmentType, + val avatarUri: String?, + val displayName: String?, + val details: String?, + val locationAddress: String?, + ) : ConversationVCardAttachmentMetadata +} diff --git a/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt new file mode 100644 index 00000000..012b077c --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt @@ -0,0 +1,9 @@ +package com.android.messaging.data.conversation.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal enum class ConversationVCardAttachmentType { + CONTACT, + LOCATION, +} diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt new file mode 100644 index 00000000..0615d0b3 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt @@ -0,0 +1,23 @@ +package com.android.messaging.data.conversation.model.draft + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationDraft( + val messageText: String = "", + val subjectText: String = "", + val selfParticipantId: String = "", + val attachments: ImmutableList = persistentListOf(), + val isCheckingDraft: Boolean = false, + val isSending: Boolean = false, +) { + val hasContent: Boolean + get() = messageText.isNotBlank() || + subjectText.isNotBlank() || + attachments.isNotEmpty() + + val isMms: Boolean + get() = subjectText.isNotBlank() || attachments.isNotEmpty() +} diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt new file mode 100644 index 00000000..5f21d973 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.conversation.model.draft + +// TODO: Probably should be sealed interface and mapped to types on this stage +internal data class ConversationDraftAttachment( + val contentType: String, + val contentUri: String, + val captionText: String = "", + val width: Int? = null, + val height: Int? = null, + val durationMillis: Long? = null, +) diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt new file mode 100644 index 00000000..322d2228 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt @@ -0,0 +1,15 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class ConversationDraftPendingAttachment( + val pendingAttachmentId: String, + val contentUri: String, + val contentType: String, + val displayName: String = "", + val kind: ConversationDraftPendingAttachmentKind = + ConversationDraftPendingAttachmentKind.Generic, +) + +internal enum class ConversationDraftPendingAttachmentKind { + Generic, + AudioFinalizing, +} diff --git a/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt new file mode 100644 index 00000000..2a8e8c8f --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt @@ -0,0 +1,6 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class PhotoPickerDraftAttachment( + val sourceContentUri: String, + val draftAttachment: ConversationDraftAttachment, +) diff --git a/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt b/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt new file mode 100644 index 00000000..26ac8164 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.conversation.model.message + +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData + +internal data class ConversationMessageDetailsData( + val message: ConversationMessageData, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, +) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt new file mode 100644 index 00000000..cdfd85c7 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt @@ -0,0 +1,31 @@ +package com.android.messaging.data.conversation.model.metadata + +internal data class ConversationComposerAvailability( + val isMessageFieldEnabled: Boolean, + val isAttachmentActionEnabled: Boolean, + val isSendAvailable: Boolean, + val disabledReason: ConversationComposerDisabledReason?, +) { + + companion object { + fun editable(): ConversationComposerAvailability { + return ConversationComposerAvailability( + isMessageFieldEnabled = true, + isAttachmentActionEnabled = true, + isSendAvailable = true, + disabledReason = null, + ) + } + + fun unavailable( + reason: ConversationComposerDisabledReason, + ): ConversationComposerAvailability { + return ConversationComposerAvailability( + isMessageFieldEnabled = false, + isAttachmentActionEnabled = false, + isSendAvailable = false, + disabledReason = reason, + ) + } + } +} diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt new file mode 100644 index 00000000..84e50d09 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt @@ -0,0 +1,6 @@ +package com.android.messaging.data.conversation.model.metadata + +internal enum class ConversationComposerDisabledReason { + CONVERSATION_UNAVAILABLE, + READ_ONLY_CONVERSATION, +} diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt new file mode 100644 index 00000000..7548c3af --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -0,0 +1,16 @@ +package com.android.messaging.data.conversation.model.metadata + +internal data class ConversationMetadata( + val conversationName: String, + val selfParticipantId: String, + val isGroupConversation: Boolean, + val includeEmailAddress: Boolean, + val participantCount: Int, + val otherParticipantDisplayDestination: String?, + val otherParticipantNormalizedDestination: String?, + val otherParticipantContactLookupKey: String?, + val otherParticipantPhotoUri: String?, + val isArchived: Boolean, + val composerAvailability: ConversationComposerAvailability, + val sortTimestamp: Long, +) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt new file mode 100644 index 00000000..89d13511 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt @@ -0,0 +1,22 @@ +package com.android.messaging.data.conversation.model.metadata + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationSubscriptionLabel { + + @Immutable + data class Named( + val name: String, + ) : ConversationSubscriptionLabel + + @Immutable + data class Slot( + val slotId: Int, + ) : ConversationSubscriptionLabel + + @Immutable + data class DebugFake( + val slotId: Int, + ) : ConversationSubscriptionLabel +} diff --git a/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt new file mode 100644 index 00000000..173cd195 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt @@ -0,0 +1,12 @@ +package com.android.messaging.data.conversation.model.recipient + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationRecipient( + val id: String, + val displayName: String, + val destination: String, + val photoUri: String? = null, + val secondaryText: String? = null, +) diff --git a/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt b/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt new file mode 100644 index 00000000..f0f84e3d --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.conversation.model.send + +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData + +internal data class ConversationSendData( + val metadata: ConversationMetadata, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt new file mode 100644 index 00000000..f7bfc687 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -0,0 +1,259 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.core.net.toUri +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.store.ConversationDraftStore +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +internal interface ConversationDraftsRepository { + fun observeConversationDraft(conversationId: String): Flow + + suspend fun saveDraft( + conversationId: String, + draft: ConversationDraft, + ) +} + +internal class ConversationDraftsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, + private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val conversationDraftStore: ConversationDraftStore, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationDraftsRepository { + + override fun observeConversationDraft(conversationId: String): Flow { + val draftChangeUri = MessagingContentProvider.buildConversationMetadataUri(conversationId) + + return observeDraftChanges(uri = draftChangeUri) + .conflate() + .map { loadConversationDraft(conversationId = conversationId) } + .catch { e -> + LogUtil.e( + TAG, + "Failed to load draft for conversation $conversationId", + e, + ) + + emit(ConversationDraft()) + } + .flowOn(ioDispatcher) + } + + override suspend fun saveDraft( + conversationId: String, + draft: ConversationDraft, + ) { + withContext(context = ioDispatcher) { + val message = conversationDraftMessageDataMapper.map( + conversationId = conversationId, + draft = draft, + ) + val boundMessage = bindDraftParticipantsIfNeeded( + conversationId = conversationId, + message = message, + ) ?: return@withContext + + conversationDraftStore.updateDraftMessage( + conversationId = conversationId, + message = boundMessage, + ) + + notifyConversationMetadataChanged( + conversationId = conversationId, + ) + } + } + + private fun observeDraftChanges(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + contentResolver.registerContentObserver(uri, true, observer) + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun notifyConversationMetadataChanged(conversationId: String) { + MessagingContentProvider.notifyConversationMetadataChanged(conversationId) + } + + private fun loadConversationDraft(conversationId: String): ConversationDraft { + val selfParticipantId = conversationDraftStore.getSelfParticipantId( + conversationId = conversationId, + ) ?: return ConversationDraft() + + val draftMessage = conversationDraftStore.readDraftMessage( + conversationId = conversationId, + selfParticipantId = selfParticipantId, + ) + + return createConversationDraft( + selfParticipantId = selfParticipantId, + draftMessage = draftMessage, + ) + } + + private fun createConversationDraft( + selfParticipantId: String, + draftMessage: MessageData?, + ): ConversationDraft { + return when (draftMessage) { + null -> { + ConversationDraft( + selfParticipantId = selfParticipantId, + ) + } + + else -> { + resolveDraftAttachmentMetadata( + draft = conversationMessageDataDraftMapper.map( + messageData = draftMessage, + fallbackSelfParticipantId = selfParticipantId, + ), + ) + } + } + } + + private fun resolveDraftAttachmentMetadata(draft: ConversationDraft): ConversationDraft { + val hasAudioAttachments = draft.attachments.any { attachment -> + ContentType.isAudioType(attachment.contentType) + } + + return when { + hasAudioAttachments -> resolveDraftAudioMetadata(draft = draft) + else -> draft + } + } + + private fun resolveDraftAudioMetadata(draft: ConversationDraft): ConversationDraft { + var hasChanges = false + + val resolvedAttachments = draft + .attachments + .map { attachment -> + val isAudio = ContentType.isAudioType(attachment.contentType) + + if (!isAudio || attachment.durationMillis != null) { + return@map attachment + } + + hasChanges = true + attachment.copy( + durationMillis = resolveAudioDurationMillis( + contentUri = attachment.contentUri, + ), + ) + } + + return when { + hasChanges -> { + draft.copy( + attachments = resolvedAttachments.toImmutableList(), + ) + } + else -> draft + } + } + + @Suppress("TooGenericExceptionCaught") + private fun resolveAudioDurationMillis(contentUri: String): Long { + val mediaMetadataRetrieverWrapper = MediaMetadataRetrieverWrapper() + + return try { + mediaMetadataRetrieverWrapper.setDataSource(contentUri.toUri()) + mediaMetadataRetrieverWrapper + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() + ?.coerceAtLeast(minimumValue = 0L) + ?: 0L + } catch (exception: Exception) { + LogUtil.w( + TAG, + "Failed to resolve draft audio duration for $contentUri", + exception, + ) + + 0L + } finally { + mediaMetadataRetrieverWrapper.release() + } + } + + private fun bindDraftParticipantsIfNeeded( + conversationId: String, + message: MessageData, + ): MessageData? { + if (hasDraftParticipants(message = message)) { + return message + } + + return conversationDraftStore + .getSelfParticipantId(conversationId) + ?.let { selfParticipantId -> + bindMissingDraftParticipants( + message = message, + selfParticipantId = selfParticipantId, + ) + } + } + + private fun hasDraftParticipants(message: MessageData): Boolean { + return message.selfId != null && message.participantId != null + } + + private fun bindMissingDraftParticipants( + message: MessageData, + selfParticipantId: String, + ): MessageData { + if (selfParticipantId.isBlank()) { + return message + } + + if (message.selfId == null) { + message.bindSelfId(selfParticipantId) + } + + if (message.participantId == null) { + message.bindParticipantId(selfParticipantId) + } + + return message + } + + private companion object { + private const val TAG = "ConversationDraftsRepository" + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt new file mode 100644 index 00000000..97ef9dc1 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt @@ -0,0 +1,118 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.IoDispatcher +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationParticipantsRepository { + fun getParticipants( + conversationId: String, + ): Flow> +} + +internal class ConversationParticipantsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationParticipantsRepository { + + override fun getParticipants( + conversationId: String, + ): Flow> { + val uri = MessagingContentProvider.buildConversationParticipantsUri(conversationId) + + return observeUri(uri = uri) + .conflate() + .map { + queryParticipants(uri = uri) + } + .flowOn(ioDispatcher) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun queryParticipants( + uri: Uri, + ): ImmutableList { + return contentResolver + .query( + uri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + val participants = persistentListOf().builder() + val seenDestinations = LinkedHashSet() + + while (cursor.moveToNext()) { + val participant = ParticipantData.getFromCursor(cursor) + val recipient = mapParticipant(participant = participant) + + if (recipient != null && seenDestinations.add(recipient.destination)) { + participants.add(recipient) + } + } + + participants.build() + } + ?: persistentListOf() + } + + private fun mapParticipant(participant: ParticipantData): ConversationRecipient? { + val destination = participantDestination(participant = participant) ?: return null + val displayName = participant.getDisplayName(true) + + return ConversationRecipient( + id = participant.id, + displayName = displayName, + destination = destination, + photoUri = participant.profilePhotoUri, + secondaryText = participant.displayDestination + ?.takeIf { it.isNotBlank() } + ?.takeIf { it != displayName }, + ) + } + + private fun participantDestination(participant: ParticipantData): String? { + return when { + participant.isSelf -> null + + else -> { + participant.sendDestination + ?.trim() + ?.takeIf { it.isNotBlank() } + } + } + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt new file mode 100644 index 00000000..852a4252 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt @@ -0,0 +1,72 @@ +package com.android.messaging.data.conversation.repository + +import android.content.Context +import androidx.core.net.toUri +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapper +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.data.PersonItemData +import com.android.messaging.datamodel.data.VCardContactItemData +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf + +internal interface ConversationVCardMetadataRepository { + fun observeAttachmentMetadata( + contentUri: String?, + ): Flow +} + +internal class ConversationVCardMetadataRepositoryImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, + private val conversationVCardMetadataMapper: ConversationVCardMetadataMapper, +) : ConversationVCardMetadataRepository { + + private val dataModel = DataModel.get() + + override fun observeAttachmentMetadata( + contentUri: String?, + ): Flow { + if (contentUri.isNullOrBlank()) { + return flowOf(ConversationVCardAttachmentMetadata.Missing) + } + + return callbackFlow { + trySend(ConversationVCardAttachmentMetadata.Loading) + + val vCardData = dataModel.createVCardContactItemData( + context, + contentUri.toUri(), + ) + val bindingId = "conversation-vcard-inline:$contentUri" + val listener = object : PersonItemData.PersonItemDataListener { + override fun onPersonDataUpdated(data: PersonItemData) { + val typedData = data as? VCardContactItemData ?: return + trySend( + conversationVCardMetadataMapper.map( + vCardContactItemData = typedData, + ), + ) + } + + override fun onPersonDataFailed( + data: PersonItemData, + exception: Exception, + ) { + trySend(ConversationVCardAttachmentMetadata.Failed) + } + } + + vCardData.bind(bindingId) + vCardData.setListener(listener) + + awaitClose { + vCardData.unbind(bindingId) + } + } + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt new file mode 100644 index 00000000..e691586f --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -0,0 +1,365 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsData +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.action.DeleteConversationAction +import com.android.messaging.datamodel.action.DeleteMessageAction +import com.android.messaging.datamodel.action.RedownloadMmsAction +import com.android.messaging.datamodel.action.ResendMessageAction +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.db.ReversedCursor +import com.android.messaging.util.db.ext.getInt +import com.android.messaging.util.db.ext.getLong +import com.android.messaging.util.db.ext.getStringOrEmpty +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationsRepository { + fun getConversationMetadata(conversationId: String): Flow + fun getConversationMessages(conversationId: String): Flow> + fun getConversationSendData( + conversationId: String, + requestedSelfParticipantId: String, + ): ConversationSendData? + + fun getConversationMessage( + conversationId: String, + messageId: String, + ): ConversationMessageData? + + fun deleteMessages(messageIds: Collection) + + fun downloadMessage(messageId: String) + + fun getMessageDetailsData( + conversationId: String, + messageId: String, + ): ConversationMessageDetailsData? + + fun resendMessage(messageId: String) + + fun archiveConversation(conversationId: String) + + fun unarchiveConversation(conversationId: String) + + fun deleteConversation(conversationId: String, cutoffTimestamp: Long) +} + +internal class ConversationsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationsRepository { + + override fun getConversationMetadata(conversationId: String): Flow { + val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId) + + return observeUri(uri = uri) + .map { + queryConversationMetadata(uri = uri) + } + .flowOn(ioDispatcher) + } + + override fun getConversationMessages( + conversationId: String, + ): Flow> { + val uri = MessagingContentProvider.buildConversationMessagesUri(conversationId) + + return observeUri(uri = uri) + .conflate() + .map { + queryConversationMessages(uri = uri) + } + .flowOn(ioDispatcher) + } + + override fun getConversationSendData( + conversationId: String, + requestedSelfParticipantId: String, + ): ConversationSendData? { + val metadata = when { + conversationId.isBlank() -> null + else -> { + MessagingContentProvider + .buildConversationMetadataUri(conversationId) + .let(::queryConversationMetadata) + } + } + + return metadata?.let { conversationMetadata -> + val resolvedSelfParticipantId = requestedSelfParticipantId + .takeIf { it.isNotBlank() } + ?: conversationMetadata.selfParticipantId + + ConversationSendData( + metadata = conversationMetadata, + participants = queryConversationParticipants(conversationId = conversationId), + selfParticipant = queryParticipant(participantId = resolvedSelfParticipantId), + ) + } + } + + override fun getConversationMessage( + conversationId: String, + messageId: String, + ): ConversationMessageData? { + return getConversationMessageData( + conversationId = conversationId, + messageId = messageId, + ) + } + + override fun deleteMessages(messageIds: Collection) { + messageIds + .asSequence() + .filter(String::isNotBlank) + .forEach(DeleteMessageAction::deleteMessage) + } + + override fun downloadMessage(messageId: String) { + messageId + .takeIf { it.isNotBlank() } + ?.let(RedownloadMmsAction::redownloadMessage) + } + + override fun getMessageDetailsData( + conversationId: String, + messageId: String, + ): ConversationMessageDetailsData? { + val message = getConversationMessageData( + conversationId = conversationId, + messageId = messageId, + ) ?: return null + + val participants = queryConversationParticipants( + conversationId = conversationId, + ) + val selfParticipant = queryParticipant( + participantId = message.selfParticipantId, + ) + + return ConversationMessageDetailsData( + message = message, + participants = participants, + selfParticipant = selfParticipant, + ) + } + + override fun resendMessage(messageId: String) { + messageId + .takeIf { it.isNotBlank() } + ?.let(ResendMessageAction::resendMessage) + } + + override fun archiveConversation(conversationId: String) { + conversationId + .takeIf { it.isNotBlank() } + ?.let(UpdateConversationArchiveStatusAction::archiveConversation) + } + + override fun unarchiveConversation(conversationId: String) { + conversationId + .takeIf { it.isNotBlank() } + ?.let(UpdateConversationArchiveStatusAction::unarchiveConversation) + } + + override fun deleteConversation(conversationId: String, cutoffTimestamp: Long) { + if (conversationId.isBlank()) { + return + } + + DeleteConversationAction.deleteConversation( + conversationId, + cutoffTimestamp, + ) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun getConversationMessageData( + conversationId: String, + messageId: String, + ): ConversationMessageData? { + if (conversationId.isBlank() || messageId.isBlank()) { + return null + } + + return when { + conversationId.isBlank() || messageId.isBlank() -> null + + else -> { + MessagingContentProvider + .buildConversationMessagesUri(conversationId) + .let(::queryConversationMessages) + .firstOrNull { it.messageId == messageId } + } + } + } + + private fun queryConversationMetadata(uri: Uri): ConversationMetadata? { + return contentResolver + .query( + uri, + ConversationListItemData.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + + val participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) + + val otherParticipant = when { + participantCount == 1 -> queryConversationOtherParticipant(uri = uri) + else -> null + } + + val otherParticipantContactLookupKey = otherParticipant + ?.lookupKey + ?.takeIf { it.isNotBlank() } + ?: cursor + .getStringOrEmpty(ConversationColumns.PARTICIPANT_LOOKUP_KEY) + .takeIf { it.isNotBlank() } + + ConversationMetadata( + conversationName = cursor.getStringOrEmpty(ConversationColumns.NAME), + selfParticipantId = cursor.getStringOrEmpty( + ConversationColumns.CURRENT_SELF_ID, + ), + isGroupConversation = participantCount > 1, + includeEmailAddress = cursor.getInt( + ConversationColumns.INCLUDE_EMAIL_ADDRESS, + ) == 1, + participantCount = participantCount, + otherParticipantDisplayDestination = otherParticipant + ?.displayDestination + ?.takeIf { it.isNotBlank() }, + otherParticipantNormalizedDestination = cursor + .getStringOrEmpty( + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, + ) + .takeIf { it.isNotBlank() }, + otherParticipantContactLookupKey = otherParticipantContactLookupKey, + otherParticipantPhotoUri = otherParticipant + ?.profilePhotoUri + ?.takeIf { it.isNotBlank() }, + isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, + composerAvailability = ConversationComposerAvailability.editable(), + sortTimestamp = cursor.getLong(ConversationColumns.SORT_TIMESTAMP), + ) + } + } + + private fun queryConversationOtherParticipant(uri: Uri): ParticipantData? { + val conversationId = uri.lastPathSegment + ?.takeIf { it.isNotBlank() } + ?: return null + + val participants = queryConversationParticipants(conversationId = conversationId) + return participants.getOtherParticipant() + } + + private fun queryConversationParticipants( + conversationId: String, + ): ConversationParticipantsData { + val uri = MessagingContentProvider.buildConversationParticipantsUri(conversationId) + + return contentResolver + .query( + uri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + ConversationParticipantsData().apply { + bind(cursor) + } + } + ?: ConversationParticipantsData() + } + + private fun queryParticipant( + participantId: String?, + ): ParticipantData? { + if (participantId.isNullOrBlank()) { + return null + } + + return contentResolver + .query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf(participantId), + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + + ParticipantData.getFromCursor(cursor) + } + } + + private fun queryConversationMessages(uri: Uri): List { + return contentResolver + .query( + uri, + ConversationMessageData.getProjection(), + null, + null, + null, + ) + ?.use { rawCursor -> + val reversedCursor = ReversedCursor(cursor = rawCursor) + + buildList(capacity = rawCursor.count) { + while (reversedCursor.moveToNext()) { + add(ConversationMessageData().apply { bind(reversedCursor) }) + } + } + } + ?: emptyList() + } +} diff --git a/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt new file mode 100644 index 00000000..5786a8c6 --- /dev/null +++ b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt @@ -0,0 +1,56 @@ +package com.android.messaging.data.conversation.store + +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.MessageData +import javax.inject.Inject + +internal interface ConversationDraftStore { + fun getSelfParticipantId(conversationId: String): String? + + fun readDraftMessage( + conversationId: String, + selfParticipantId: String, + ): MessageData? + + fun updateDraftMessage( + conversationId: String, + message: MessageData, + ) +} + +internal class ConversationDraftStoreImpl @Inject constructor() : ConversationDraftStore { + + override fun getSelfParticipantId(conversationId: String): String? { + val conversation = ConversationListItemData.getExistingConversation( + DataModel.get().database, + conversationId, + ) ?: return null + + return conversation.selfId?.takeIf { it.isNotBlank() } + } + + override fun readDraftMessage( + conversationId: String, + selfParticipantId: String, + ): MessageData? { + return BugleDatabaseOperations.readDraftMessageData( + DataModel.get().database, + conversationId, + selfParticipantId, + ) + } + + override fun updateDraftMessage( + conversationId: String, + message: MessageData, + ) { + BugleDatabaseOperations.updateDraftMessageData( + DataModel.get().database, + conversationId, + message, + BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT, + ) + } +} diff --git a/src/com/android/messaging/data/media/model/AttachmentToSave.kt b/src/com/android/messaging/data/media/model/AttachmentToSave.kt new file mode 100644 index 00000000..3f7c2732 --- /dev/null +++ b/src/com/android/messaging/data/media/model/AttachmentToSave.kt @@ -0,0 +1,6 @@ +package com.android.messaging.data.media.model + +internal data class AttachmentToSave( + val contentType: String, + val contentUri: String, +) diff --git a/src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt b/src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt new file mode 100644 index 00000000..da1609aa --- /dev/null +++ b/src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt @@ -0,0 +1,8 @@ +package com.android.messaging.data.media.model + +internal data class ConversationCapturedMedia( + val contentUri: String, + val contentType: String, + val width: Int? = null, + val height: Int? = null, +) diff --git a/src/com/android/messaging/data/media/model/ConversationMediaItem.kt b/src/com/android/messaging/data/media/model/ConversationMediaItem.kt new file mode 100644 index 00000000..83cdb675 --- /dev/null +++ b/src/com/android/messaging/data/media/model/ConversationMediaItem.kt @@ -0,0 +1,16 @@ +package com.android.messaging.data.media.model + +internal data class ConversationMediaItem( + val mediaId: String, + val contentUri: String, + val contentType: String, + val mediaType: ConversationMediaType, + val width: Int?, + val height: Int?, + val durationMillis: Long?, +) + +internal enum class ConversationMediaType { + Image, + Video, +} diff --git a/src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt b/src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt new file mode 100644 index 00000000..e1fc2e0c --- /dev/null +++ b/src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt @@ -0,0 +1,13 @@ +package com.android.messaging.data.media.model + +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment + +internal sealed interface PhotoPickerDraftAttachmentResult { + data class Resolved( + val photoPickerDraftAttachment: PhotoPickerDraftAttachment, + ) : PhotoPickerDraftAttachmentResult + + data class Failed( + val sourceContentUri: String, + ) : PhotoPickerDraftAttachmentResult +} diff --git a/src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt b/src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt new file mode 100644 index 00000000..305c42d9 --- /dev/null +++ b/src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt @@ -0,0 +1,8 @@ +package com.android.messaging.data.media.model + +internal data class SaveAttachmentsResult( + val imageCount: Int, + val videoCount: Int, + val otherCount: Int, + val failCount: Int, +) diff --git a/src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt b/src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt new file mode 100644 index 00000000..7b33db62 --- /dev/null +++ b/src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt @@ -0,0 +1,544 @@ +package com.android.messaging.data.media.repository + +import android.content.ContentResolver +import android.content.ContentValues +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment +import android.provider.ContactsContract.Contacts +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.core.database.getStringOrNull +import androidx.core.net.toUri +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.model.SaveAttachmentsResult +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.typedFlow +import com.android.messaging.util.core.extension.unitFlow +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn + +internal interface ConversationAttachmentsRepository { + fun createDraftAttachmentsFromPhotoPicker( + contentUris: List, + ): Flow + + fun createDraftAttachmentFromContact( + contactUri: String, + ): Flow + + fun deleteTemporaryAttachment( + contentUri: String, + ): Flow + + fun saveAttachmentsToMediaStore( + attachments: List, + ): Flow +} + +internal class ConversationAttachmentsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationAttachmentsRepository { + + @Suppress("TooGenericExceptionCaught") + override fun createDraftAttachmentsFromPhotoPicker( + contentUris: List, + ): Flow { + return flow { + for (contentUri in contentUris) { + val attachment = try { + createDraftAttachmentFromPhotoPicker(contentUri = contentUri) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to resolve photo picker attachment $contentUri", e) + null + } + + val result = when (attachment) { + null -> { + PhotoPickerDraftAttachmentResult.Failed( + sourceContentUri = contentUri, + ) + } + + else -> { + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = attachment, + ) + } + } + + emit(result) + } + }.flowOn(ioDispatcher) + } + + override fun createDraftAttachmentFromContact( + contactUri: String, + ): Flow { + return typedFlow { + queryDraftAttachmentFromContact(contactUri = contactUri) + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w( + TAG, + "Failed to resolve contact draft attachment for $contactUri", + throwable, + ) + emit(null) + }.flowOn(ioDispatcher) + } + + override fun deleteTemporaryAttachment(contentUri: String): Flow { + return unitFlow { + val attachmentUri = contentUri.toUri() + if (MediaScratchFileProvider.isMediaScratchSpaceUri(attachmentUri)) { + contentResolver.delete(attachmentUri, null, null) + } + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Failed to delete temporary attachment $contentUri", throwable) + emit(Unit) + }.flowOn(ioDispatcher) + } + + override fun saveAttachmentsToMediaStore( + attachments: List, + ): Flow { + return typedFlow { + saveAttachments(attachments = attachments) + }.flowOn(ioDispatcher) + } + + private fun saveAttachments( + attachments: List, + ): SaveAttachmentsResult { + var imageCount = 0 + var videoCount = 0 + var otherCount = 0 + var failCount = 0 + + for (attachment in attachments) { + val target = mediaStoreTarget(contentType = attachment.contentType) + val saved = saveOne( + sourceUri = attachment.contentUri.toUri(), + contentType = attachment.contentType, + target = target, + ) + + if (!saved) { + failCount++ + continue + } + + when (target.kind) { + MediaKind.Image -> imageCount++ + MediaKind.Video -> videoCount++ + MediaKind.Audio, + MediaKind.Other, + -> otherCount++ + } + } + + return SaveAttachmentsResult( + imageCount = imageCount, + videoCount = videoCount, + otherCount = otherCount, + failCount = failCount, + ) + } + + private fun createDraftAttachmentFromPhotoPicker( + contentUri: String, + ): PhotoPickerDraftAttachment? { + val prepared = preparePhotoPickerContent(contentUri = contentUri) + ?: return null + + val metadata = resolveVisualAttachmentMetadata( + uri = prepared.scratchUri, + contentType = prepared.contentType, + ) + + return PhotoPickerDraftAttachment( + sourceContentUri = contentUri, + draftAttachment = ConversationDraftAttachment( + contentType = prepared.contentType, + contentUri = prepared.scratchUri.toString(), + width = metadata.width, + height = metadata.height, + durationMillis = metadata.durationMillis, + ), + ) + } + + private fun preparePhotoPickerContent( + contentUri: String, + ): PreparedPhotoPickerContent? { + if (contentUri.isBlank()) { + return null + } + + val sourceUri = contentUri.toUri() + val contentType = resolvePickerContentType(uri = sourceUri) + val isVisualContent = ContentType.isImageType(contentType) || + ContentType.isVideoType(contentType) + + return when { + !isVisualContent -> { + LogUtil.w(TAG, "Dropping unsupported photo picker attachment $contentUri") + null + } + + else -> { + copyPhotoPickerContentToScratchSpace( + sourceUri = sourceUri, + contentType = contentType, + )?.let { scratchUri -> + PreparedPhotoPickerContent( + scratchUri = scratchUri, + contentType = contentType, + ) + } + } + } + } + + private fun saveOne( + sourceUri: Uri, + contentType: String, + target: MediaStoreTarget, + ): Boolean { + val pendingUri = insertPendingRow( + contentType = contentType, + target = target, + ) ?: return false + + val copied = copyToPending( + sourceUri = sourceUri, + pendingUri = pendingUri, + ) + + if (copied) { + finalizePendingRow(pendingUri = pendingUri) + } else { + deletePendingRow(pendingUri = pendingUri) + } + + return copied + } + + @Suppress("TooGenericExceptionCaught") + private fun insertPendingRow( + contentType: String, + target: MediaStoreTarget, + ): Uri? { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, buildDisplayName(contentType = contentType)) + put(MediaStore.MediaColumns.MIME_TYPE, contentType) + put(MediaStore.MediaColumns.RELATIVE_PATH, target.relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + return try { + contentResolver.insert(target.collection, values) + } catch (e: Exception) { + LogUtil.e(TAG, "MediaStore insert failed for $contentType", e) + null + } + } + + private fun copyToPending( + sourceUri: Uri, + pendingUri: Uri, + ): Boolean { + return try { + copyUriContentOrThrow( + sourceUri = sourceUri, + targetUri = pendingUri, + ) + true + } catch (e: IOException) { + LogUtil.e(TAG, "Copy to MediaStore failed for $sourceUri", e) + false + } catch (e: SecurityException) { + LogUtil.e(TAG, "Copy to MediaStore denied for $sourceUri", e) + false + } + } + + private fun buildDisplayName(contentType: String): String { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(contentType) ?: "bin" + return "${System.currentTimeMillis()}.$extension" + } + + private fun mediaStoreTarget(contentType: String): MediaStoreTarget { + val volume = MediaStore.VOLUME_EXTERNAL_PRIMARY + + return when { + ContentType.isImageType(contentType) -> MediaStoreTarget( + collection = MediaStore.Images.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_PICTURES + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Image, + ) + + ContentType.isVideoType(contentType) -> MediaStoreTarget( + collection = MediaStore.Video.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_PICTURES + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Video, + ) + + ContentType.isAudioType(contentType) -> MediaStoreTarget( + collection = MediaStore.Audio.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_MUSIC + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Audio, + ) + + else -> MediaStoreTarget( + collection = MediaStore.Downloads.getContentUri(volume), + relativePath = Environment.DIRECTORY_DOWNLOADS, + kind = MediaKind.Other, + ) + } + } + + private fun deletePendingRow(pendingUri: Uri) { + runCatching { contentResolver.delete(pendingUri, null, null) } + } + + private fun finalizePendingRow(pendingUri: Uri) { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + runCatching { contentResolver.update(pendingUri, values, null, null) } + } + + private fun copyPhotoPickerContentToScratchSpace( + sourceUri: Uri, + contentType: String, + ): Uri? { + val scratchUri = createScratchUri(contentType = contentType) + val isCopied = copyPhotoPickerContent( + sourceUri = sourceUri, + scratchUri = scratchUri, + ) + + return when { + isCopied -> scratchUri + + else -> { + deleteScratchContent(scratchUri = scratchUri) + null + } + } + } + + private fun createScratchUri(contentType: String): Uri { + return MimeTypeMap + .getSingleton() + .getExtensionFromMimeType(contentType) + .let(MediaScratchFileProvider::buildMediaScratchSpaceUri) + } + + private fun copyPhotoPickerContent(sourceUri: Uri, scratchUri: Uri): Boolean { + return try { + copyUriContentOrThrow( + sourceUri = sourceUri, + targetUri = scratchUri, + ) + + true + } catch (e: IOException) { + LogUtil.w(TAG, "Failed to copy photo picker content $sourceUri", e) + false + } catch (e: SecurityException) { + LogUtil.w(TAG, "Permission denied while copying photo picker content $sourceUri", e) + false + } + } + + private fun copyUriContentOrThrow(sourceUri: Uri, targetUri: Uri) { + val sourceStream = contentResolver.openInputStream(sourceUri) + ?: throw IOException("Unable to open input stream for $sourceUri") + + sourceStream.use { source -> + val targetStream = contentResolver.openOutputStream(targetUri) + ?: throw IOException("Unable to open output stream for $targetUri") + + targetStream.use(source::copyTo) + } + } + + private fun deleteScratchContent(scratchUri: Uri) { + runCatching { + contentResolver.delete(scratchUri, null, null) + } + } + + private fun resolvePickerContentType(uri: Uri): String { + val contentType = contentResolver + .getType(uri) + ?.takeIf { it.isNotBlank() } + + if (contentType != null) { + return contentType + } + + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + val extensionContentType = MimeTypeMap + .getSingleton() + .getMimeTypeFromExtension(extension) + ?.takeIf { it.isNotBlank() } + + return when { + extensionContentType != null -> extensionContentType + else -> ContentType.IMAGE_UNSPECIFIED + } + } + + private fun resolveVisualAttachmentMetadata( + uri: Uri, + contentType: String, + ): VisualAttachmentMetadata { + return when { + ContentType.isVideoType(contentType) -> resolveVideoAttachmentMetadata(uri = uri) + else -> resolveImageAttachmentMetadata(uri = uri) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun resolveImageAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { + val decodeBoundsOptions = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + try { + contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) + } + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to decode photo picker image bounds for $uri", e) + } + + return VisualAttachmentMetadata( + width = decodeBoundsOptions.outWidth.takeIf { it > 0 }, + height = decodeBoundsOptions.outHeight.takeIf { it > 0 }, + durationMillis = null, + ) + } + + @Suppress("TooGenericExceptionCaught") + private fun resolveVideoAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { + val retriever = MediaMetadataRetriever() + + return try { + contentResolver.openAssetFileDescriptor(uri, "r")?.use { fileDescriptor -> + retriever.setDataSource(fileDescriptor.fileDescriptor) + } + + VisualAttachmentMetadata( + width = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() + ?.takeIf { it > 0 }, + height = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() + ?.takeIf { it > 0 }, + durationMillis = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() + ?.takeIf { it > 0 }, + ) + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to decode photo picker video metadata for $uri", e) + VisualAttachmentMetadata() + } finally { + try { + retriever.release() + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to release media metadata retriever", e) + } + } + } + + private fun queryDraftAttachmentFromContact( + contactUri: String, + ): ConversationDraftAttachment? { + val lookupKey = contentResolver.query( + contactUri.toUri(), + arrayOf(Contacts.LOOKUP_KEY), + null, + null, + null, + )?.use { cursor -> + val lookupKeyColumnIndex = cursor.getColumnIndexOrThrow(Contacts.LOOKUP_KEY) + + when { + cursor.moveToFirst() -> cursor.getStringOrNull(lookupKeyColumnIndex) + else -> null + } + } + + if (lookupKey.isNullOrBlank()) { + LogUtil.w(TAG, "Unable to resolve contact lookup key for $contactUri") + return null + } + + val vCardUri = Uri.withAppendedPath( + Contacts.CONTENT_VCARD_URI, + lookupKey, + ) + + return ConversationDraftAttachment( + contentType = ContentType.TEXT_VCARD, + contentUri = vCardUri.toString(), + ) + } + + private companion object { + private const val TAG = "ConversationAttachmentRepository" + + private const val SAVED_ATTACHMENTS_FOLDER = "Messaging" + } +} + +private data class PreparedPhotoPickerContent( + val scratchUri: Uri, + val contentType: String, +) + +private data class VisualAttachmentMetadata( + val width: Int? = null, + val height: Int? = null, + val durationMillis: Long? = null, +) + +private enum class MediaKind { Image, Video, Audio, Other } + +private data class MediaStoreTarget( + val collection: Uri, + val relativePath: String, + val kind: MediaKind, +) diff --git a/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt b/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt new file mode 100644 index 00000000..86403a70 --- /dev/null +++ b/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt @@ -0,0 +1,140 @@ +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.data.media.model.ConversationMediaType +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 mediaType = getMediaType(mediaTypeValue = mediaTypeValue) + + val item = ConversationMediaItem( + mediaId = mediaStoreId.toString(), + contentUri = UriUtil + .getContentUriForMediaStoreId(mediaStoreId) + .toString(), + contentType = cursor + .getString(mimeTypeIndex) + ?.takeIf { it.isNotBlank() } + ?: fallbackContentType(mediaTypeValue = mediaTypeValue), + mediaType = mediaType, + width = cursor.getInt(widthIndex).takeIf { it > 0 }, + height = cursor.getInt(heightIndex).takeIf { it > 0 }, + durationMillis = cursor.getLong(durationIndex).takeIf { it > 0 }, + ) + + add(item) + } + } + } ?: emptyList() + } + + 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 getMediaType(mediaTypeValue: Int): ConversationMediaType { + return when (mediaTypeValue) { + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> ConversationMediaType.Video + else -> ConversationMediaType.Image + } + } + + 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/data/subscription/model/Subscription.kt b/src/com/android/messaging/data/subscription/model/Subscription.kt new file mode 100644 index 00000000..fe22596f --- /dev/null +++ b/src/com/android/messaging/data/subscription/model/Subscription.kt @@ -0,0 +1,14 @@ +package com.android.messaging.data.subscription.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel + +@Immutable +internal data class Subscription( + val selfParticipantId: String, + val subId: Int, + val label: ConversationSubscriptionLabel, + val displayDestination: String?, + val displaySlotId: Int, + val color: Int, +) diff --git a/src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt b/src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt new file mode 100644 index 00000000..93d001ac --- /dev/null +++ b/src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt @@ -0,0 +1,285 @@ +package com.android.messaging.data.subscription.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.debug.DebugSimEmulationMode +import com.android.messaging.debug.DebugSimEmulationSource +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.sms.MmsConfig +import com.android.messaging.util.BugleGservices +import com.android.messaging.util.BugleGservicesKeys +import com.android.messaging.util.LogUtil +import com.android.messaging.util.PhoneUtils +import com.android.messaging.util.core.extension.typedFlow +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface SubscriptionsRepository { + fun observeActiveSubscriptions(): Flow> + + fun getDefaultSmsSubscriptionId(): Int + + fun resolveAttachmentLimit(): Int + + fun resolveMaxMessageSize(selfParticipantId: String): Flow +} + +internal class SubscriptionsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val debugSimEmulationSource: DebugSimEmulationSource, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : SubscriptionsRepository { + + override fun observeActiveSubscriptions(): Flow> { + val uri = MessagingContentProvider.PARTICIPANTS_URI + + val realSubscriptions = observeUri(uri = uri) + .conflate() + .map { + queryActiveSubscriptions() + } + .flowOn(ioDispatcher) + + return combine( + realSubscriptions, + debugSimEmulationSource.mode, + ) { subscriptions, emulationMode -> + applyDebugEmulation( + subscriptions = subscriptions, + mode = emulationMode, + ) + } + } + + override fun getDefaultSmsSubscriptionId(): Int { + return PhoneUtils.getDefault().defaultSmsSubscriptionId + } + + override fun resolveAttachmentLimit(): Int { + return BugleGservices + .get() + .getInt( + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT, + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT, + ) + } + + override fun resolveMaxMessageSize(selfParticipantId: String): Flow { + return typedFlow { + queryMaxMessageSize(selfParticipantId = selfParticipantId) + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Failed to resolve max message size", throwable) + emit(MmsConfig.getMaxMaxMessageSize()) + }.flowOn(ioDispatcher) + } + + private fun applyDebugEmulation( + subscriptions: ImmutableList, + mode: DebugSimEmulationMode, + ): ImmutableList { + return when (mode) { + DebugSimEmulationMode.DEFAULT -> subscriptions + DebugSimEmulationMode.SINGLE -> applySingleSimEmulation(subscriptions = subscriptions) + DebugSimEmulationMode.DUAL -> applyDualSimEmulation(subscriptions = subscriptions) + } + } + + private fun applySingleSimEmulation( + subscriptions: ImmutableList, + ): ImmutableList { + val hasRealSubscription = subscriptions.isNotEmpty() + + if (hasRealSubscription) { + return subscriptions + } + + return persistentListOf( + fakeSubscription(slotId = 1, colorIndex = 0), + ) + } + + private fun applyDualSimEmulation( + subscriptions: ImmutableList, + ): ImmutableList { + return when (subscriptions.size) { + 0 -> { + persistentListOf( + fakeSubscription(slotId = 1, colorIndex = 0), + fakeSubscription(slotId = 2, colorIndex = 1), + ) + } + + 1 -> pairRealSubscriptionWithFake(realSubscription = subscriptions.first()) + + else -> subscriptions + } + } + + private fun pairRealSubscriptionWithFake( + realSubscription: Subscription, + ): ImmutableList { + val fakeSlot = when (realSubscription.displaySlotId) { + 1 -> 2 + else -> 1 + } + return sequenceOf( + realSubscription, + fakeSubscription(slotId = fakeSlot, colorIndex = 1), + ) + .sortedBy { subscription -> subscription.displaySlotId } + .toImmutableList() + } + + private fun fakeSubscription( + slotId: Int, + colorIndex: Int, + ): Subscription { + return Subscription( + selfParticipantId = "$FAKE_SIM_ID_PREFIX$slotId", + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + label = ConversationSubscriptionLabel.DebugFake(slotId = slotId), + displayDestination = null, + displaySlotId = slotId, + color = FAKE_SIM_COLORS[colorIndex % FAKE_SIM_COLORS.size], + ) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun queryActiveSubscriptions(): ImmutableList { + return contentResolver + .query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns.SUB_ID} <> ?", + arrayOf(ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), + null, + ) + ?.use { cursor -> + val subscriptions = persistentListOf().builder() + + while (cursor.moveToNext()) { + val participant = ParticipantData.getFromCursor(cursor) + + val shouldSkip = !participant.isSelf || + participant.isDefaultSelf || + !participant.isActiveSubscription + + if (shouldSkip) { + continue + } + + subscriptions.add(participant.toConversationSubscription()) + } + + subscriptions + .build() + .sortedBy { subscription -> subscription.displaySlotId } + .toImmutableList() + } + ?: persistentListOf() + } + + private fun queryMaxMessageSize( + selfParticipantId: String, + ): Int { + val resolvedSubId = resolveSubscriptionId(selfParticipantId) + + return when { + resolvedSubId == null || resolvedSubId <= ParticipantData.DEFAULT_SELF_SUB_ID -> { + MmsConfig.getMaxMaxMessageSize() + } + + else -> { + MmsConfig.get(resolvedSubId).maxMessageSize + } + } + } + + private fun resolveSubscriptionId(selfParticipantId: String): Int? { + if (selfParticipantId.isBlank()) { + return null + } + + return contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf(selfParticipantId), + null, + )?.use { cursor -> + when { + cursor.moveToFirst() -> { + ParticipantData.getFromCursor(cursor).subId + } + else -> null + } + } + } + + private companion object { + private const val TAG = "ConversationSubscriptionsRepo" + private const val FAKE_SIM_ID_PREFIX = "debug_sim_emulated_" + private val FAKE_SIM_COLORS = intArrayOf( + 0xFF5E9BE8.toInt(), + 0xFFE97E6A.toInt(), + ) + } + + private fun ParticipantData.toConversationSubscription(): Subscription { + val slotId = displaySlotId + + return Subscription( + selfParticipantId = id, + subId = subId, + label = when { + subscriptionName.isNullOrBlank() -> ConversationSubscriptionLabel.Slot( + slotId = slotId, + ) + + else -> ConversationSubscriptionLabel.Named(name = subscriptionName) + }, + displayDestination = displayDestination?.takeIf { it.isNotBlank() }, + displaySlotId = slotId, + color = subscriptionColor, + ) + } +} diff --git a/src/com/android/messaging/datamodel/data/ParticipantData.java b/src/com/android/messaging/datamodel/data/ParticipantData.java index 95c74e24..2e501f7f 100644 --- a/src/com/android/messaging/datamodel/data/ParticipantData.java +++ b/src/com/android/messaging/datamodel/data/ParticipantData.java @@ -179,7 +179,7 @@ public static ParticipantData getFromRecipientEntry(final RecipientEntry recipie pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); pd.mNormalizedDestination = pd.mIsEmailAddress ? pd.mSendDestination : - PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + PhoneUtils.getDefault().getCanonicalForEnteredPhoneNumber(pd.mSendDestination); pd.mDisplayDestination = pd.mIsEmailAddress ? pd.mNormalizedDestination : PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); @@ -223,7 +223,8 @@ private static ParticipantData getFromRawPhone(final String phoneNumber) { } /** - * Get an instance from a raw phone number and using system locale to normalize it. + * Get an instance from a raw phone number and using the best available telephony or locale + * signal to normalize it. * * Use this when creating a participant that is for displaying UI and not associated * with a specific SIM. For example, when creating a conversation using user entered @@ -236,7 +237,7 @@ public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNu final ParticipantData pd = getFromRawPhone(phoneNumber); pd.mNormalizedDestination = pd.mIsEmailAddress ? pd.mSendDestination : - PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + PhoneUtils.getDefault().getCanonicalForEnteredPhoneNumber(pd.mSendDestination); pd.mDisplayDestination = pd.mIsEmailAddress ? pd.mNormalizedDestination : PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); diff --git a/src/com/android/messaging/debug/DebugSimEmulation.kt b/src/com/android/messaging/debug/DebugSimEmulation.kt new file mode 100644 index 00000000..92642835 --- /dev/null +++ b/src/com/android/messaging/debug/DebugSimEmulation.kt @@ -0,0 +1,51 @@ +package com.android.messaging.debug + +import com.android.messaging.util.BuglePrefs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class DebugSimEmulationMode { + DEFAULT, + SINGLE, + DUAL, +} + +internal interface DebugSimEmulationSource { + val mode: StateFlow +} + +object DebugSimEmulationStore : DebugSimEmulationSource { + + private const val PREF_KEY = "debug_sim_emulation_mode" + + private val _mode: MutableStateFlow by lazy { + MutableStateFlow(value = loadPersistedMode()) + } + + override val mode = _mode.asStateFlow() + + @JvmStatic + fun getCurrentMode(): DebugSimEmulationMode { + return _mode.value + } + + @JvmStatic + fun setMode(mode: DebugSimEmulationMode) { + if (_mode.value == mode) { + return + } + + _mode.value = mode + BuglePrefs.getApplicationPrefs().putString(PREF_KEY, mode.name) + } + + private fun loadPersistedMode(): DebugSimEmulationMode { + val stored = BuglePrefs + .getApplicationPrefs() + .getString(PREF_KEY, DebugSimEmulationMode.DEFAULT.name) + + return runCatching { DebugSimEmulationMode.valueOf(value = stored) } + .getOrDefault(defaultValue = DebugSimEmulationMode.DEFAULT) + } +} diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index b5f3de11..3dc7b2a4 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -11,6 +11,8 @@ import android.graphics.Color import android.graphics.Paint import android.net.Uri import androidx.core.graphics.createBitmap +import androidx.core.net.toUri +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns @@ -19,23 +21,61 @@ import com.android.messaging.datamodel.DatabaseHelper.MessageColumns import com.android.messaging.datamodel.DatabaseHelper.PartColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.DatabaseWrapper +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.ParticipantData import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.db.ext.withTransaction +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream import java.io.File import java.io.FileOutputStream +import kotlin.math.PI +import kotlin.math.sin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking private const val TAG = "TestDataSeeder" private const val TEST_PHONE_PREFIX = "+15550" +private const val TEST_YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +private const val TEST_LINK_MESSAGE_URL = "https://grapheneos.org" +private const val MEDIA_SCRATCH_FILE_EXTENSION_QUERY_PARAMETER = "ext" +private const val SEED_IMAGE_1_FILE_ID = "800001" +private const val SEED_IMAGE_2_FILE_ID = "800002" +private const val SEED_IMAGE_3_FILE_ID = "800003" +private const val SEED_CONTACT_VCARD_FILE_ID = "800004" +private const val SEED_VIDEO_FILE_ID = "800005" +private const val SEED_AUDIO_FILE_ID = "800006" +private const val SEED_LOCATION_VCARD_FILE_ID = "800007" +private const val SEED_AUDIO_DURATION_SECONDS = 2 +private const val SEED_AUDIO_SAMPLE_RATE_HZ = 16_000 +private const val SEED_AUDIO_FREQUENCY_HZ = 440.0 private const val MINUTES = 60 * 1000L private const val HOURS = 60 * MINUTES private const val DAYS = 24 * HOURS +private data class SeedVCards( + val contactUri: String, + val locationUri: String, +) + +@EntryPoint +@InstallIn(SingletonComponent::class) +private interface SeedSubscriptionsEntryPoint { + fun subscriptionsRepository(): SubscriptionsRepository +} + fun seedTestData(context: Context) { + clearSeededTestData(context = context) + val db = DataModel.get().getDatabase() val selfId = findSelfParticipantId(db) ?: run { @@ -43,7 +83,12 @@ fun seedTestData(context: Context) { return } + val (simAId, simBId) = resolveDualSimSelfIds(context = context) + val testImages = buildTestImages(context) + val testAudio = buildTestAudio() + val testVideo = buildTestVideo(context) + val testVCards = buildTestVCards() val now = System.currentTimeMillis() db.withTransaction { @@ -57,6 +102,12 @@ fun seedTestData(context: Context) { val henry = upsertParticipant(db, "${TEST_PHONE_PREFIX}008901", "Henry Hall", "Henry") val iris = upsertParticipant(db, "${TEST_PHONE_PREFIX}009012", "Iris Ingram", "Iris") val jack = upsertParticipant(db, "${TEST_PHONE_PREFIX}010123", "Jack Johnson", "Jack") + val kim = upsertParticipant(db, "${TEST_PHONE_PREFIX}011234", "Kim Kelly", "Kim") + val liam = upsertParticipant(db, "${TEST_PHONE_PREFIX}012345", "Liam Lewis", "Liam") + val mia = upsertParticipant(db, "${TEST_PHONE_PREFIX}013456", "Mia Miller", "Mia") + val noah = upsertParticipant(db, "${TEST_PHONE_PREFIX}014567", "Noah Nguyen", "Noah") + val olivia = upsertParticipant(db, "${TEST_PHONE_PREFIX}015678", "Olivia Ortega", "Olivia") + val nora = upsertParticipant(db, "${TEST_PHONE_PREFIX}016789", "Nora Notifications", "Nora") seedScenarioA(db, selfId, alice, now) seedScenarioB(db, selfId, bob, now) @@ -65,16 +116,68 @@ fun seedTestData(context: Context) { seedScenarioE(db, selfId, grace, now) seedScenarioF(db, selfId, henry, now) seedScenarioG(db, selfId, iris, testImages, now) - seedScenarioH(db, selfId, jack, carol, testImages, now) + seedScenarioH( + db = db, + selfId = selfId, + jackId = jack, + carolId = carol, + images = testImages, + audioUri = testAudio, + videoUri = testVideo, + vCards = testVCards, + now = now, + ) seedScenarioI(db, selfId, carol, dave, eve, now) + seedScenarioJ(db, selfId, kim, testImages, now) + seedScenarioK(db, selfId, liam, mia, noah, testImages, now) + if (simAId != null && simBId != null) { + seedScenarioL( + db = db, + realSelfId = selfId, + simAId = simAId, + simBId = simBId, + oliviaId = olivia, + now = now, + ) + } + seedScenarioM( + db = db, + realSelfId = selfId, + secondarySelfId = simBId ?: selfId, + noraId = nora, + now = now, + ) } MessagingContentProvider.notifyConversationListChanged() LogUtil.d(TAG, "Test data seeded successfully") } +private fun resolveDualSimSelfIds(context: Context): Pair { + DebugSimEmulationStore.setMode(mode = DebugSimEmulationMode.DUAL) + + val repository = EntryPointAccessors + .fromApplication( + context.applicationContext, + SeedSubscriptionsEntryPoint::class.java, + ) + .subscriptionsRepository() + + val subscriptions = runCatching { + runBlocking { + repository.observeActiveSubscriptions().first { it.size >= 2 } + } + }.getOrElse { throwable -> + LogUtil.w(TAG, "Failed to resolve dual SIM subscriptions for seeding", throwable) + return null to null + } + + return subscriptions[0].selfParticipantId to subscriptions[1].selfParticipantId +} + fun clearSeededTestData(context: Context) { val db = DataModel.get().getDatabase() + val seededAttachmentUris = mutableSetOf() db.withTransaction { val participantIds = mutableListOf() @@ -85,7 +188,7 @@ fun clearSeededTestData(context: Context) { arrayOf("$TEST_PHONE_PREFIX%"), null, null, - null + null, )?.use { cursor -> while (cursor.moveToNext()) participantIds.add(cursor.getString(0)) } @@ -106,24 +209,42 @@ fun clearSeededTestData(context: Context) { pArgs, null, null, - null + null, )?.use { cursor -> while (cursor.moveToNext()) conversationIds.add(cursor.getString(0)) } if (conversationIds.isNotEmpty()) { + val conversationIdArgs = conversationIds.toTypedArray() + db.query( + DatabaseHelper.PARTS_TABLE, + arrayOf(PartColumns.CONTENT_URI), + "${PartColumns.CONVERSATION_ID} IN (${conversationIds.joinToString(",") { "?" }})" + + " AND ${PartColumns.CONTENT_URI} IS NOT NULL", + conversationIdArgs, + null, + null, + null, + )?.use { cursor -> + while (cursor.moveToNext()) { + cursor.getString(0)?.let { attachmentUri -> + seededAttachmentUris.add(attachmentUri) + } + } + } + // ON DELETE CASCADE handles messages and parts db.delete( DatabaseHelper.CONVERSATIONS_TABLE, "${ConversationColumns._ID} IN (${conversationIds.joinToString(",") { "?" }})", - conversationIds.toTypedArray() + conversationIdArgs, ) } db.delete( DatabaseHelper.PARTICIPANTS_TABLE, "${ParticipantColumns._ID} IN ($pPlaceholders)", - pArgs + pArgs, ) } @@ -131,6 +252,23 @@ fun clearSeededTestData(context: Context) { for (i in 1..3) { File(context.cacheDir, "seed_img_$i.jpg").delete() } + File(context.cacheDir, "seed_video.mp4").delete() + File(context.cacheDir, "seed_contact.vcf").delete() + deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID, fileExtension = "wav") + deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_CONTACT_VCARD_FILE_ID, fileExtension = "vcf") + deleteSeedScratchFile(fileId = SEED_LOCATION_VCARD_FILE_ID, fileExtension = "vcf") + deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID, fileExtension = "mp4") + deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID) + deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID) + deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID) + deleteSeedScratchFile(fileId = SEED_CONTACT_VCARD_FILE_ID) + deleteSeedScratchFile(fileId = SEED_LOCATION_VCARD_FILE_ID) + deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID) + deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID) + deleteSeededAttachmentScratchFiles(attachmentUris = seededAttachmentUris) MessagingContentProvider.notifyConversationListChanged() LogUtil.d(TAG, "Seeded test data cleared") @@ -138,31 +276,223 @@ fun clearSeededTestData(context: Context) { private fun buildTestImages(context: Context): List { val specs = listOf( - Triple("seed_img_1.jpg", Color.rgb(100, 149, 237), "Photo 1"), - Triple("seed_img_2.jpg", Color.rgb(144, 238, 144), "Photo 2"), - Triple("seed_img_3.jpg", Color.rgb(255, 160, 122), "Photo 3") + SeedImageSpec( + fileId = SEED_IMAGE_1_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(100, 149, 237), + label = "Photo 1", + ), + SeedImageSpec( + fileId = SEED_IMAGE_2_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(144, 238, 144), + label = "Photo 2", + ), + SeedImageSpec( + fileId = SEED_IMAGE_3_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(255, 160, 122), + label = "Photo 3", + ), ) - return specs.map { (filename, bgColor, label) -> - val file = File(context.cacheDir, filename) - if (!file.exists()) { - val bmp = createBitmap(400, 300) - val canvas = Canvas(bmp) - canvas.drawColor(bgColor) - val paint = Paint().apply { - color = Color.WHITE - textSize = 48f - isAntiAlias = true - isFakeBoldText = true - } - canvas.drawText(label, 130f, 165f, paint) - FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) } - bmp.recycle() + return specs.map { spec -> + val imageUri = buildSeedScratchUri( + fileId = spec.fileId, + fileExtension = spec.fileExtension, + ) + val file = MediaScratchFileProvider.getFileFromUri(imageUri) + val bmp = createBitmap(400, 300) + val canvas = Canvas(bmp) + canvas.drawColor(spec.backgroundColor) + val paint = Paint().apply { + color = Color.WHITE + textSize = 48f + isAntiAlias = true + isFakeBoldText = true + } + file.parentFile?.mkdirs() + canvas.drawText(spec.label, 130f, 165f, paint) + FileOutputStream(file).use { outputStream -> + bmp.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + } + bmp.recycle() + imageUri.toString() + } +} + +private fun buildTestVCards(): SeedVCards { + val contactVCardUri = buildSeedScratchUri( + fileId = SEED_CONTACT_VCARD_FILE_ID, + fileExtension = "vcf", + ) + val contactFile = MediaScratchFileProvider.getFileFromUri(contactVCardUri) + contactFile.parentFile?.mkdirs() + contactFile.writeText( + """ + BEGIN:VCARD + VERSION:3.0 + FN:Sam Rivera + N:Rivera;Sam;;; + TEL;TYPE=CELL:+15550001111 + EMAIL:sam.rivera@example.com + END:VCARD + """.trimIndent(), + ) + MediaScratchFileProvider.addUriToDisplayNameEntry(contactVCardUri, "Sam Rivera") + + val locationVCardUri = buildSeedScratchUri( + fileId = SEED_LOCATION_VCARD_FILE_ID, + fileExtension = "vcf", + ) + val locationFile = MediaScratchFileProvider.getFileFromUri(locationVCardUri) + locationFile.parentFile?.mkdirs() + locationFile.writeText( + """ + BEGIN:VCARD + VERSION:3.0 + KIND:location + FN:Pier 57 + ADR;TYPE=WORK:;;25 11th Ave;New York;NY;10011;United States + NOTE:Meet by the market entrance + END:VCARD + """.trimIndent(), + ) + MediaScratchFileProvider.addUriToDisplayNameEntry(locationVCardUri, "Pier 57") + + return SeedVCards( + contactUri = contactVCardUri.toString(), + locationUri = locationVCardUri.toString(), + ) +} + +private fun buildTestAudio(): String { + val audioUri = buildSeedScratchUri( + fileId = SEED_AUDIO_FILE_ID, + fileExtension = "wav", + ) + val file = MediaScratchFileProvider.getFileFromUri(audioUri) + file.parentFile?.mkdirs() + + BufferedOutputStream(FileOutputStream(file)).use { outputStream -> + writeSeedWaveFile(outputStream = outputStream) + } + + MediaScratchFileProvider.addUriToDisplayNameEntry(audioUri, "seed_audio.wav") + return audioUri.toString() +} + +private fun buildTestVideo(context: Context): String { + val videoUri = buildSeedScratchUri( + fileId = SEED_VIDEO_FILE_ID, + fileExtension = "mp4", + ) + val file = MediaScratchFileProvider.getFileFromUri(videoUri) + file.parentFile?.mkdirs() + context.assets.open("seed_video.mp4").use { inputStream -> + FileOutputStream(file).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + MediaScratchFileProvider.addUriToDisplayNameEntry(videoUri, "seed_video.mp4") + return videoUri.toString() +} + +private fun buildSeedScratchUri( + fileId: String, + fileExtension: String? = null, +): Uri { + val uriBuilder = MediaScratchFileProvider.getUriBuilder() + .appendPath(fileId) + if (!fileExtension.isNullOrBlank()) { + uriBuilder.appendQueryParameter( + MEDIA_SCRATCH_FILE_EXTENSION_QUERY_PARAMETER, + fileExtension, + ) + } + return uriBuilder.build() +} + +private fun deleteSeedScratchFile( + fileId: String, + fileExtension: String? = null, +) { + val seedScratchUri = buildSeedScratchUri( + fileId = fileId, + fileExtension = fileExtension, + ) + MediaScratchFileProvider.getFileFromUri(seedScratchUri).delete() +} + +private fun deleteSeededAttachmentScratchFiles( + attachmentUris: Set, +) { + attachmentUris.forEach { attachmentUri -> + val uri = attachmentUri.toUri() + if (!MediaScratchFileProvider.isMediaScratchSpaceUri(uri)) { + return@forEach + } + + MediaScratchFileProvider.getFileFromUri(uri).delete() + } +} + +private fun writeSeedWaveFile( + outputStream: BufferedOutputStream, +) { + val pcmBytes = buildSeedAudioPcmData() + val channels = 1 + val bitsPerSample = 16 + val byteRate = SEED_AUDIO_SAMPLE_RATE_HZ * channels * bitsPerSample / 8 + val blockAlign = channels * bitsPerSample / 8 + val dataSize = pcmBytes.size + val riffChunkSize = 36 + dataSize + + DataOutputStream(outputStream).use { dataOutputStream -> + dataOutputStream.writeBytes("RIFF") + dataOutputStream.writeInt(Integer.reverseBytes(riffChunkSize)) + dataOutputStream.writeBytes("WAVE") + dataOutputStream.writeBytes("fmt ") + dataOutputStream.writeInt(Integer.reverseBytes(16)) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(1.toShort()).toInt()) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(channels.toShort()).toInt()) + dataOutputStream.writeInt(Integer.reverseBytes(SEED_AUDIO_SAMPLE_RATE_HZ)) + dataOutputStream.writeInt(Integer.reverseBytes(byteRate)) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(blockAlign.toShort()).toInt()) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(bitsPerSample.toShort()).toInt()) + dataOutputStream.writeBytes("data") + dataOutputStream.writeInt(Integer.reverseBytes(dataSize)) + dataOutputStream.write(pcmBytes) + } +} + +private fun buildSeedAudioPcmData(): ByteArray { + val totalSamples = SEED_AUDIO_SAMPLE_RATE_HZ * SEED_AUDIO_DURATION_SECONDS + val byteArrayOutputStream = ByteArrayOutputStream(totalSamples * 2) + val sampleAmplitude = Short.MAX_VALUE * 0.35 + + DataOutputStream(byteArrayOutputStream).use { dataOutputStream -> + repeat(totalSamples) { sampleIndex -> + val timeSeconds = sampleIndex.toDouble() / SEED_AUDIO_SAMPLE_RATE_HZ.toDouble() + val sampleValue = ( + sin(2.0 * PI * SEED_AUDIO_FREQUENCY_HZ * timeSeconds) * sampleAmplitude + ).toInt() + .toShort() + dataOutputStream.writeShort(java.lang.Short.reverseBytes(sampleValue).toInt()) } - Uri.fromFile(file).toString() } + + return byteArrayOutputStream.toByteArray() } +private data class SeedImageSpec( + val fileId: String, + val fileExtension: String, + val backgroundColor: Int, + val label: String, +) + private fun findSelfParticipantId(db: DatabaseWrapper): String? = db.query( DatabaseHelper.PARTICIPANTS_TABLE, arrayOf(ParticipantColumns._ID), @@ -171,7 +501,7 @@ private fun findSelfParticipantId(db: DatabaseWrapper): String? = db.query( null, null, "${ParticipantColumns._ID} ASC", - "1" + "1", )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } @@ -193,7 +523,7 @@ private fun upsertParticipant( put(ParticipantColumns.FULL_NAME, fullName) put(ParticipantColumns.FIRST_NAME, firstName) }, - SQLiteDatabase.CONFLICT_IGNORE + SQLiteDatabase.CONFLICT_IGNORE, ) return db @@ -204,7 +534,7 @@ private fun upsertParticipant( arrayOf(phone, ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), null, null, - null + null, ) ?.use { cursor -> cursor.moveToFirst() @@ -239,7 +569,7 @@ private fun createConversation( ) { put(ConversationColumns.PREVIEW_CONTENT_TYPE, previewContentType) } - } + }, ) for (participantId in participantIds) { db.insert( @@ -248,7 +578,7 @@ private fun createConversation( ContentValues().apply { put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId) put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId) - } + }, ) } return conversationId @@ -269,7 +599,7 @@ private fun insertTextMessage( ): Long { val messageId = insertMessageRow( db, conversationId, senderId, selfId, - status, protocol, timestamp, seen, read, mmsSubject + status, protocol, timestamp, seen, read, mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -279,7 +609,7 @@ private fun insertTextMessage( put(PartColumns.CONVERSATION_ID, conversationId) put(PartColumns.TEXT, text) put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) - } + }, ) return messageId } @@ -294,24 +624,23 @@ private fun insertImageMessage( timestamp: Long, seen: Boolean = true, read: Boolean = true, + mmsSubject: String? = null, ): Long { - val messageId = insertMessageRow( - db, conversationId, senderId, selfId, - status, MessageData.PROTOCOL_MMS, timestamp, seen, read, mmsSubject = null - ) - db.insert( - DatabaseHelper.PARTS_TABLE, - null, - ContentValues().apply { - put(PartColumns.MESSAGE_ID, messageId) - put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.CONTENT_TYPE, ContentType.IMAGE_JPEG) - put(PartColumns.CONTENT_URI, imageUri) - put(PartColumns.WIDTH, 400) - put(PartColumns.HEIGHT, 300) - } + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.IMAGE_JPEG, + attachmentUri = imageUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, + mmsSubject = mmsSubject, ) - return messageId } private fun insertMixedMessage( @@ -325,10 +654,22 @@ private fun insertMixedMessage( timestamp: Long, seen: Boolean = true, read: Boolean = true, + mmsSubject: String? = null, ): Long { - val messageId = insertMessageRow( - db, conversationId, senderId, selfId, - status, MessageData.PROTOCOL_MMS, timestamp, seen, read, mmsSubject = null + val messageId = insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.IMAGE_JPEG, + attachmentUri = imageUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, + mmsSubject = mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -336,11 +677,39 @@ private fun insertMixedMessage( ContentValues().apply { put(PartColumns.MESSAGE_ID, messageId) put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.CONTENT_TYPE, ContentType.IMAGE_JPEG) - put(PartColumns.CONTENT_URI, imageUri) - put(PartColumns.WIDTH, 400) - put(PartColumns.HEIGHT, 300) - } + put(PartColumns.TEXT, text) + put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) + }, + ) + return messageId +} + +private fun insertAttachmentMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + contentType: String, + attachmentUri: String, + status: Int, + timestamp: Long, + width: Int = 0, + height: Int = 0, + seen: Boolean = true, + read: Boolean = true, + mmsSubject: String? = null, +): Long { + val messageId = insertMessageRow( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = timestamp, + seen = seen, + read = read, + mmsSubject = mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -348,13 +717,92 @@ private fun insertMixedMessage( ContentValues().apply { put(PartColumns.MESSAGE_ID, messageId) put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.TEXT, text) - put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) - } + put(PartColumns.CONTENT_TYPE, contentType) + put(PartColumns.CONTENT_URI, attachmentUri) + put(PartColumns.WIDTH, width) + put(PartColumns.HEIGHT, height) + }, ) return messageId } +private fun insertVCardMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + vCardUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.TEXT_VCARD, + attachmentUri = vCardUri, + status = status, + timestamp = timestamp, + seen = seen, + read = read, + ) +} + +private fun insertVideoMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + videoUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.VIDEO_MP4, + attachmentUri = videoUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, + ) +} + +private fun insertAudioMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + audioUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.AUDIO_X_WAV, + attachmentUri = audioUri, + status = status, + timestamp = timestamp, + seen = seen, + read = read, + ) +} + private fun insertMessageRow( db: DatabaseWrapper, conversationId: Long, @@ -380,9 +828,58 @@ private fun insertMessageRow( put(MessageColumns.SEEN, if (seen) 1 else 0) put(MessageColumns.READ, if (read) 1 else 0) if (mmsSubject != null) put(MessageColumns.MMS_SUBJECT, mmsSubject) - } + }, ) +private fun insertMmsDownloadMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + status: Int, + timestamp: Long, + messageSizeBytes: Long, + expiryTimestamp: Long, + seedIndex: Int, +): Long { + val messageId = db.insert( + DatabaseHelper.MESSAGES_TABLE, + null, + ContentValues().apply { + put(MessageColumns.CONVERSATION_ID, conversationId) + put(MessageColumns.SENDER_PARTICIPANT_ID, senderId) + put(MessageColumns.SELF_PARTICIPANT_ID, selfId) + put(MessageColumns.STATUS, status) + put(MessageColumns.PROTOCOL, MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION) + put(MessageColumns.SENT_TIMESTAMP, timestamp) + put(MessageColumns.RECEIVED_TIMESTAMP, timestamp) + put(MessageColumns.SEEN, 1) + put(MessageColumns.READ, 1) + put(MessageColumns.SMS_MESSAGE_URI, "content://mms/${900_000 + seedIndex}") + put(MessageColumns.SMS_PRIORITY, 0) + put(MessageColumns.SMS_MESSAGE_SIZE, messageSizeBytes) + put(MessageColumns.MMS_TRANSACTION_ID, "seeded-transaction-$seedIndex") + put(MessageColumns.MMS_CONTENT_LOCATION, "https://example.invalid/mms/$seedIndex") + put(MessageColumns.MMS_EXPIRY, expiryTimestamp) + put(MessageColumns.RAW_TELEPHONY_STATUS, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED) + put(MessageColumns.RETRY_START_TIMESTAMP, timestamp) + }, + ) + + db.insert( + DatabaseHelper.PARTS_TABLE, + null, + ContentValues().apply { + put(PartColumns.MESSAGE_ID, messageId) + put(PartColumns.CONVERSATION_ID, conversationId) + put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) + put(PartColumns.TEXT, "") + }, + ) + + return messageId +} + private fun finalizeConversation( db: DatabaseWrapper, conversationId: Long, @@ -406,7 +903,7 @@ private fun finalizeConversation( } }, "${ConversationColumns._ID} = ?", - arrayOf(conversationId.toString()) + arrayOf(conversationId.toString()), ) } @@ -437,7 +934,7 @@ private fun seedScenarioA(db: DatabaseWrapper, selfId: String, aliceId: String, "Will do", "See you Saturday!", "Looking forward to it", - "Don't forget to bring the book" + "Don't forget to bring the book", ) var latestMsgId = 0L @@ -462,7 +959,7 @@ private fun seedScenarioA(db: DatabaseWrapper, selfId: String, aliceId: String, texts[i % texts.size], status, MessageData.PROTOCOL_SMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -484,7 +981,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no Triple( "Can you send the updated version?", false, - MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, ), Triple("Sure, give me a minute", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), Triple("Here you go", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), @@ -497,7 +994,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no Triple("Great, see you then", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), Triple("Perfect", false, MessageData.BUGLE_STATUS_OUTGOING_COMPLETE), Triple("Don't be late!", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), - Triple("Never", false, MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY) + Triple("Never", false, MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY), ) var latestMsgId = 0L @@ -514,7 +1011,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no text, status, MessageData.PROTOCOL_SMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -539,7 +1036,7 @@ private fun seedScenarioC( "Team Chat", selfId, listOf(carolId, daveId, eveId), - baseTime + baseTime, ) val senders = listOf(carolId, daveId, eveId, selfId) @@ -551,7 +1048,7 @@ private fun seedScenarioC( "Agreed", "Anyone need a ride?", "I'm good, thanks", "I could use one actually", "I got you Carol", "Thanks Dave!", "Ok see everyone at 3", "See you there!", "Don't forget it's at the usual place", "Got it", "See you all soon!", - "This is going to be fun", "Definitely", "On my way!" + "This is going to be fun", "Definitely", "On my way!", ) var latestMsgId = 0L @@ -572,7 +1069,7 @@ private fun seedScenarioC( texts[i], status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -597,7 +1094,7 @@ private fun seedScenarioD(db: DatabaseWrapper, selfId: String, frankId: String, Pair("Bring sunscreen, it'll be hot", false), Pair("Good call", true), Pair("See you Saturday!", false), - Pair("Can't wait!", true) + Pair("Can't wait!", true), ) var latestMsgId = 0L @@ -613,7 +1110,7 @@ private fun seedScenarioD(db: DatabaseWrapper, selfId: String, frankId: String, } latestMsgId = insertTextMessage( db, convId, senderId, selfId, - text, status, MessageData.PROTOCOL_MMS, msgTime, mmsSubject = "Weekend plans" + text, status, MessageData.PROTOCOL_MMS, msgTime, mmsSubject = "Weekend plans", ) latestTime = msgTime } @@ -646,7 +1143,7 @@ private fun seedScenarioE(db: DatabaseWrapper, selfId: String, graceId: String, latestMsgId = insertTextMessage( db, convId, senderId, selfId, latestText, status, MessageData.PROTOCOL_SMS, msgTime, - seen = !isUnread, read = !isUnread + seen = !isUnread, read = !isUnread, ) } @@ -655,7 +1152,7 @@ private fun seedScenarioE(db: DatabaseWrapper, selfId: String, graceId: String, convId, latestMsgId, baseTime + totalMessages * 2 * MINUTES, - latestText + latestText, ) } @@ -671,7 +1168,8 @@ private fun seedScenarioF(db: DatabaseWrapper, selfId: String, henryId: String, "I need to show you something", "It's important", "Please reply when you get a chance", - "I'll be online for the next hour" + "I'll be online for the next hour", + TEST_LINK_MESSAGE_URL, ) var latestMsgId = 0L @@ -681,7 +1179,7 @@ private fun seedScenarioF(db: DatabaseWrapper, selfId: String, henryId: String, latestMsgId = insertTextMessage( db, convId, henryId, selfId, text, MessageData.BUGLE_STATUS_INCOMING_COMPLETE, MessageData.PROTOCOL_SMS, msgTime, - seen = false, read = false + seen = false, read = false, ) latestTime = msgTime } @@ -711,7 +1209,7 @@ private fun seedScenarioG( listOf(irisId), baseTime, previewUri = img1, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) data class Msg( @@ -731,13 +1229,14 @@ private fun seedScenarioG( "mixed", text = "Here's another one from the same day", imageUri = img2, - isIncoming = true + isIncoming = true, ), Msg("text", text = "These are stunning", isIncoming = false), Msg("image", imageUri = img3, isIncoming = false), Msg("text", text = "I took that one on the way home", isIncoming = false), Msg("text", text = "You have such a good eye for photos!", isIncoming = true), Msg("text", text = "Thanks! We should go together sometime", isIncoming = false), + Msg("text", text = TEST_LINK_MESSAGE_URL, isIncoming = false), Msg("text", text = "Definitely, let me know when you're free", isIncoming = true), Msg("image", imageUri = img2, isIncoming = true), Msg("text", text = "And one more from yesterday", isIncoming = true), @@ -745,8 +1244,8 @@ private fun seedScenarioG( "mixed", text = "Shot this from my window this morning", imageUri = img1, - isIncoming = false - ) + isIncoming = false, + ), ) var latestMsgId = 0L @@ -767,7 +1266,7 @@ private fun seedScenarioG( selfId, m.imageUri, status, - msgTime + msgTime, ) "mixed" -> insertMixedMessage( @@ -778,7 +1277,7 @@ private fun seedScenarioG( m.text, m.imageUri, status, - msgTime + msgTime, ) else -> insertTextMessage( @@ -789,7 +1288,7 @@ private fun seedScenarioG( m.text, status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) } latestTime = msgTime @@ -802,7 +1301,7 @@ private fun seedScenarioG( latestTime, "Shot this from my window this morning", previewUri = img1, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) } @@ -815,6 +1314,9 @@ private fun seedScenarioH( jackId: String, carolId: String, images: List, + audioUri: String, + videoUri: String, + vCards: SeedVCards, now: Long, ) { val img1 = images[0] @@ -829,32 +1331,46 @@ private fun seedScenarioH( listOf(jackId, carolId), baseTime, previewUri = img2, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) data class Msg( val type: String, val text: String = "", - val imageUri: String = "", + val attachmentUri: String = "", val senderId: String, ) val messages = listOf( Msg("text", text = "Dropping some pics from last night", senderId = jackId), - Msg("image", imageUri = img1, senderId = jackId), - Msg("image", imageUri = img3, senderId = jackId), + Msg("image", attachmentUri = img1, senderId = jackId), + Msg("image", attachmentUri = img3, senderId = jackId), Msg("text", text = "The lighting was perfect", senderId = jackId), Msg("text", text = "These are great Jack!", senderId = carolId), - Msg("image", imageUri = img2, senderId = carolId), + Msg("image", attachmentUri = img2, senderId = carolId), Msg("text", text = "I got a few too", senderId = carolId), Msg("text", text = "Love that shot Carol", senderId = selfId), - Msg("mixed", text = "Here's mine from the same spot", imageUri = img3, senderId = selfId), + Msg( + "mixed", + text = "Here's mine from the same spot", + attachmentUri = img3, + senderId = selfId, + ), Msg("text", text = "We all had the same idea haha", senderId = jackId), - Msg("image", imageUri = img1, senderId = carolId), + Msg("image", attachmentUri = img1, senderId = carolId), + Msg("text", text = TEST_YOUTUBE_VIDEO_URL, senderId = carolId), + Msg("text", text = "The clip version is even better", senderId = jackId), + Msg("video", attachmentUri = videoUri, senderId = carolId), + Msg("text", text = "And here's the ambient audio from the room", senderId = jackId), + Msg("audio", attachmentUri = audioUri, senderId = jackId), + Msg("text", text = "Send me the photographer contact too", senderId = selfId), + Msg("vcard", attachmentUri = vCards.contactUri, senderId = carolId), Msg("text", text = "One more", senderId = carolId), + Msg("text", text = "Pin the meetup spot too", senderId = selfId), + Msg("vcard", attachmentUri = vCards.locationUri, senderId = jackId), Msg("text", text = "We need to do this again soon", senderId = selfId), Msg("text", text = "+1", senderId = jackId), - Msg("text", text = "Same time next week?", senderId = carolId) + Msg("text", text = "Same time next week?", senderId = carolId), ) var latestMsgId = 0L @@ -872,9 +1388,9 @@ private fun seedScenarioH( convId, m.senderId, selfId, - m.imageUri, + m.attachmentUri, status, - msgTime + msgTime, ) "mixed" -> insertMixedMessage( @@ -883,9 +1399,39 @@ private fun seedScenarioH( m.senderId, selfId, m.text, - m.imageUri, + m.attachmentUri, status, - msgTime + msgTime, + ) + + "vcard" -> insertVCardMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + vCardUri = m.attachmentUri, + status = status, + timestamp = msgTime, + ) + + "video" -> insertVideoMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + videoUri = m.attachmentUri, + status = status, + timestamp = msgTime, + ) + + "audio" -> insertAudioMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + audioUri = m.attachmentUri, + status = status, + timestamp = msgTime, ) else -> insertTextMessage( @@ -896,7 +1442,7 @@ private fun seedScenarioH( m.text, status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) } latestTime = msgTime @@ -909,7 +1455,7 @@ private fun seedScenarioH( latestTime, "Same time next week?", previewUri = img2, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) } @@ -930,92 +1476,96 @@ private fun seedScenarioI( name = "Clustering Test Cases", selfId = selfId, participantIds = listOf(carolId, daveId, eveId), - sortTimestamp = baseTime + sortTimestamp = baseTime, ) - data class ClusterTestMessage(val text: String, val senderId: String, val offsetMillis: Long) + data class ClusterTestMessage( + val text: String, + val senderId: String, + val offsetMillis: Long, + ) val messages = listOf( ClusterTestMessage( text = "Standalone incoming", senderId = carolId, - offsetMillis = 0L + offsetMillis = 0L, ), ClusterTestMessage( text = "Pair top", senderId = carolId, - offsetMillis = 2 * MINUTES + offsetMillis = 2 * MINUTES, ), ClusterTestMessage( text = "Pair bottom", senderId = carolId, - offsetMillis = 2 * MINUTES + 30_000L + offsetMillis = 2 * MINUTES + 30_000L, ), ClusterTestMessage( text = "Triplet top", senderId = daveId, - offsetMillis = 5 * MINUTES + offsetMillis = 5 * MINUTES, ), ClusterTestMessage( text = "Triplet middle", senderId = daveId, - offsetMillis = 5 * MINUTES + 20_000L + offsetMillis = 5 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Triplet bottom", senderId = daveId, - offsetMillis = 5 * MINUTES + 40_000L + offsetMillis = 5 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Quartet top", senderId = eveId, - offsetMillis = 8 * MINUTES + offsetMillis = 8 * MINUTES, ), ClusterTestMessage( text = "Quartet middle 1", senderId = eveId, - offsetMillis = 8 * MINUTES + 20_000L + offsetMillis = 8 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Quartet middle 2", senderId = eveId, - offsetMillis = 8 * MINUTES + 40_000L + offsetMillis = 8 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Quartet bottom", senderId = eveId, - offsetMillis = 9 * MINUTES + offsetMillis = 9 * MINUTES, ), ClusterTestMessage( text = "Same sender after gap", senderId = daveId, - offsetMillis = 12 * MINUTES + offsetMillis = 12 * MINUTES, ), ClusterTestMessage( text = "Gap break still standalone", senderId = daveId, - offsetMillis = 13 * MINUTES + 40_000L + offsetMillis = 13 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Different sender break", senderId = carolId, - offsetMillis = 16 * MINUTES + offsetMillis = 16 * MINUTES, ), ClusterTestMessage( text = "Outgoing standalone", senderId = selfId, - offsetMillis = 16 * MINUTES + 20_000L + offsetMillis = 16 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Outgoing pair top", senderId = selfId, - offsetMillis = 19 * MINUTES + offsetMillis = 19 * MINUTES, ), ClusterTestMessage( text = "Outgoing pair bottom", senderId = selfId, - offsetMillis = 19 * MINUTES + 20_000L - ) + offsetMillis = 19 * MINUTES + 20_000L, + ), ) var latestMessageId = 0L @@ -1039,7 +1589,7 @@ private fun seedScenarioI( text = message.text, status = status, protocol = MessageData.PROTOCOL_MMS, - timestamp = timestamp + timestamp = timestamp, ) latestTimestamp = timestamp } @@ -1049,6 +1599,393 @@ private fun seedScenarioI( conversationId = conversationId, latestMessageId = latestMessageId, latestTimestamp = latestTimestamp, - snippetText = latestText + snippetText = latestText, + ) +} + +/** + * 1:1 MMS thread with Kim covering subject across direction and attachment variants. + * + * Sender row never shows in 1:1 chats, so this scenario isolates direction × content type: + * outgoing text, incoming text, incoming image-only, outgoing image+body — each with subject. + */ +private fun seedScenarioJ( + db: DatabaseWrapper, + selfId: String, + kimId: String, + images: List, + now: Long, +) { + val baseTime = now - 4 * HOURS + val convId = createConversation(db, "Kim Kelly", selfId, listOf(kimId), baseTime) + val img = images[0] + + data class SubjectMsg( + val type: String, + val text: String = "", + val imageUri: String = "", + val isIncoming: Boolean, + val subject: String, + ) + + val messages = listOf( + SubjectMsg( + type = "text", + text = "Did you check the report?", + isIncoming = false, + subject = "Q1 review", + ), + SubjectMsg( + type = "text", + text = "Yes, looks great. A couple of comments on slide 4.", + isIncoming = true, + subject = "Q1 review", + ), + SubjectMsg( + type = "image", + imageUri = img, + isIncoming = true, + subject = "Updated chart", + ), + SubjectMsg( + type = "mixed", + text = "Much clearer now, thanks!", + imageUri = img, + isIncoming = false, + subject = "Updated chart", + ), + ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for ((idx, m) in messages.withIndex()) { + val msgTime = baseTime + idx * 6 * MINUTES + val senderId = if (m.isIncoming) kimId else selfId + val status = if (m.isIncoming) { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } else { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } + latestText = m.text.ifBlank { m.subject } + latestMsgId = when (m.type) { + "image" -> insertImageMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + "mixed" -> insertMixedMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + text = m.text, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + else -> insertTextMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + text = m.text, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = msgTime, + mmsSubject = m.subject, + ) + } + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) +} + +/** + * 1:1 SMS thread with Olivia exercising every branch of the SIM annotation rule. + * + * The annotation appears on the LAST message of each contiguous SIM run. The sequence below + * walks: a 3-message SIM-A burst (annotation only on the 3rd), a single SIM-B run, a 2-message + * SIM-A burst, a 2-message incoming SIM-B burst (direction change ends the run), and a final + * outgoing SIM-A message (last in conversation). + * + * Outgoing rows keep [realSelfId] as their sender so the participants table FK remains valid; + * only [MessageColumns.SELF_PARTICIPANT_ID] varies between [simAId] and [simBId]. + */ +private fun seedScenarioL( + db: DatabaseWrapper, + realSelfId: String, + simAId: String, + simBId: String, + oliviaId: String, + now: Long, +) { + val baseTime = now - 90 * MINUTES + val convId = createConversation( + db = db, + name = "Olivia Ortega", + selfId = simAId, + participantIds = listOf(oliviaId), + sortTimestamp = baseTime, + ) + + data class SimMixMessage( + val text: String, + val isIncoming: Boolean, + val simSelfId: String, + val offsetMillis: Long, + ) + + val messages = listOf( + SimMixMessage("Heads up — switching SIMs today", false, simAId, 0L), + SimMixMessage("Two more on this number", false, simAId, 30_000L), + SimMixMessage("Third one wraps the SIM 1 burst", false, simAId, 60_000L), + SimMixMessage("Now sending from SIM 2 just once", false, simBId, 6 * MINUTES), + SimMixMessage("Back to SIM 1", false, simAId, 12 * MINUTES), + SimMixMessage("Still on SIM 1", false, simAId, 12 * MINUTES + 30_000L), + SimMixMessage("Got it — replying on SIM 2", true, simBId, 18 * MINUTES), + SimMixMessage("And one more reply", true, simBId, 18 * MINUTES + 30_000L), + SimMixMessage("Last message — back on SIM 1", false, simAId, 24 * MINUTES), ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for (message in messages) { + val msgTime = baseTime + message.offsetMillis + val senderId = if (message.isIncoming) oliviaId else realSelfId + val status = if (message.isIncoming) { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } else { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } + latestText = message.text + latestMsgId = insertTextMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = message.simSelfId, + text = message.text, + status = status, + protocol = MessageData.PROTOCOL_SMS, + timestamp = msgTime, + ) + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) +} + +/** + * 1:1 MMS notification thread covering manual download rendering states. + * + * The last two rows intentionally mirror the regression case: the upper placeholder remains + * actionable while only the lower placeholder is in a downloading state. + */ +private fun seedScenarioM( + db: DatabaseWrapper, + realSelfId: String, + secondarySelfId: String, + noraId: String, + now: Long, +) { + val baseTime = now - 40 * MINUTES + val conversationId = createConversation( + db = db, + name = "MMS Download States", + selfId = realSelfId, + participantIds = listOf(noraId), + sortTimestamp = baseTime, + ) + + data class MmsDownloadSeedMessage( + val status: Int, + val selfId: String, + val offsetMillis: Long, + val sizeBytes: Long, + val expiryTimestamp: Long, + val snippet: String, + ) + + val messages = listOf( + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, + selfId = realSelfId, + offsetMillis = 0L, + sizeBytes = 4_096L, + expiryTimestamp = now + 2 * DAYS, + snippet = "Couldn't download MMS", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE, + selfId = realSelfId, + offsetMillis = 8 * MINUTES, + sizeBytes = 9_216L, + expiryTimestamp = now - 1 * DAYS, + snippet = "MMS expired", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, + selfId = realSelfId, + offsetMillis = 16 * MINUTES, + sizeBytes = 1_548L, + expiryTimestamp = now + 3 * DAYS, + snippet = "Tap to download MMS", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING, + selfId = secondarySelfId, + offsetMillis = 17 * MINUTES, + sizeBytes = 1_548L, + expiryTimestamp = now + 3 * DAYS, + snippet = "Downloading MMS", + ), + ) + + var latestMessageId = 0L + var latestTime = baseTime + var latestSnippet = "" + for ((index, message) in messages.withIndex()) { + val messageTime = baseTime + message.offsetMillis + latestMessageId = insertMmsDownloadMessage( + db = db, + conversationId = conversationId, + senderId = noraId, + selfId = message.selfId, + status = message.status, + timestamp = messageTime, + messageSizeBytes = message.sizeBytes, + expiryTimestamp = message.expiryTimestamp, + seedIndex = index + 1, + ) + latestTime = messageTime + latestSnippet = message.snippet + } + + finalizeConversation( + db = db, + conversationId = conversationId, + latestMessageId = latestMessageId, + latestTimestamp = latestTime, + snippetText = latestSnippet, + ) +} + +/** + * Group MMS thread covering subject combined with sender-display variations. + * + * Sender label is shown only on the first message of an incoming cluster, so this scenario + * mixes a same-sender cluster (label shown then hidden), a sender change (label shown again), + * an outgoing message (no label), and an attachment-with-subject from a fresh sender. + */ +private fun seedScenarioK( + db: DatabaseWrapper, + selfId: String, + liamId: String, + miaId: String, + noahId: String, + images: List, + now: Long, +) { + val baseTime = now - 2 * HOURS + val convId = createConversation( + db, + "Subject Group", + selfId, + listOf(liamId, miaId, noahId), + baseTime, + ) + val img = images[1] + + data class GroupSubjectMsg( + val type: String, + val text: String = "", + val imageUri: String = "", + val senderId: String, + val subject: String, + ) + + val messages = listOf( + GroupSubjectMsg( + type = "text", + text = "Anyone free to review?", + senderId = liamId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "I just added the new mock to the doc", + senderId = liamId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "Looks good — what about the dark mode?", + senderId = miaId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "I'll send screenshots in a minute", + senderId = selfId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "mixed", + text = "Here's the dark version", + imageUri = img, + senderId = noahId, + subject = "Design review", + ), + ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for ((idx, m) in messages.withIndex()) { + val msgTime = baseTime + idx * 6 * MINUTES + val status = if (m.senderId == selfId) { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } else { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } + latestText = m.text + latestMsgId = when (m.type) { + "mixed" -> insertMixedMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + text = m.text, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + else -> insertTextMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + text = m.text, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = msgTime, + mmsSubject = m.subject, + ) + } + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) } diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt new file mode 100644 index 00000000..a23f8d2d --- /dev/null +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -0,0 +1,257 @@ +package com.android.messaging.di.conversation + +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.repository.ContactsRepository +import com.android.messaging.data.contact.repository.ContactsRepositoryImpl +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapperImpl +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapper +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapperImpl +import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.data.conversation.store.ConversationDraftStore +import com.android.messaging.data.conversation.store.ConversationDraftStoreImpl +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.SubscriptionsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepositoryImpl +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirementsImpl +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequestImpl +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocolImpl +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraftImpl +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessageImpl +import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatter +import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatterImpl +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipantsImpl +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceededImpl +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationIdImpl +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapableImpl +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumberImpl +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapperImpl +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapper +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapperImpl +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapperImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ConversationBindsModule { + + @Binds + @Reusable + abstract fun bindConversationDraftMessageDataMapper( + impl: ConversationDraftMessageDataMapperImpl, + ): ConversationDraftMessageDataMapper + + @Binds + @Reusable + abstract fun bindConversationMessageDataDraftMapper( + impl: ConversationMessageDataDraftMapperImpl, + ): ConversationMessageDataDraftMapper + + @Binds + @Reusable + abstract fun bindConversationDraftStore( + impl: ConversationDraftStoreImpl, + ): ConversationDraftStore + + @Binds + @Reusable + abstract fun bindConversationDraftsRepository( + impl: ConversationDraftsRepositoryImpl, + ): ConversationDraftsRepository + + @Binds + @Reusable + abstract fun bindConversationParticipantsRepository( + impl: ConversationParticipantsRepositoryImpl, + ): ConversationParticipantsRepository + + @Binds + @Reusable + abstract fun bindConversationContactsRepository( + impl: ContactsRepositoryImpl, + ): ContactsRepository + + @Binds + @Reusable + abstract fun bindCanAddMoreConversationParticipants( + impl: CanAddMoreConversationParticipantsImpl, + ): CanAddMoreConversationParticipants + + @Binds + @Reusable + abstract fun bindContactDestinationFormatter( + impl: ContactDestinationFormatterImpl, + ): ContactDestinationFormatter + + @Binds + @Reusable + abstract fun bindCheckConversationActionRequirements( + impl: CheckConversationActionRequirementsImpl, + ): CheckConversationActionRequirements + + @Binds + @Reusable + abstract fun bindCreateDefaultSmsRoleRequest( + impl: CreateDefaultSmsRoleRequestImpl, + ): CreateDefaultSmsRoleRequest + + @Binds + @Reusable + abstract fun bindIsDeviceVoiceCapable( + impl: IsDeviceVoiceCapableImpl, + ): IsDeviceVoiceCapable + + @Binds + @Reusable + abstract fun bindIsEmergencyPhoneNumber( + impl: IsEmergencyPhoneNumberImpl, + ): IsEmergencyPhoneNumber + + @Binds + @Reusable + abstract fun bindCreateForwardedMessage( + impl: CreateForwardedMessageImpl, + ): CreateForwardedMessage + + @Binds + @Reusable + abstract fun bindGetConversationDraftSendProtocol( + impl: GetConversationDraftSendProtocolImpl, + ): GetConversationDraftSendProtocol + + @Binds + @Reusable + abstract fun bindIsReadContactsPermissionGranted( + impl: IsReadContactsPermissionGrantedImpl, + ): IsReadContactsPermissionGranted + + @Binds + @Reusable + abstract fun bindForwardedMessageSubjectFormatter( + impl: ForwardedMessageSubjectFormatterImpl, + ): ForwardedMessageSubjectFormatter + + @Binds + @Reusable + abstract fun bindResolveConversationId( + impl: ResolveConversationIdImpl, + ): ResolveConversationId + + @Binds + @Reusable + abstract fun bindIsConversationRecipientLimitExceeded( + impl: IsConversationRecipientLimitExceededImpl, + ): IsConversationRecipientLimitExceeded + + @Binds + @Reusable + abstract fun bindConversationsRepository( + impl: ConversationsRepositoryImpl, + ): ConversationsRepository + + @Binds + @Reusable + abstract fun bindSubscriptionsRepository( + impl: SubscriptionsRepositoryImpl, + ): SubscriptionsRepository + + @Binds + @Reusable + abstract fun bindConversationAttachmentRepository( + impl: ConversationAttachmentsRepositoryImpl, + ): ConversationAttachmentsRepository + + @Binds + @Reusable + abstract fun bindConversationDraftAttachmentMapper( + impl: ConversationDraftAttachmentMapperImpl, + ): ConversationDraftAttachmentMapper + + @Binds + @Reusable + abstract fun bindConversationComposerAttachmentUiModelMapper( + impl: ConversationComposerAttachmentUiModelMapperImpl, + ): ConversationComposerAttachmentUiModelMapper + + @Binds + abstract fun bindConversationComposerUiStateMapper( + impl: ConversationComposerUiStateMapperImpl, + ): ConversationComposerUiStateMapper + + @Binds + abstract fun bindConversationMessageUiModelMapper( + impl: ConversationMessageUiModelMapperImpl, + ): ConversationMessageUiModelMapper + + @Binds + @Reusable + abstract fun bindConversationVCardAttachmentUiModelMapper( + impl: ConversationVCardAttachmentUiModelMapperImpl, + ): ConversationVCardAttachmentUiModelMapper + + @Binds + @Reusable + abstract fun bindConversationVCardMetadataRepository( + impl: ConversationVCardMetadataRepositoryImpl, + ): ConversationVCardMetadataRepository + + @Binds + @Reusable + abstract fun bindConversationVCardMetadataMapper( + impl: ConversationVCardMetadataMapperImpl, + ): ConversationVCardMetadataMapper + + @Binds + @Reusable + abstract fun bindConversationMediaRepository( + impl: ConversationMediaRepositoryImpl, + ): ConversationMediaRepository + + @Binds + abstract fun bindConversationMetadataUiStateMapper( + impl: ConversationMetadataUiStateMapperImpl, + ): ConversationMetadataUiStateMapper + + @Binds + @Reusable + abstract fun bindSendConversationDraft( + impl: SendConversationDraftImpl, + ): SendConversationDraft +} diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt new file mode 100644 index 00000000..0ca2bbc3 --- /dev/null +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -0,0 +1,108 @@ +package com.android.messaging.di.conversation + +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocolImpl +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimitImpl +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEditorDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEditorDelegateImpl +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegateImpl +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegateImpl +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegateImpl +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegateImpl +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegateImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class ConversationViewModelBindsModule { + + @Binds + @ViewModelScoped + abstract fun bindConversationAudioRecordingDelegate( + impl: ConversationAudioRecordingDelegateImpl, + ): ConversationAudioRecordingDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationComposerAttachmentsDelegate( + impl: ConversationComposerAttachmentsDelegateImpl, + ): ConversationComposerAttachmentsDelegate + + @Binds + @ViewModelScoped + abstract fun bindResolveConversationDraftSendProtocol( + impl: ResolveConversationDraftSendProtocolImpl, + ): ResolveConversationDraftSendProtocol + + @Binds + @ViewModelScoped + abstract fun bindResolveDraftAttachmentsWithinLimit( + impl: ResolveDraftAttachmentsWithinLimitImpl, + ): ResolveDraftAttachmentsWithinLimit + + @Binds + @ViewModelScoped + abstract fun bindConversationDraftDelegate( + impl: ConversationDraftDelegateImpl, + ): ConversationDraftDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationDraftEditorDelegate( + impl: ConversationDraftEditorDelegateImpl, + ): ConversationDraftEditorDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMediaPickerDelegate( + impl: ConversationMediaPickerDelegateImpl, + ): ConversationMediaPickerDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMessageSelectionDelegate( + impl: ConversationMessageSelectionDelegateImpl, + ): ConversationMessageSelectionDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMessagesDelegate( + impl: ConversationMessagesDelegateImpl, + ): ConversationMessagesDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMetadataDelegate( + impl: ConversationMetadataDelegateImpl, + ): ConversationMetadataDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationFocusDelegate( + impl: ConversationFocusDelegateImpl, + ): ConversationFocusDelegate + + @Binds + @ViewModelScoped + abstract fun bindRecipientPickerDelegate( + impl: RecipientPickerDelegateImpl, + ): RecipientPickerDelegate +} diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index 8be96507..5badb5f3 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -1,15 +1,21 @@ package com.android.messaging.di.core +import android.app.role.RoleManager +import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context +import android.telephony.TelephonyManager import dagger.Module import dagger.Provides import dagger.Reusable import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) @@ -36,6 +42,16 @@ internal class CoreProvidesModule { return Dispatchers.Main } + @Provides + @Singleton + @ApplicationCoroutineScope + fun provideApplicationCoroutineScope( + @DefaultDispatcher + defaultDispatcher: CoroutineDispatcher, + ): CoroutineScope { + return CoroutineScope(SupervisorJob() + defaultDispatcher) + } + @Provides @Reusable fun provideContentResolver( @@ -44,4 +60,31 @@ internal class CoreProvidesModule { ): ContentResolver { return context.contentResolver } + + @Provides + @Reusable + fun provideRoleManager( + @ApplicationContext + context: Context, + ): RoleManager { + return context.getSystemService(RoleManager::class.java) + } + + @Provides + @Reusable + fun provideClipboardManager( + @ApplicationContext + context: Context, + ): ClipboardManager { + return context.getSystemService(ClipboardManager::class.java) + } + + @Provides + @Reusable + fun provideTelephonyManager( + @ApplicationContext + context: Context, + ): TelephonyManager { + return context.getSystemService(TelephonyManager::class.java) + } } diff --git a/src/com/android/messaging/di/core/DebugProvidesModule.kt b/src/com/android/messaging/di/core/DebugProvidesModule.kt new file mode 100644 index 00000000..59d1c59e --- /dev/null +++ b/src/com/android/messaging/di/core/DebugProvidesModule.kt @@ -0,0 +1,18 @@ +package com.android.messaging.di.core + +import com.android.messaging.debug.DebugSimEmulationSource +import com.android.messaging.debug.DebugSimEmulationStore +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object DebugProvidesModule { + + @Provides + @Reusable + fun provideDebugSimEmulationSource(): DebugSimEmulationSource = DebugSimEmulationStore +} diff --git a/src/com/android/messaging/di/core/Qualifiers.kt b/src/com/android/messaging/di/core/Qualifiers.kt index 4bf9b1ec..9cd64168 100644 --- a/src/com/android/messaging/di/core/Qualifiers.kt +++ b/src/com/android/messaging/di/core/Qualifiers.kt @@ -13,3 +13,7 @@ annotation class IoDispatcher @Retention(AnnotationRetention.BINARY) @Qualifier annotation class MainDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationCoroutineScope diff --git a/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt b/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt new file mode 100644 index 00000000..840a6dba --- /dev/null +++ b/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt @@ -0,0 +1,25 @@ +package com.android.messaging.domain.contacts.usecase + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal interface IsReadContactsPermissionGranted { + operator fun invoke(): Boolean +} + +internal class IsReadContactsPermissionGrantedImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, +) : IsReadContactsPermissionGranted { + + override fun invoke(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt b/src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt new file mode 100644 index 00000000..65d1b8e2 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt @@ -0,0 +1,35 @@ +package com.android.messaging.domain.conversation.usecase.action + +import android.app.role.RoleManager +import com.android.messaging.util.PhoneUtils +import javax.inject.Inject + +internal fun interface CheckConversationActionRequirements { + operator fun invoke(): ConversationActionRequirementsResult +} + +internal class CheckConversationActionRequirementsImpl @Inject constructor( + private val roleManager: RoleManager, +) : CheckConversationActionRequirements { + + private val phoneUtils by lazy { PhoneUtils.getDefault() } + + override operator fun invoke(): ConversationActionRequirementsResult { + return when { + !phoneUtils.isSmsCapable -> ConversationActionRequirementsResult.SmsNotCapable + + !phoneUtils.hasPreferredSmsSim -> { + ConversationActionRequirementsResult.NoPreferredSmsSim + } + + !hasDefaultSmsRole() -> ConversationActionRequirementsResult.MissingDefaultSmsRole + + else -> ConversationActionRequirementsResult.Ready + } + } + + private fun hasDefaultSmsRole(): Boolean { + return roleManager.isRoleAvailable(RoleManager.ROLE_SMS) && + roleManager.isRoleHeld(RoleManager.ROLE_SMS) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt b/src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt new file mode 100644 index 00000000..d56421e9 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt @@ -0,0 +1,11 @@ +package com.android.messaging.domain.conversation.usecase.action + +internal sealed interface ConversationActionRequirementsResult { + data object Ready : ConversationActionRequirementsResult + + data object SmsNotCapable : ConversationActionRequirementsResult + + data object NoPreferredSmsSim : ConversationActionRequirementsResult + + data object MissingDefaultSmsRole : ConversationActionRequirementsResult +} diff --git a/src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt b/src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt new file mode 100644 index 00000000..bac862af --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt @@ -0,0 +1,24 @@ +package com.android.messaging.domain.conversation.usecase.action + +import android.app.role.RoleManager +import android.content.Intent +import javax.inject.Inject + +internal fun interface CreateDefaultSmsRoleRequest { + operator fun invoke(): Intent? +} + +internal class CreateDefaultSmsRoleRequestImpl @Inject constructor( + private val roleManager: RoleManager, +) : CreateDefaultSmsRoleRequest { + + override operator fun invoke(): Intent? { + return when { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) -> { + roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) + } + + else -> null + } + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt new file mode 100644 index 00000000..1b536ca0 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt @@ -0,0 +1,78 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.datamodel.MessageTextStats +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.sms.MmsUtils +import javax.inject.Inject + +internal fun interface GetConversationDraftSendProtocol { + operator fun invoke( + draft: ConversationDraft, + sendData: ConversationSendData, + ): ConversationDraftSendProtocol +} + +internal class GetConversationDraftSendProtocolImpl @Inject constructor() : + GetConversationDraftSendProtocol { + + override operator fun invoke( + draft: ConversationDraft, + sendData: ConversationSendData, + ): ConversationDraftSendProtocol { + return when { + shouldSendAsMms( + draft = draft, + sendData = sendData, + ) -> ConversationDraftSendProtocol.MMS + + else -> ConversationDraftSendProtocol.SMS + } + } + + private fun shouldSendAsMms( + draft: ConversationDraft, + sendData: ConversationSendData, + ): Boolean { + val selfSubId = resolveSelfSubId(sendData = sendData) + val conversationMetadata = sendData.metadata + + val groupConversationRequiresMms = conversationMetadata.isGroupConversation && + MmsUtils.groupMmsEnabled(selfSubId) + + val emailAddressRequiresMms = MmsSmsUtils.getRequireMmsForEmailAddress( + conversationMetadata.includeEmailAddress, + selfSubId, + ) + + return when { + draft.attachments.isNotEmpty() -> true + draft.subjectText.isNotBlank() -> true + groupConversationRequiresMms -> true + emailAddressRequiresMms -> true + + else -> messageLengthRequiresMms( + messageText = draft.messageText, + selfSubId = selfSubId, + ) + } + } + + private fun resolveSelfSubId(sendData: ConversationSendData): Int { + return sendData.selfParticipant?.subId ?: ParticipantData.DEFAULT_SELF_SUB_ID + } + + private fun messageLengthRequiresMms( + messageText: String, + selfSubId: Int, + ): Boolean { + return MessageTextStats() + .apply { + updateMessageTextStats(selfSubId, messageText) + } + .messageLengthRequiresMms + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt new file mode 100644 index 00000000..e98e25b5 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt @@ -0,0 +1,91 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +internal interface ResolveConversationDraftSendProtocol { + suspend operator fun invoke( + conversationId: String?, + draft: ConversationDraft, + ): ConversationDraftSendProtocol +} + +internal class ResolveConversationDraftSendProtocolImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ResolveConversationDraftSendProtocol { + + @Suppress("TooGenericExceptionCaught") + override suspend operator fun invoke( + conversationId: String?, + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return try { + val sendData = resolveConversationSendData( + conversationId = conversationId, + draft = draft, + ) + + when (sendData) { + null -> fallbackDraftSendProtocol(draft = draft) + else -> { + getConversationDraftSendProtocol( + draft = draft, + sendData = sendData, + ) + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + LogUtil.e( + TAG, + "Failed to resolve draft send protocol for conversation $conversationId", + exception, + ) + + fallbackDraftSendProtocol(draft = draft) + } + } + + private suspend fun resolveConversationSendData( + conversationId: String?, + draft: ConversationDraft, + ): ConversationSendData? { + return when { + draft.hasContent && !conversationId.isNullOrBlank() -> { + withContext(context = ioDispatcher) { + conversationsRepository.getConversationSendData( + conversationId = conversationId, + requestedSelfParticipantId = draft.selfParticipantId, + ) + } + } + + else -> null + } + } + + private fun fallbackDraftSendProtocol( + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return when { + draft.isMms -> ConversationDraftSendProtocol.MMS + else -> ConversationDraftSendProtocol.SMS + } + } + + private companion object { + private const val TAG = "ResolveDraftSendProtocol" + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt new file mode 100644 index 00000000..efb8f5fa --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt @@ -0,0 +1,58 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import javax.inject.Inject + +internal interface ResolveDraftAttachmentsWithinLimit { + operator fun invoke( + currentAttachments: Collection, + attachmentsToAdd: Collection, + ): DraftAttachmentLimitResult +} + +internal class ResolveDraftAttachmentsWithinLimitImpl @Inject constructor( + private val subscriptionsRepository: SubscriptionsRepository, +) : ResolveDraftAttachmentsWithinLimit { + + override operator fun invoke( + currentAttachments: Collection, + attachmentsToAdd: Collection, + ): DraftAttachmentLimitResult { + return resolveAttachmentsWithinLimit( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + attachmentLimit = subscriptionsRepository.resolveAttachmentLimit(), + ) + } + + private fun resolveAttachmentsWithinLimit( + currentAttachments: Collection, + attachmentsToAdd: Collection, + attachmentLimit: Int, + ): DraftAttachmentLimitResult { + val remainingAttachmentSlots = (attachmentLimit - currentAttachments.size) + .coerceAtLeast(0) + + val currentAttachmentContentUris = currentAttachments + .map { attachment -> attachment.contentUri } + .toSet() + + val uniqueAttachmentsToAdd = attachmentsToAdd + .asSequence() + .distinctBy { attachment -> attachment.contentUri } + .filterNot { attachment -> + attachment.contentUri in currentAttachmentContentUris + } + .toList() + + val acceptedAttachments = uniqueAttachmentsToAdd + .take(n = remainingAttachmentSlots) + + return DraftAttachmentLimitResult( + attachmentsToAdd = acceptedAttachments, + didDropAttachments = uniqueAttachmentsToAdd.size > acceptedAttachments.size, + ) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt new file mode 100644 index 00000000..7f513a83 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -0,0 +1,309 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.datamodel.action.InsertNewMessageAction +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.domain.conversation.usecase.draft.exception.BlankConversationIdException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationRecipientsNotLoadedException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException +import com.android.messaging.domain.conversation.usecase.draft.exception.EmptyConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.MessageLimitExceededException +import com.android.messaging.domain.conversation.usecase.draft.exception.MissingSelfPhoneNumberForGroupMmsException +import com.android.messaging.domain.conversation.usecase.draft.exception.SendConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsConfig +import com.android.messaging.sms.MmsUtils +import com.android.messaging.util.ContentType +import com.android.messaging.util.PhoneUtils +import com.android.messaging.util.core.extension.unitFlow +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +internal interface SendConversationDraft { + operator fun invoke( + conversationId: String, + draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean = false, + ): Flow +} + +internal class SendConversationDraftImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val subscriptionsRepository: SubscriptionsRepository, + private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, + private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : SendConversationDraft { + + @Suppress("TooGenericExceptionCaught") + override operator fun invoke( + conversationId: String, + draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean, + ): Flow { + return unitFlow { + try { + validateAndSendDraft( + conversationId = conversationId, + draft = draft, + ignoreMessageSizeLimit = ignoreMessageSizeLimit, + ) + } catch (exception: Exception) { + if (exception is CancellationException) { + throw exception + } + + throw exception.toSendConversationDraftException( + conversationId = conversationId, + ) + } + }.flowOn(ioDispatcher) + } + + private fun Exception.toSendConversationDraftException( + conversationId: String, + ): SendConversationDraftException { + return when (this) { + is SendConversationDraftException -> this + + else -> { + DraftDispatchFailedException( + conversationId = conversationId, + cause = this, + ) + } + } + } + + private fun validateAndSendDraft( + conversationId: String, + draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean, + ) { + validateDraftBasics( + conversationId = conversationId, + draft = draft, + ) + + val sendData = conversationsRepository.getConversationSendData( + conversationId = conversationId, + requestedSelfParticipantId = draft.selfParticipantId, + ) ?: throw ConversationRecipientsNotLoadedException( + conversationId = conversationId, + ) + + val selfSubId = resolveSelfSubId(sendData = sendData) + val sendProtocol = getConversationDraftSendProtocol( + draft = draft, + sendData = sendData, + ) + val shouldSendAsMms = sendProtocol == ConversationDraftSendProtocol.MMS + + validateDraftForSend( + conversationId = conversationId, + draft = draft, + sendData = sendData, + selfSubId = selfSubId, + shouldSendAsMms = shouldSendAsMms, + ) + + val message = conversationDraftMessageDataMapper.map( + conversationId = conversationId, + draft = draft, + forceMms = shouldSendAsMms, + ) + + message.consolidateText() + + validateMappedMessageForSend( + conversationId = conversationId, + message = message, + selfSubId = selfSubId, + ignoreMessageSizeLimit = ignoreMessageSizeLimit, + ) + + insertNewMessageWithLegacySelfLock( + message = message, + sendData = sendData, + ) + } + + private fun validateDraftForSend( + conversationId: String, + draft: ConversationDraft, + sendData: ConversationSendData, + selfSubId: Int, + shouldSendAsMms: Boolean, + ) { + validateKnownRecipients( + conversationId = conversationId, + sendData = sendData, + ) + + validateGroupMmsSelfNumber( + conversationId = conversationId, + sendData = sendData, + selfSubId = selfSubId, + shouldSendAsMms = shouldSendAsMms, + ) + validateVideoAttachmentLimit( + conversationId = conversationId, + attachments = draft.attachments, + ) + } + + private fun validateDraftBasics( + conversationId: String, + draft: ConversationDraft, + ) { + if (conversationId.isBlank()) { + throw BlankConversationIdException() + } + + if (!draft.hasContent) { + throw EmptyConversationDraftException( + conversationId = conversationId, + ) + } + } + + private fun validateKnownRecipients( + conversationId: String, + sendData: ConversationSendData, + ) { + if (!sendData.participants.isLoaded) { + throw ConversationRecipientsNotLoadedException( + conversationId = conversationId, + ) + } + + val hasUnknownSenders = sendData.participants.any { it.isUnknownSender } + + if (hasUnknownSenders) { + throw UnknownConversationRecipientException( + conversationId = conversationId, + ) + } + } + + private fun resolveSelfSubId(sendData: ConversationSendData): Int { + return sendData.selfParticipant?.subId ?: ParticipantData.DEFAULT_SELF_SUB_ID + } + + private fun validateGroupMmsSelfNumber( + conversationId: String, + sendData: ConversationSendData, + selfSubId: Int, + shouldSendAsMms: Boolean, + ) { + if (!sendData.metadata.isGroupConversation || !shouldSendAsMms) { + return + } + + try { + val selfPhoneNumber = PhoneUtils.get(selfSubId).getSelfRawNumber(true) + if (selfPhoneNumber.isNullOrBlank()) { + throw MissingSelfPhoneNumberForGroupMmsException( + conversationId = conversationId, + selfSubId = selfSubId, + ) + } + } catch (exception: IllegalStateException) { + throw ConversationSimNotReadyException( + conversationId = conversationId, + selfSubId = selfSubId, + cause = exception, + ) + } + } + + private fun validateVideoAttachmentLimit( + conversationId: String, + attachments: Iterable, + ) { + val videoAttachmentCount = attachments.count { attachment -> + ContentType.isVideoType(attachment.contentType) + } + + if (videoAttachmentCount > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) { + throw TooManyVideoAttachmentsException( + conversationId = conversationId, + videoAttachmentCount = videoAttachmentCount, + ) + } + } + + private fun validateMappedMessageForSend( + conversationId: String, + message: MessageData, + selfSubId: Int, + ignoreMessageSizeLimit: Boolean, + ) { + if (ignoreMessageSizeLimit) { + return + } + + val attachments = message.parts.filter { part -> part.isAttachment } + + if (attachments.size > subscriptionsRepository.resolveAttachmentLimit()) { + throw MessageLimitExceededException(conversationId = conversationId) + } + + val totalAttachmentSize = attachments.sumOf { attachment -> + attachment.minimumSizeInBytesForSending + } + + if (totalAttachmentSize > resolveMaxMessageSize(selfSubId = selfSubId)) { + throw MessageLimitExceededException(conversationId = conversationId) + } + } + + private fun resolveMaxMessageSize(selfSubId: Int): Int { + return when { + selfSubId <= ParticipantData.DEFAULT_SELF_SUB_ID -> MmsConfig.getMaxMaxMessageSize() + else -> MmsConfig.get(selfSubId).maxMessageSize + } + } + + private fun insertNewMessageWithLegacySelfLock( + message: MessageData, + sendData: ConversationSendData, + ) { + val selfParticipant = sendData.selfParticipant + + val systemDefaultSubId = PhoneUtils.getDefault().defaultSmsSubscriptionId + + val messageHasSelfParticipant = message.selfId != null + val conversationUsesDefaultSelf = selfParticipant?.isDefaultSelf == true + val systemDefaultSubIdIsResolved = systemDefaultSubId != + ParticipantData.DEFAULT_SELF_SUB_ID + + val shouldLockToSystemDefaultSubId = messageHasSelfParticipant && + conversationUsesDefaultSelf && + systemDefaultSubIdIsResolved + + when { + shouldLockToSystemDefaultSubId -> { + InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId) + } + + else -> { + InsertNewMessageAction.insertNewMessage(message) + } + } + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt b/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt new file mode 100644 index 00000000..368b2960 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt @@ -0,0 +1,69 @@ +package com.android.messaging.domain.conversation.usecase.draft.exception + +internal sealed class SendConversationDraftException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal class BlankConversationIdException : + SendConversationDraftException( + message = "Conversation id must not be blank.", + ) + +internal class EmptyConversationDraftException( + conversationId: String, +) : SendConversationDraftException( + message = "Draft must contain content before it can be sent " + + "for conversation $conversationId.", +) + +internal class ConversationRecipientsNotLoadedException( + conversationId: String, +) : SendConversationDraftException( + message = "Conversation recipients are not loaded for conversation $conversationId.", +) + +internal class UnknownConversationRecipientException( + conversationId: String, +) : SendConversationDraftException( + message = "Conversation $conversationId contains an unknown sender.", +) + +internal class MissingSelfPhoneNumberForGroupMmsException( + conversationId: String, + selfSubId: Int, +) : SendConversationDraftException( + message = "Missing self phone number for group MMS in conversation $conversationId " + + "on subId $selfSubId.", +) + +internal class ConversationSimNotReadyException( + conversationId: String, + selfSubId: Int, + cause: Throwable, +) : SendConversationDraftException( + message = "SIM is not ready for conversation $conversationId on subId $selfSubId.", + cause = cause, +) + +internal class TooManyVideoAttachmentsException( + conversationId: String, + videoAttachmentCount: Int, +) : SendConversationDraftException( + message = "Draft for conversation $conversationId has $videoAttachmentCount video " + + "attachments.", +) + +internal class MessageLimitExceededException( + conversationId: String, +) : SendConversationDraftException( + message = "Draft for conversation $conversationId exceeds the MMS message limit.", +) + +internal class DraftDispatchFailedException( + conversationId: String, + cause: Throwable, +) : SendConversationDraftException( + message = "Failed to enqueue outgoing draft for conversation $conversationId.", + cause = cause, +) diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt new file mode 100644 index 00000000..c07dfa8f --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt @@ -0,0 +1,6 @@ +package com.android.messaging.domain.conversation.usecase.draft.model + +internal enum class ConversationDraftSendProtocol { + SMS, + MMS, +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt b/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt new file mode 100644 index 00000000..e629abe1 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt @@ -0,0 +1,8 @@ +package com.android.messaging.domain.conversation.usecase.draft.model + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment + +internal data class DraftAttachmentLimitResult( + val attachmentsToAdd: List, + val didDropAttachments: Boolean, +) diff --git a/src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt b/src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt new file mode 100644 index 00000000..cb75b65c --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt @@ -0,0 +1,58 @@ +package com.android.messaging.domain.conversation.usecase.forward + +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.datamodel.data.PendingAttachmentData +import javax.inject.Inject + +internal interface CreateForwardedMessage { + operator fun invoke( + conversationId: String, + messageId: String, + ): MessageData? +} + +internal class CreateForwardedMessageImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val forwardedMessageSubjectFormatter: ForwardedMessageSubjectFormatter, +) : CreateForwardedMessage { + + override operator fun invoke( + conversationId: String, + messageId: String, + ): MessageData? { + val message = conversationsRepository + .getConversationMessage( + conversationId = conversationId, + messageId = messageId, + ) + ?: return null + + val forwardedMessage = MessageData() + + forwardedMessage.mmsSubject = forwardedMessageSubjectFormatter.format( + subject = message.mmsSubject, + ) + + message + .parts + ?.map(::createForwardedPart) + ?.forEach(forwardedMessage::addPart) + + return forwardedMessage + } + + private fun createForwardedPart(part: MessagePartData): MessagePartData { + return when { + part.isText -> MessagePartData.createTextMessagePart(part.text) + + else -> { + PendingAttachmentData.createPendingAttachmentData( + part.contentType, + part.contentUri, + ) + } + } + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt b/src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt new file mode 100644 index 00000000..b2af1489 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt @@ -0,0 +1,30 @@ +package com.android.messaging.domain.conversation.usecase.forward + +import android.content.Context +import com.android.messaging.R +import com.android.messaging.sms.cleanseMmsSubject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal interface ForwardedMessageSubjectFormatter { + fun format(subject: String?): String? +} + +internal class ForwardedMessageSubjectFormatterImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, +) : ForwardedMessageSubjectFormatter { + + override fun format(subject: String?): String? { + val resources = context.resources + val originalSubject = cleanseMmsSubject( + resources = resources, + subject = subject, + ) ?: return null + + return resources.getString( + R.string.message_fwd, + originalSubject, + ) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt new file mode 100644 index 00000000..ebe0bbc2 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt @@ -0,0 +1,17 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.data.ContactPickerData +import javax.inject.Inject + +internal fun interface CanAddMoreConversationParticipants { + operator fun invoke(participantCount: Int): Boolean +} + +// TODO: Get rid of legacy ContactPickerData usage +internal class CanAddMoreConversationParticipantsImpl @Inject constructor() : + CanAddMoreConversationParticipants { + + override operator fun invoke(participantCount: Int): Boolean { + return ContactPickerData.getCanAddMoreParticipants(participantCount) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt b/src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt new file mode 100644 index 00000000..2101c9cc --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt @@ -0,0 +1,16 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.data.ContactPickerData +import javax.inject.Inject + +internal fun interface IsConversationRecipientLimitExceeded { + operator fun invoke(participantCount: Int): Boolean +} + +internal class IsConversationRecipientLimitExceededImpl @Inject constructor() : + IsConversationRecipientLimitExceeded { + + override operator fun invoke(participantCount: Int): Boolean { + return ContactPickerData.isTooManyParticipants(participantCount) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt b/src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt new file mode 100644 index 00000000..f9dcdb7a --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt @@ -0,0 +1,85 @@ +package com.android.messaging.domain.conversation.usecase.participant + +import com.android.messaging.datamodel.action.ActionMonitor +import com.android.messaging.datamodel.action.GetOrCreateConversationAction +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +internal interface ResolveConversationId { + suspend operator fun invoke( + destinations: List, + ): ResolveConversationIdResult +} + +// TODO: Get rid of legacy GetOrCreateConversationAction +internal class ResolveConversationIdImpl @Inject constructor( + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ResolveConversationId { + + override suspend operator fun invoke( + destinations: List, + ): ResolveConversationIdResult { + val participants = createParticipants(destinations = destinations) + + if (participants.isEmpty()) { + return ResolveConversationIdResult.EmptyDestinations + } + + return withContext(context = mainDispatcher) { + suspendCancellableCoroutine { continuation -> + var actionMonitor: ActionMonitor? = null + + actionMonitor = GetOrCreateConversationAction.getOrCreateConversation( + participants, + null, + object : GetOrCreateConversationAction.GetOrCreateConversationActionListener { + override fun onGetOrCreateConversationSucceeded( + monitor: ActionMonitor, + data: Any?, + conversationId: String, + ) { + if (continuation.isActive) { + continuation.resume( + ResolveConversationIdResult.Resolved( + conversationId = conversationId, + ), + ) + } + } + + override fun onGetOrCreateConversationFailed( + monitor: ActionMonitor, + data: Any?, + ) { + if (continuation.isActive) { + continuation.resume(ResolveConversationIdResult.NotResolved) + } + } + }, + ) + + continuation.invokeOnCancellation { + actionMonitor?.unregister() + } + } + } + } + + private fun createParticipants( + destinations: List, + ): ArrayList { + return destinations + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map(ParticipantData::getFromRawPhoneBySystemLocale) + .toCollection(ArrayList(destinations.size)) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt b/src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt new file mode 100644 index 00000000..d89df860 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt @@ -0,0 +1,11 @@ +package com.android.messaging.domain.conversation.usecase.participant.model + +internal sealed interface ResolveConversationIdResult { + data object EmptyDestinations : ResolveConversationIdResult + + data object NotResolved : ResolveConversationIdResult + + data class Resolved( + val conversationId: String, + ) : ResolveConversationIdResult +} diff --git a/src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt b/src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt new file mode 100644 index 00000000..e3ac8b4d --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt @@ -0,0 +1,15 @@ +package com.android.messaging.domain.conversation.usecase.telephony + +import com.android.messaging.util.PhoneUtils +import javax.inject.Inject + +internal fun interface IsDeviceVoiceCapable { + operator fun invoke(): Boolean +} + +internal class IsDeviceVoiceCapableImpl @Inject constructor() : IsDeviceVoiceCapable { + + override operator fun invoke(): Boolean { + return PhoneUtils.getDefault().isVoiceCapable + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt b/src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt new file mode 100644 index 00000000..35ce1d46 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt @@ -0,0 +1,34 @@ +package com.android.messaging.domain.conversation.usecase.telephony + +import android.telephony.PhoneNumberUtils +import android.telephony.TelephonyManager +import com.android.messaging.util.LogUtil +import javax.inject.Inject + +internal fun interface IsEmergencyPhoneNumber { + operator fun invoke(phoneNumber: String): Boolean +} + +internal class IsEmergencyPhoneNumberImpl @Inject constructor( + private val telephonyManager: TelephonyManager, +) : IsEmergencyPhoneNumber { + + @Suppress("DEPRECATION") + override operator fun invoke(phoneNumber: String): Boolean { + val normalizedPhoneNumber = PhoneNumberUtils.stripSeparators(phoneNumber) + + return try { + telephonyManager.isEmergencyNumber(normalizedPhoneNumber) + } catch (exception: IllegalStateException) { + LogUtil.w(LOG_TAG, "Unable to check emergency phone number", exception) + PhoneNumberUtils.isEmergencyNumber(normalizedPhoneNumber) + } catch (exception: UnsupportedOperationException) { + LogUtil.w(LOG_TAG, "Unable to check emergency phone number", exception) + PhoneNumberUtils.isEmergencyNumber(normalizedPhoneNumber) + } + } + + private companion object { + private const val LOG_TAG = "IsEmergencyPhoneNumber" + } +} diff --git a/src/com/android/messaging/sms/MmsSubjectSanitizer.kt b/src/com/android/messaging/sms/MmsSubjectSanitizer.kt new file mode 100644 index 00000000..23d709c1 --- /dev/null +++ b/src/com/android/messaging/sms/MmsSubjectSanitizer.kt @@ -0,0 +1,27 @@ +package com.android.messaging.sms + +import android.content.res.Resources +import com.android.messaging.R + +internal fun cleanseMmsSubject( + resources: Resources, + subject: String?, +): String? { + return cleanseMmsSubject( + subject = subject, + emptySubjectStrings = resources.getStringArray(R.array.empty_subject_strings), + ) +} + +internal fun cleanseMmsSubject( + subject: String?, + emptySubjectStrings: Array, +): String? { + return subject + ?.takeIf(String::isNotEmpty) + ?.takeUnless { candidateSubject -> + emptySubjectStrings.any { emptySubjectString -> + candidateSubject.equals(other = emptySubjectString, ignoreCase = true) + } + } +} diff --git a/src/com/android/messaging/ui/AttachmentPreview.java b/src/com/android/messaging/ui/AttachmentPreview.java deleted file mode 100644 index f4465c46..00000000 --- a/src/com/android/messaging/ui/AttachmentPreview.java +++ /dev/null @@ -1,343 +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.ui; - -import android.animation.Animator; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.graphics.Rect; -import android.os.Handler; -import android.os.Looper; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ScrollView; - -import com.android.messaging.R; -import com.android.messaging.annotation.VisibleForAnimation; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; -import com.android.messaging.ui.animation.PopupTransitionAnimation; -import com.android.messaging.ui.conversation.ComposeMessageView; -import com.android.messaging.ui.conversation.ConversationFragment; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ThreadUtil; -import com.android.messaging.util.UiUtils; - -import java.util.ArrayList; -import java.util.List; - -public class AttachmentPreview extends ScrollView implements OnAttachmentClickListener { - private FrameLayout mAttachmentView; - private ComposeMessageView mComposeMessageView; - private ImageButton mCloseButton; - private int mAnimatedHeight = -1; - private Animator mCloseGapAnimator; - private boolean mPendingFirstUpdate; - private Handler mHandler; - private Runnable mHideRunnable; - private boolean mPendingHideCanceled; - - private PopupTransitionAnimation mPopupTransitionAnimation; - - private static final int CLOSE_BUTTON_REVEAL_STAGGER_MILLIS = 300; - - public AttachmentPreview(final Context context, final AttributeSet attrs) { - super(context, attrs); - mHandler = new Handler(Looper.getMainLooper()); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mCloseButton = (ImageButton) findViewById(R.id.close_button); - mCloseButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View view) { - mComposeMessageView.clearAttachments(); - } - }); - - mAttachmentView = (FrameLayout) findViewById(R.id.attachment_view); - - // The attachment preview is a scroll view so that it can show the bottom portion of the - // attachment whenever the space is tight (e.g. when in landscape mode). Per design - // request we'd like to make the attachment view always scrolled to the bottom. - addOnLayoutChangeListener(new OnLayoutChangeListener() { - @Override - public void onLayoutChange(final View v, final int left, final int top, final int right, - final int bottom, final int oldLeft, final int oldTop, final int oldRight, - final int oldBottom) { - post(new Runnable() { - @Override - public void run() { - final int childCount = getChildCount(); - if (childCount > 0) { - final View lastChild = getChildAt(childCount - 1); - scrollTo(getScrollX(), lastChild.getBottom() - getHeight()); - } - } - }); - } - }); - mPendingFirstUpdate = true; - } - - public void setComposeMessageView(final ComposeMessageView composeMessageView) { - mComposeMessageView = composeMessageView; - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (mAnimatedHeight >= 0) { - setMeasuredDimension(getMeasuredWidth(), mAnimatedHeight); - } - } - - private void cancelPendingHide() { - mPendingHideCanceled = true; - } - - public void hideAttachmentPreview() { - if (getVisibility() != GONE) { - UiUtils.revealOrHideViewWithAnimation(mCloseButton, GONE, - null /* onFinishRunnable */); - startCloseGapAnimationOnAttachmentClear(); - - if (mAttachmentView.getChildCount() > 0) { - mPendingHideCanceled = false; - final View viewToHide = mAttachmentView.getChildCount() > 1 ? - mAttachmentView : mAttachmentView.getChildAt(0); - UiUtils.revealOrHideViewWithAnimation(viewToHide, INVISIBLE, - new Runnable() { - @Override - public void run() { - // Only hide if we are didn't get overruled by showing - if (!mPendingHideCanceled) { - stopPopupAnimation(); - mAttachmentView.removeAllViews(); - setVisibility(GONE); - } - } - }); - } else { - mAttachmentView.removeAllViews(); - setVisibility(GONE); - } - } - } - - // returns true if we have attachments - public boolean onAttachmentsChanged(final DraftMessageData draftMessageData) { - final boolean isFirstUpdate = mPendingFirstUpdate; - final List attachments = draftMessageData.getReadOnlyAttachments(); - final List pendingAttachments = - draftMessageData.getReadOnlyPendingAttachments(); - - // Any change in attachments would invalidate the animated height animation. - cancelCloseGapAnimation(); - mPendingFirstUpdate = false; - - final int combinedAttachmentCount = attachments.size() + pendingAttachments.size(); - mCloseButton.setContentDescription(getResources() - .getQuantityString(R.plurals.attachment_preview_close_content_description, - combinedAttachmentCount)); - if (combinedAttachmentCount == 0) { - mHideRunnable = new Runnable() { - @Override - public void run() { - mHideRunnable = null; - // Only start the hiding if there are still no attachments - if (attachments.size() + pendingAttachments.size() == 0) { - hideAttachmentPreview(); - } - } - }; - if (draftMessageData.isSending()) { - // Wait to hide until the message is ready to start animating - // We'll execute immediately when the animation triggers - mHandler.postDelayed(mHideRunnable, - ConversationFragment.MESSAGE_ANIMATION_MAX_WAIT); - } else { - // Run immediately when clearing attachments - mHideRunnable.run(); - } - return false; - } - - cancelPendingHide(); // We're showing - if (getVisibility() != VISIBLE) { - setVisibility(VISIBLE); - mAttachmentView.setVisibility(VISIBLE); - - // Don't animate in the close button if this is the first update after view creation. - // This is the initial draft load from database for pre-existing drafts. - if (!isFirstUpdate) { - // Reveal the close button after the view animates in. - mCloseButton.setVisibility(INVISIBLE); - ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { - @Override - public void run() { - UiUtils.revealOrHideViewWithAnimation(mCloseButton, VISIBLE, - null /* onFinishRunnable */); - } - }, UiUtils.MEDIAPICKER_TRANSITION_DURATION + CLOSE_BUTTON_REVEAL_STAGGER_MILLIS); - } - } - - // Merge the pending attachment list with real attachment. Design would prefer these be - // in LIFO order user can see added images past the 5th one but we also want them to be in - // order and we want it to be WYSIWYG. - final List combinedAttachments = new ArrayList<>(); - combinedAttachments.addAll(attachments); - combinedAttachments.addAll(pendingAttachments); - - final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); - if (combinedAttachmentCount > 1) { - MultiAttachmentLayout multiAttachmentLayout = null; - Rect transitionRect = null; - if (mAttachmentView.getChildCount() > 0) { - final View firstChild = mAttachmentView.getChildAt(0); - if (firstChild instanceof MultiAttachmentLayout) { - Assert.equals(1, mAttachmentView.getChildCount()); - multiAttachmentLayout = (MultiAttachmentLayout) firstChild; - multiAttachmentLayout.bindAttachments(combinedAttachments, - null /* transitionRect */, combinedAttachmentCount); - } else { - transitionRect = new Rect(firstChild.getLeft(), firstChild.getTop(), - firstChild.getRight(), firstChild.getBottom()); - } - } - if (multiAttachmentLayout == null) { - multiAttachmentLayout = AttachmentPreviewFactory.createMultiplePreview( - getContext(), this); - multiAttachmentLayout.bindAttachments(combinedAttachments, transitionRect, - combinedAttachmentCount); - mAttachmentView.removeAllViews(); - mAttachmentView.addView(multiAttachmentLayout); - } - } else { - final MessagePartData attachment = combinedAttachments.get(0); - boolean shouldAnimate = true; - if (mAttachmentView.getChildCount() > 0) { - // If we are going from N->1 attachments, try to use the current bounds - // bounds as the starting rect. - shouldAnimate = false; - final View firstChild = mAttachmentView.getChildAt(0); - if (firstChild instanceof MultiAttachmentLayout && - attachment instanceof MediaPickerMessagePartData) { - final View leftoverView = ((MultiAttachmentLayout) firstChild) - .findViewForAttachment(attachment); - if (leftoverView != null) { - final Rect currentRect = UiUtils.getMeasuredBoundsOnScreen(leftoverView); - if (!currentRect.isEmpty() && - attachment instanceof MediaPickerMessagePartData) { - ((MediaPickerMessagePartData) attachment).setStartRect(currentRect); - shouldAnimate = true; - } - } - } - } - mAttachmentView.removeAllViews(); - final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview( - layoutInflater, attachment, mAttachmentView, - AttachmentPreviewFactory.TYPE_SINGLE, true /* startImageRequest */, this); - if (attachmentView != null) { - mAttachmentView.addView(attachmentView); - if (shouldAnimate) { - tryAnimateViewIn(attachment, attachmentView); - } - } - } - return true; - } - - public void onMessageAnimationStart() { - if (mHideRunnable == null) { - return; - } - - // Run the hide animation at the same time as the message animation - mHandler.removeCallbacks(mHideRunnable); - setVisibility(View.INVISIBLE); - mHideRunnable.run(); - } - - private void tryAnimateViewIn(final MessagePartData attachmentData, final View view) { - if (attachmentData instanceof MediaPickerMessagePartData) { - final Rect startRect = ((MediaPickerMessagePartData) attachmentData).getStartRect(); - stopPopupAnimation(); - mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); - mPopupTransitionAnimation.startAfterLayoutComplete(); - } - } - - private void stopPopupAnimation() { - if (mPopupTransitionAnimation != null) { - mPopupTransitionAnimation.cancel(); - mPopupTransitionAnimation = null; - } - } - - @VisibleForAnimation - public void setAnimatedHeight(final int animatedHeight) { - if (mAnimatedHeight != animatedHeight) { - mAnimatedHeight = animatedHeight; - requestLayout(); - } - } - - /** - * Kicks off an animation to animate the layout change for closing the gap between the - * message list and the compose message box when the attachments are cleared. - */ - private void startCloseGapAnimationOnAttachmentClear() { - // Cancel existing animation. - cancelCloseGapAnimation(); - mCloseGapAnimator = ObjectAnimator.ofInt(this, "animatedHeight", getHeight(), 0); - mCloseGapAnimator.start(); - } - - private void cancelCloseGapAnimation() { - if (mCloseGapAnimator != null) { - mCloseGapAnimator.cancel(); - mCloseGapAnimator = null; - } - mAnimatedHeight = -1; - } - - @Override - public boolean onAttachmentClick(final MessagePartData attachment, - final Rect viewBoundsOnScreen, final boolean longPress) { - if (longPress) { - mComposeMessageView.onAttachmentPreviewLongClicked(); - return true; - } - - if (!(attachment instanceof PendingAttachmentData) && attachment.isImage()) { - mComposeMessageView.displayPhoto(attachment.getContentUri(), viewBoundsOnScreen); - return true; - } - return false; - } -} diff --git a/src/com/android/messaging/ui/AttachmentSaveTask.java b/src/com/android/messaging/ui/AttachmentSaveTask.java new file mode 100644 index 00000000..cd501b5d --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentSaveTask.java @@ -0,0 +1,150 @@ +/* + * 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.ui; + +import android.app.DownloadManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; + +import com.android.messaging.R; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class AttachmentSaveTask extends SafeAsyncTask { + private final Context mContext; + private final List mAttachmentsToSave = new ArrayList<>(); + + public AttachmentSaveTask(final Context context, final Uri contentUri, + final String contentType) { + mContext = context; + addAttachmentToSave(contentUri, contentType); + } + + public AttachmentSaveTask(final Context context) { + mContext = context; + } + + public void addAttachmentToSave(final Uri contentUri, final String contentType) { + mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); + } + + public int getAttachmentCount() { + return mAttachmentsToSave.size(); + } + + @Override + protected Void doInBackgroundTimed(final Void... arg) { + final File appDir = new File(Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES), + mContext.getResources().getString(R.string.app_name)); + final File downloadDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + for (final AttachmentToSave attachment : mAttachmentsToSave) { + final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) + || ContentType.isVideoType(attachment.contentType); + attachment.persistedUri = UriUtil.persistContent(attachment.uri, + isImageOrVideo ? appDir : downloadDir, attachment.contentType); + } + return null; + } + + @Override + protected void onPostExecute(final Void result) { + int failCount = 0; + int imageCount = 0; + int videoCount = 0; + int otherCount = 0; + for (final AttachmentToSave attachment : mAttachmentsToSave) { + if (attachment.persistedUri == null) { + failCount++; + continue; + } + + final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + scanFileIntent.setData(attachment.persistedUri); + mContext.sendBroadcast(scanFileIntent); + + if (ContentType.isImageType(attachment.contentType)) { + imageCount++; + } else if (ContentType.isVideoType(attachment.contentType)) { + videoCount++; + } else { + otherCount++; + final DownloadManager downloadManager = + (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE); + final String filePath = attachment.persistedUri.getPath(); + final File file = new File(filePath); + + if (file.exists()) { + downloadManager.addCompletedDownload( + file.getName() /* title */, + mContext.getString( + R.string.attachment_file_description) /* description */, + true /* isMediaScannerScannable */, + attachment.contentType, + file.getAbsolutePath(), + file.length(), + false /* showNotification */); + } + } + } + + final String message; + if (failCount > 0) { + message = mContext.getResources().getQuantityString( + R.plurals.attachment_save_error, failCount, failCount); + } else { + int messageId = R.plurals.attachments_saved; + if (otherCount > 0) { + if (imageCount + videoCount == 0) { + messageId = R.plurals.attachments_saved_to_downloads; + } + } else { + if (videoCount == 0) { + messageId = R.plurals.photos_saved_to_album; + } else if (imageCount == 0) { + messageId = R.plurals.videos_saved_to_album; + } else { + messageId = R.plurals.attachments_saved_to_album; + } + } + final String appName = mContext.getResources().getString(R.string.app_name); + final int count = imageCount + videoCount + otherCount; + message = mContext.getResources().getQuantityString(messageId, count, count, appName); + } + UiUtils.showToastAtBottom(message); + } + + private static class AttachmentToSave { + public final Uri uri; + public final String contentType; + public Uri persistedUri; + + AttachmentToSave(final Uri uri, final String contentType) { + this.uri = uri; + this.contentType = contentType; + } + } +} diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java index 9d7e8bcd..6a704c69 100644 --- a/src/com/android/messaging/ui/UIIntents.java +++ b/src/com/android/messaging/ui/UIIntents.java @@ -25,8 +25,6 @@ import android.net.Uri; import android.os.Bundle; -import androidx.fragment.app.Fragment; - import com.android.messaging.Factory; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.util.ConversationIdSet; @@ -45,12 +43,6 @@ public static UIIntents get() { // Sending draft data (from share intent / message forwarding) to the ConversationActivity. public static final String UI_INTENT_EXTRA_DRAFT_DATA = "draft_data"; - // The request code for picking a media from the Document picker. - public static final int REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER = 1400; - - // The request code for picking a contact card from existing Contacts apps. - public static final int REQUEST_PICK_CONTACT_CARD = 1500; - // Indicates what type of notification this applies to (See BugleNotifications: // UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, UPDATE_ALL) public static final String UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE = "notifications_update"; @@ -168,20 +160,6 @@ public abstract void launchCreateNewConversationActivity(final Context context, */ public abstract void launchAddContactActivity(final Context context, final String destination); - /** - * Launch an activity to show the document picker to pick an image/video/audio. - * - * @param fragment the requesting fragment - */ - public abstract void launchDocumentImagePicker(final Fragment fragment); - - /** - * Launch an activity to show the contacts list to pick one. - * - * @param fragment the requesting fragment - */ - public abstract void launchContactCardPicker(final Fragment fragment); - /** * Launch an activity to show people & options for a given conversation. */ diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java index 9c5d18f0..e980da69 100644 --- a/src/com/android/messaging/ui/UIIntentsImpl.java +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -67,7 +67,6 @@ import androidx.annotation.Nullable; import androidx.core.app.TaskStackBuilder; -import androidx.fragment.app.Fragment; import androidx.localbroadcastmanager.content.LocalBroadcastManager; /** @@ -233,29 +232,6 @@ public void launchBlockedParticipantsActivity(final Context context) { context.startActivity(intent); } - @Override - public void launchDocumentImagePicker(final Fragment fragment) { - final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, MessagePartData.ACCEPTABLE_GALLERY_MEDIA_TYPES); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(ContentType.ANY_TYPE); - - fragment.startActivityForResult(intent, REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER); - } - - @Override - public void launchContactCardPicker(final Fragment fragment) { - final Intent intent = new Intent(Intent.ACTION_PICK); - intent.setType(Contacts.CONTENT_TYPE); - - try { - fragment.startActivityForResult(intent, REQUEST_PICK_CONTACT_CARD); - } catch (final ActivityNotFoundException ex) { - LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); - UiUtils.showToastAtBottom(R.string.activity_not_found_message); - } - } - @Override public void launchPeopleAndOptionsActivity(final Activity activity, final String conversationId) { diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java deleted file mode 100644 index e187f10c..00000000 --- a/src/com/android/messaging/ui/conversation/ComposeMessageView.java +++ /dev/null @@ -1,1016 +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.ui.conversation; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.Html; -import android.text.InputFilter; -import android.text.InputFilter.LengthFilter; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.format.Formatter; -import android.util.AttributeSet; -import android.view.ContextThemeWrapper; -import android.view.KeyEvent; -import android.view.View; -import android.view.accessibility.AccessibilityEvent; -import android.view.inputmethod.EditorInfo; -import android.widget.ImageButton; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask; -import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.ui.AttachmentPreview; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.PlainTextEditText; -import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.BuglePrefs; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.MediaUtil; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.UriUtil; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import androidx.appcompat.app.ActionBar; - -/** - * This view contains the UI required to generate and send messages. - */ -public class ComposeMessageView extends LinearLayout - implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher, - ConversationInputSink { - - public interface IComposeMessageViewHost extends - DraftMessageData.DraftMessageSubscriptionDataProvider { - void sendMessage(MessageData message); - void onComposeEditTextFocused(); - void onAttachmentsCleared(); - void onAttachmentsChanged(final boolean haveAttachments); - void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft); - void promptForSelfPhoneNumber(); - boolean isReadyForAction(); - void warnOfMissingActionConditions(final boolean sending, - final Runnable commandToRunAfterActionConditionResolved); - void warnOfExceedingMessageLimit(final boolean showAttachmentChooser, - boolean tooManyVideos); - void notifyOfAttachmentLoadFailed(); - void showAttachmentChooser(); - boolean shouldShowSubjectEditor(); - boolean shouldHideAttachmentsWhenSimSelectorShown(); - Uri getSelfSendButtonIconUri(); - int overrideCounterColor(); - int getAttachmentsClearedFlags(); - } - - public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10; - - // There is no draft and there is no need for the SIM selector - private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1; - // There is no draft but we need to show the SIM selector - private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2; - // There is a draft - private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3; - - private PlainTextEditText mComposeEditText; - private PlainTextEditText mComposeSubjectText; - private TextView mMessageBodySize; - private TextView mMmsIndicator; - private SimIconView mSelfSendIcon; - private ImageButton mSendButton; - private View mSubjectView; - private ImageButton mDeleteSubjectButton; - private AttachmentPreview mAttachmentPreview; - private ImageButton mAttachMediaButton; - - private final Binding mBinding; - private IComposeMessageViewHost mHost; - private final Context mOriginalContext; - private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; - - // Shared data model object binding from the conversation. - private ImmutableBindingRef mConversationDataModel; - - // Centrally manages all the mutual exclusive UI components accepting user input, i.e. - // media picker, IME keyboard and SIM selector. - private ConversationInputManager mInputManager; - - private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { - @Override - public void onConversationMetadataUpdated(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateVisualsOnDraftChanged(); - } - - @Override - public void onConversationParticipantDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateVisualsOnDraftChanged(); - } - - @Override - public void onSubscriptionListDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateOnSelfSubscriptionChange(); - updateVisualsOnDraftChanged(); - } - }; - - public ComposeMessageView(final Context context, final AttributeSet attrs) { - super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs); - mOriginalContext = context; - mBinding = BindingBase.createBinding(this); - } - - /** - * Host calls this to bind view to DraftMessageData object - */ - public void bind(final DraftMessageData data, final IComposeMessageViewHost host) { - mHost = host; - mBinding.bind(data); - data.addListener(this); - data.setSubscriptionDataProvider(host); - - final int counterColor = mHost.overrideCounterColor(); - if (counterColor != -1) { - mMessageBodySize.setTextColor(counterColor); - } - } - - /** - * Host calls this to unbind view - */ - public void unbind() { - mBinding.unbind(); - mHost = null; - mInputManager.onDetach(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mComposeEditText = findViewById(R.id.compose_message_text); - mComposeEditText.setOnEditorActionListener(this); - mComposeEditText.addTextChangedListener(this); - mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() { - @Override - public void onFocusChange(final View v, final boolean hasFocus) { - if (v == mComposeEditText && hasFocus) { - mHost.onComposeEditTextFocused(); - } - } - }); - mComposeEditText.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View arg0) { - if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - hideSimSelector(); - } - } - }); - - // onFinishInflate() is called before self is loaded from db. We set the default text - // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). - mComposeEditText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) - .getMaxTextLimit()) }); - - mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon); - mSelfSendIcon.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - } - }); - mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View v) { - if (mHost.shouldShowSubjectEditor()) { - showSubjectEditor(); - } else { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - } - return true; - } - }); - - mComposeSubjectText = (PlainTextEditText) findViewById( - R.id.compose_subject_text); - // We need the listener to change the avatar to the send button when the user starts - // typing a subject without a message. - mComposeSubjectText.addTextChangedListener(this); - // onFinishInflate() is called before self is loaded from db. We set the default text - // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). - mComposeSubjectText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) - .getMaxSubjectLength())}); - - mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button); - mDeleteSubjectButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View clickView) { - hideSubjectEditor(); - mComposeSubjectText.setText(null); - mBinding.getData().setMessageSubject(null); - } - }); - - mSubjectView = findViewById(R.id.subject_view); - - mSendButton = (ImageButton) findViewById(R.id.send_message_button); - mSendButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View clickView) { - sendMessageInternal(true /* checkMessageSize */); - } - }); - mSendButton.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View arg0) { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - if (mHost.shouldShowSubjectEditor()) { - showSubjectEditor(); - } - return true; - } - }); - mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() { - @Override - public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { - super.onPopulateAccessibilityEvent(host, event); - // When the send button is long clicked, we want TalkBack to announce the real - // action (select SIM or edit subject), as opposed to "long press send button." - if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) { - event.getText().clear(); - event.getText().add(getResources() - .getText(shouldShowSimSelector(mConversationDataModel.getData()) ? - R.string.send_button_long_click_description_with_sim_selector : - R.string.send_button_long_click_description_no_sim_selector)); - // Make this an announcement so TalkBack will read our custom message. - event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); - } - } - }); - - mAttachMediaButton = - (ImageButton) findViewById(R.id.attach_media_button); - mAttachMediaButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View clickView) { - // Showing the media picker is treated as starting to compose the message. - mInputManager.showHideMediaPicker(true /* show */, true /* animate */); - } - }); - - mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view); - mAttachmentPreview.setComposeMessageView(this); - - mMessageBodySize = (TextView) findViewById(R.id.message_body_size); - mMmsIndicator = (TextView) findViewById(R.id.mms_indicator); - } - - private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) { - if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - return; - } - final boolean haveAttachments = mBinding.getData().hasAttachments(); - if (simPickerVisible && haveAttachments) { - mHost.onAttachmentsChanged(false); - mAttachmentPreview.hideAttachmentPreview(); - } else { - mHost.onAttachmentsChanged(haveAttachments); - mAttachmentPreview.onAttachmentsChanged(mBinding.getData()); - } - } - - public void setInputManager(final ConversationInputManager inputManager) { - mInputManager = inputManager; - } - - public void setConversationDataModel(final ImmutableBindingRef refDataModel) { - mConversationDataModel = refDataModel; - mConversationDataModel.getData().addConversationDataListener(mDataListener); - } - - ImmutableBindingRef getDraftDataModel() { - return BindingBase.createBindingReference(mBinding); - } - - // returns true if it actually shows the subject editor and false if already showing - private boolean showSubjectEditor() { - // show the subject editor - if (mSubjectView.getVisibility() == View.GONE) { - mSubjectView.setVisibility(View.VISIBLE); - mSubjectView.requestFocus(); - return true; - } - return false; - } - - private void hideSubjectEditor() { - mSubjectView.setVisibility(View.GONE); - mComposeEditText.requestFocus(); - } - - /** - * {@inheritDoc} from TextView.OnEditorActionListener - */ - @Override // TextView.OnEditorActionListener.onEditorAction - public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { - if (actionId == EditorInfo.IME_ACTION_SEND) { - sendMessageInternal(true /* checkMessageSize */); - return true; - } - return false; - } - - private void sendMessageInternal(final boolean checkMessageSize) { - LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " + - mBinding.getData().getConversationId()); - if (mBinding.getData().isCheckingDraft()) { - // Don't send message if we are currently checking draft for sending. - LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft"); - return; - } - // Check the host for pre-conditions about any action. - if (mHost.isReadyForAction()) { - mInputManager.showHideSimSelector(false /* show */, true /* animate */); - final String messageToSend = mComposeEditText.getText().toString(); - mBinding.getData().setMessageText(messageToSend); - final String subject = mComposeSubjectText.getText().toString(); - mBinding.getData().setMessageSubject(subject); - // Asynchronously check the draft against various requirements before sending. - mBinding.getData().checkDraftForAction(checkMessageSize, - mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() { - @Override - public void onDraftChecked(DraftMessageData data, int result) { - mBinding.ensureBound(data); - switch (result) { - case CheckDraftForSendTask.RESULT_PASSED: - // Continue sending after check succeeded. - final MessageData message = mBinding.getData() - .prepareMessageForSending(mBinding); - if (message != null && message.hasContent()) { - playSentSound(); - mHost.sendMessage(message); - hideSubjectEditor(); - if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { - AccessibilityUtil.announceForAccessibilityCompat( - ComposeMessageView.this, null, - R.string.sending_message); - } - } - break; - - case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS: - // Cannot send while there's still attachment(s) being loaded. - UiUtils.showToastAtBottom( - R.string.cant_send_message_while_loading_attachments); - break; - - case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS: - mHost.promptForSelfPhoneNumber(); - break; - - case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT: - Assert.isTrue(checkMessageSize); - mHost.warnOfExceedingMessageLimit( - true /*sending*/, false /* tooManyVideos */); - break; - - case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED: - Assert.isTrue(checkMessageSize); - mHost.warnOfExceedingMessageLimit( - true /*sending*/, true /* tooManyVideos */); - break; - - case CheckDraftForSendTask.RESULT_SIM_NOT_READY: - // Cannot send if there is no active subscription - UiUtils.showToastAtBottom( - R.string.cant_send_message_without_active_subscription); - break; - - default: - break; - } - } - }, mBinding); - } else { - mHost.warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - sendMessageInternal(checkMessageSize); - } - - }); - } - } - - public static void playSentSound() { - // Check if this setting is enabled before playing - final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); - final Context context = Factory.get().getApplicationContext(); - final String prefKey = context.getString(R.string.send_sound_pref_key); - final boolean defaultValue = context.getResources().getBoolean( - R.bool.send_sound_pref_default); - if (!prefs.getBoolean(prefKey, defaultValue)) { - return; - } - MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */); - } - - /** - * {@inheritDoc} from DraftMessageDataListener - */ - @Override // From DraftMessageDataListener - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - // As this is called asynchronously when message read check bound before updating text - mBinding.ensureBound(data); - - // We have to cache the values of the DraftMessageData because when we set - // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged, - // which immediately reloads the text from the subject and message fields and replaces - // what's in the DraftMessageData. - - final String subject = data.getMessageSubject(); - final String message = data.getMessageText(); - - boolean hasAttachmentsChanged = false; - - if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) == - DraftMessageData.MESSAGE_SUBJECT_CHANGED) { - mComposeSubjectText.setText(subject); - - // Set the cursor selection to the end since setText resets it to the start - mComposeSubjectText.setSelection(mComposeSubjectText.getText().length()); - } - - if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) == - DraftMessageData.MESSAGE_TEXT_CHANGED) { - mComposeEditText.setText(message); - - // Set the cursor selection to the end since setText resets it to the start - mComposeEditText.setSelection(mComposeEditText.getText().length()); - } - - if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == - DraftMessageData.ATTACHMENTS_CHANGED) { - final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data); - mHost.onAttachmentsChanged(haveAttachments); - hasAttachmentsChanged = true; - } - - if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) { - updateOnSelfSubscriptionChange(); - } - updateVisualsOnDraftChanged(hasAttachmentsChanged); - } - - @Override // From DraftMessageDataListener - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - mBinding.ensureBound(data); - mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */); - } - - private void updateOnSelfSubscriptionChange() { - // Refresh the length filters according to the selected self's MmsConfig. - mComposeEditText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) - .getMaxTextLimit()) }); - mComposeSubjectText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) - .getMaxSubjectLength())}); - } - - @Override - public void onMediaItemsSelected(final Collection items) { - mBinding.getData().addAttachments(items); - announceMediaItemState(true /*isSelected*/); - } - - @Override - public void onMediaItemsUnselected(final MessagePartData item) { - mBinding.getData().removeAttachment(item); - announceMediaItemState(false /*isSelected*/); - } - - @Override - public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) { - mBinding.getData().addPendingAttachment(pendingItem, mBinding); - resumeComposeMessage(); - } - - private void announceMediaItemState(final boolean isSelected) { - final Resources res = getContext().getResources(); - final String announcement = isSelected ? res.getString( - R.string.mediapicker_gallery_item_selected_content_description) : - res.getString(R.string.mediapicker_gallery_item_unselected_content_description); - AccessibilityUtil.announceForAccessibilityCompat( - this, null, announcement); - } - - private void announceAttachmentState() { - if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { - int attachmentCount = mBinding.getData().getReadOnlyAttachments().size() - + mBinding.getData().getReadOnlyPendingAttachments().size(); - final String announcement = getContext().getResources().getQuantityString( - R.plurals.attachment_changed_accessibility_announcement, - attachmentCount, attachmentCount); - AccessibilityUtil.announceForAccessibilityCompat( - this, null, announcement); - } - } - - @Override - public void resumeComposeMessage() { - mComposeEditText.requestFocus(); - mInputManager.showHideImeKeyboard(true, true); - announceAttachmentState(); - } - - public void clearAttachments() { - mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags()); - mHost.onAttachmentsCleared(); - } - - public void requestDraftMessage(boolean clearLocalDraft) { - mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft); - } - - public void setDraftMessage(final MessageData message) { - mBinding.getData().loadFromStorage(mBinding, message, false); - } - - public void writeDraftMessage() { - final String messageText = mComposeEditText.getText().toString(); - mBinding.getData().setMessageText(messageText); - - final String subject = mComposeSubjectText.getText().toString(); - mBinding.getData().setMessageSubject(subject); - - mBinding.getData().saveToStorage(mBinding); - } - - private void updateConversationSelfId(final String selfId, final boolean notify) { - mBinding.getData().setSelfId(selfId, notify); - } - - private Uri getSelfSendButtonIconUri() { - final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); - if (overridenSelfUri != null) { - return overridenSelfUri; - } - final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry(); - - if (subscriptionListEntry != null) { - return subscriptionListEntry.selectedIconUri; - } - - // Fall back to default self-avatar in the base case. - final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant(); - return self == null ? null : AvatarUriUtil.createAvatarUri(self); - } - - private SubscriptionListEntry getSelfSubscriptionListEntry() { - return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( - mBinding.getData().getSelfId(), false /* excludeDefault */); - } - - private boolean isDataLoadedForMessageSend() { - // Check data loading prerequisites for sending a message. - return mConversationDataModel != null && mConversationDataModel.isBound() && - mConversationDataModel.getData().getParticipantsLoaded(); - } - - private static class AsyncUpdateMessageBodySizeTask - extends SafeAsyncTask, Void, Long> { - - private final Context mContext; - private final TextView mSizeTextView; - - public AsyncUpdateMessageBodySizeTask(final Context context, final TextView tv) { - mContext = context; - mSizeTextView = tv; - } - - @Override - protected Long doInBackgroundTimed(final List... params) { - final List attachments = params[0]; - long totalSize = 0; - for (final MessagePartData attachment : attachments) { - final Uri contentUri = attachment.getContentUri(); - if (contentUri != null) { - totalSize += UriUtil.getContentSize(attachment.getContentUri()); - } - } - return totalSize; - } - - @Override - protected void onPostExecute(Long size) { - if (mSizeTextView != null) { - mSizeTextView.setText(Formatter.formatFileSize(mContext, size)); - mSizeTextView.setVisibility(View.VISIBLE); - } - } - } - - private void updateVisualsOnDraftChanged() { - updateVisualsOnDraftChanged(false); - } - - private void updateVisualsOnDraftChanged(boolean hasAttachmentsChanged) { - final String messageText = mComposeEditText.getText().toString(); - final DraftMessageData draftMessageData = mBinding.getData(); - draftMessageData.setMessageText(messageText); - - final String subject = mComposeSubjectText.getText().toString(); - draftMessageData.setMessageSubject(subject); - if (!TextUtils.isEmpty(subject)) { - mSubjectView.setVisibility(View.VISIBLE); - } - - final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0); - final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0); - final boolean hasWorkingDraft = hasMessageText || hasSubject || - mBinding.getData().hasAttachments(); - - final List attachments = - new ArrayList(draftMessageData.getReadOnlyAttachments()); - if (draftMessageData.getIsMms()) { // MMS case - if (draftMessageData.hasAttachments()) { - if (hasAttachmentsChanged) { - // Calculate message attachments size and show it. - new AsyncUpdateMessageBodySizeTask(getContext(), mMessageBodySize) - .executeOnThreadPool(attachments, null, null); - } else { - // No update. Just show previous size. - mMessageBodySize.setVisibility(View.VISIBLE); - } - } else { - mMessageBodySize.setVisibility(View.INVISIBLE); - } - } else { // SMS case - // Update the SMS text counter. - final int messageCount = draftMessageData.getNumMessagesToBeSent(); - final int codePointsRemaining = - draftMessageData.getCodePointsRemainingInCurrentMessage(); - // Show the counter only if we are going to send more than one message OR we are getting - // close. - if (messageCount > 1 - || codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN) { - // Update the remaining characters and number of messages required. - final String counterText = - messageCount > 1 - ? codePointsRemaining + " / " + messageCount - : String.valueOf(codePointsRemaining); - mMessageBodySize.setText(counterText); - mMessageBodySize.setVisibility(View.VISIBLE); - } else { - mMessageBodySize.setVisibility(View.INVISIBLE); - } - } - - // Update the send message button. Self icon uri might be null if self participant data - // and/or conversation metadata hasn't been loaded by the host. - final Uri selfSendButtonUri = getSelfSendButtonIconUri(); - int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; - if (selfSendButtonUri != null) { - if (hasWorkingDraft && isDataLoadedForMessageSend()) { - UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null); - if (isOverriddenAvatarAGroup()) { - // If the host has overriden the avatar to show a group avatar where the - // send button sits, we have to hide the group avatar because it can be larger - // than the send button and pieces of the avatar will stick out from behind - // the send button. - UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null); - } - mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE); - sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON; - } else { - mSelfSendIcon.setImageResourceUri(selfSendButtonUri); - if (isOverriddenAvatarAGroup()) { - UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null); - } - UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null); - mMmsIndicator.setVisibility(INVISIBLE); - if (shouldShowSimSelector(mConversationDataModel.getData())) { - sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR; - } - } - } else { - mSelfSendIcon.setImageResourceUri(null); - } - - if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) { - setSendButtonAccessibility(sendWidgetMode); - mSendWidgetMode = sendWidgetMode; - } - - // Update the text hint on the message box depending on the attachment type. - final int attachmentCount = attachments.size(); - if (attachmentCount == 0) { - final SubscriptionListEntry subscriptionListEntry = - mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( - mBinding.getData().getSelfId(), false /* excludeDefault */); - if (subscriptionListEntry == null) { - mComposeEditText.setHint(R.string.compose_message_view_hint_text); - } else { - mComposeEditText.setHint(Html.fromHtml(getResources().getString( - R.string.compose_message_view_hint_text_multi_sim, - subscriptionListEntry.displayName))); - } - } else { - int type = -1; - for (final MessagePartData attachment : attachments) { - int newType; - if (attachment.isImage()) { - newType = ContentType.TYPE_IMAGE; - } else if (attachment.isAudio()) { - newType = ContentType.TYPE_AUDIO; - } else if (attachment.isVideo()) { - newType = ContentType.TYPE_VIDEO; - } else if (attachment.isVCard()) { - newType = ContentType.TYPE_VCARD; - } else { - newType = ContentType.TYPE_OTHER; - } - - if (type == -1) { - type = newType; - } else if (type != newType || type == ContentType.TYPE_OTHER) { - type = ContentType.TYPE_OTHER; - break; - } - } - - switch (type) { - case ContentType.TYPE_IMAGE: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_photo, attachmentCount)); - break; - - case ContentType.TYPE_AUDIO: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_audio, attachmentCount)); - break; - - case ContentType.TYPE_VIDEO: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_video, attachmentCount)); - break; - - case ContentType.TYPE_VCARD: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_vcard, attachmentCount)); - break; - - case ContentType.TYPE_OTHER: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_attachments, attachmentCount)); - break; - - default: - Assert.fail("Unsupported attachment type!"); - break; - } - } - } - - private void setSendButtonAccessibility(final int sendWidgetMode) { - switch (sendWidgetMode) { - case SEND_WIDGET_MODE_SELF_AVATAR: - // No send button and no SIM selector; the self send button is no longer - // important for accessibility. - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mSelfSendIcon.setContentDescription(null); - mSendButton.setVisibility(View.GONE); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR); - break; - - case SEND_WIDGET_MODE_SIM_SELECTOR: - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mSelfSendIcon.setContentDescription(getSimContentDescription()); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR); - break; - - case SEND_WIDGET_MODE_SEND_BUTTON: - mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mMmsIndicator.setContentDescription(null); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON); - break; - } - } - - private String getSimContentDescription() { - final SubscriptionListEntry sub = getSelfSubscriptionListEntry(); - if (sub != null) { - return getResources().getString( - R.string.sim_selector_button_content_description_with_selection, - sub.displayName); - } else { - return getResources().getString( - R.string.sim_selector_button_content_description); - } - } - - // Set accessibility traversal order of the components in the send widget. - private void setSendWidgetAccessibilityTraversalOrder(final int mode) { - mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text); - switch (mode) { - case SEND_WIDGET_MODE_SIM_SELECTOR: - mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon); - break; - case SEND_WIDGET_MODE_SEND_BUTTON: - mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button); - break; - default: - break; - } - } - - @Override - public void afterTextChanged(final Editable editable) { - } - - @Override - public void beforeTextChanged(final CharSequence s, final int start, final int count, - final int after) { - if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - hideSimSelector(); - } - } - - private void hideSimSelector() { - if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) { - // Now that the sim selector has been hidden, reshow the attachments if they - // have been hidden. - hideAttachmentsWhenShowingSims(false /*simPickerVisible*/); - } - } - - @Override - public void onTextChanged(final CharSequence s, final int start, final int before, - final int count) { - final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity) - ? (BugleActionBarActivity) mOriginalContext : null; - if (activity != null && activity.getIsDestroyed()) { - LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy"); - - // if we get onTextChanged after the activity is destroyed then, ah, wtf - // b/18176615 - // This appears to have occurred as the result of orientation change. - return; - } - - mBinding.ensureBound(); - updateVisualsOnDraftChanged(); - } - - @Override - public PlainTextEditText getComposeEditText() { - return mComposeEditText; - } - - public void displayPhoto(final Uri photoUri, final Rect imageBounds) { - mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */); - } - - public void updateConversationSelfIdOnExternalChange(final String selfId) { - updateConversationSelfId(selfId, true /* notify */); - } - - /** - * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e. - * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source - * of truth for conversation self id since it reflects any pending self id change the user - * makes in the UI. - */ - public String getConversationSelfId() { - return mBinding.getData().getSelfId(); - } - - public void selectSim(SubscriptionListEntry subscriptionData) { - final String oldSelfId = getConversationSelfId(); - final String newSelfId = subscriptionData.selfParticipantId; - Assert.notNull(newSelfId); - // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed. - if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) { - return; - } - updateConversationSelfId(newSelfId, true /* notify */); - } - - public void hideAllComposeInputs(final boolean animate) { - mInputManager.hideAllInputs(animate); - } - - public void saveInputState(final Bundle outState) { - mInputManager.onSaveInputState(outState); - } - - public void resetMediaPickerState() { - mInputManager.resetMediaPickerState(); - } - - public boolean onBackPressed() { - return mInputManager.onBackPressed(); - } - - public boolean onNavigationUpPressed() { - return mInputManager.onNavigationUpPressed(); - } - - public boolean updateActionBar(final ActionBar actionBar) { - return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false; - } - - public static boolean shouldShowSimSelector(final ConversationData convData) { - return convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1; - } - - public void sendMessageIgnoreMessageSizeLimit() { - sendMessageInternal(false /* checkMessageSize */); - } - - public void onAttachmentPreviewLongClicked() { - mHost.showAttachmentChooser(); - } - - @Override - public void onDraftAttachmentLoadFailed() { - mHost.notifyOfAttachmentLoadFailed(); - } - - private boolean isOverriddenAvatarAGroup() { - final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); - if (overridenSelfUri == null) { - return false; - } - return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri)); - } - - @Override - public void setAccessibility(boolean enabled) { - if (enabled) { - mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - setSendButtonAccessibility(mSendWidgetMode); - } else { - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java deleted file mode 100644 index 8c351c77..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationActivity.java +++ /dev/null @@ -1,381 +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.ui.conversation; - -import android.content.Intent; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.MenuItem; - -import com.android.messaging.R; -import com.android.messaging.datamodel.MessagingContentProvider; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.contact.ContactPickerFragment; -import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost; -import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost; -import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost; -import com.android.messaging.ui.conversationlist.ConversationListActivity; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.UiUtils; - -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; - -public class ConversationActivity extends BugleActionBarActivity - implements ContactPickerFragmentHost, ConversationFragmentHost, - ConversationActivityUiStateHost { - public static final int FINISH_RESULT_CODE = 1; - private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate"; - - private ConversationActivityUiState mUiState; - - // Fragment transactions cannot be performed after onSaveInstanceState() has been called since - // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's - // dangerous. Therefore, we note when instance state is saved and avoid performing UI state - // updates concerning fragments past that point. - private boolean mInstanceStateSaved; - - // Tracks whether onPause is called. - private boolean mIsPaused; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.conversation_activity); - - final Intent intent = getIntent(); - - // Do our best to restore UI state from saved instance state. - if (savedInstanceState != null) { - mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY); - } else { - if (intent. - getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) { - // See the comment in BugleWidgetService.getViewMoreConversationsView() why this - // is unfortunately necessary. The Bugle desktop widget can display a list of - // conversations. When there are more conversations that can be displayed in - // the widget, the last item is a "More conversations" item. The way widgets - // are built, the list items can only go to a single fill-in intent which points - // to this ConversationActivity. When the user taps on "More conversations", we - // really want to go to the ConversationList. This code makes that possible. - finish(); - final Intent convListIntent = new Intent(this, ConversationListActivity.class); - convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(convListIntent); - return; - } - } - - // If saved instance state doesn't offer a clue, get the info from the intent. - if (mUiState == null) { - final String conversationId = intent.getStringExtra( - UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); - mUiState = new ConversationActivityUiState(conversationId); - } - mUiState.setHost(this); - mInstanceStateSaved = false; - - // Don't animate UI state change for initial setup. - updateUiState(false /* animate */); - - // See if we're getting called from a widget to directly display an image or video - final String extraToDisplay = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI); - if (!TextUtils.isEmpty(extraToDisplay)) { - final String contentType = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE); - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen( - findViewById(R.id.conversation_and_compose_container)); - if (ContentType.isImageType(contentType)) { - final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri( - mUiState.getConversationId()); - UIIntents.get().launchFullScreenPhotoViewer( - this, Uri.parse(extraToDisplay), bounds, imagesUri); - } else if (ContentType.isVideoType(contentType)) { - UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay)); - } - } - } - - @Override - protected void onSaveInstanceState(final Bundle outState) { - super.onSaveInstanceState(outState); - // After onSaveInstanceState() is called, future changes to mUiState won't update the UI - // anymore, because fragment transactions are not allowed past this point. - // For an activity recreation due to orientation change, the saved instance state keeps - // using the in-memory copy of the UI state instead of writing it to parcel as an - // optimization, so the UI state values may still change in response to, for example, - // focus change from the framework, making mUiState and actual UI inconsistent. - // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the - // restored UI state ALWAYS matches the actual restored UI components. - outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone()); - mInstanceStateSaved = true; - } - - @Override - protected void onResume() { - super.onResume(); - - // we need to reset the mInstanceStateSaved flag since we may have just been restored from - // a previous onStop() instead of an onDestroy(). - mInstanceStateSaved = false; - mIsPaused = false; - } - - @Override - protected void onPause() { - super.onPause(); - mIsPaused = true; - } - - @Override - public void onWindowFocusChanged(final boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - final ConversationFragment conversationFragment = getConversationFragment(); - // When the screen is turned on, the last used activity gets resumed, but it gets - // window focus only after the lock screen is unlocked. - if (hasFocus && conversationFragment != null) { - conversationFragment.setConversationFocus(); - } - } - - @Override - public void onDisplayHeightChanged(final int heightSpecification) { - super.onDisplayHeightChanged(heightSpecification); - invalidateActionBar(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (mUiState != null) { - mUiState.setHost(null); - } - } - - @Override - public void updateActionBar(final ActionBar actionBar) { - super.updateActionBar(actionBar); - final ConversationFragment conversation = getConversationFragment(); - final ContactPickerFragment contactPicker = getContactPicker(); - if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) { - contactPicker.updateActionBar(actionBar); - } else if (conversation != null && mUiState.shouldShowConversationFragment()) { - conversation.updateActionBar(actionBar); - } - - if (isLaunchedFromBubble()) { - actionBar.setHomeButtonEnabled(false); - actionBar.setDisplayHomeAsUpEnabled(false); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem menuItem) { - if (super.onOptionsItemSelected(menuItem)) { - return true; - } - if (menuItem.getItemId() == android.R.id.home) { - onNavigationUpPressed(); - return true; - } - return false; - } - - public void onNavigationUpPressed() { - // Let the conversation fragment handle the navigation up press. - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) { - return; - } - onFinishCurrentConversation(); - } - - @Override - public void onBackPressed() { - // If action mode is active dismiss it - if (getActionMode() != null) { - dismissActionMode(); - return; - } - - // Let the conversation fragment handle the back press. - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null && conversationFragment.onBackPressed()) { - return; - } - super.onBackPressed(); - } - - private ContactPickerFragment getContactPicker() { - return (ContactPickerFragment) getSupportFragmentManager().findFragmentByTag( - ContactPickerFragment.FRAGMENT_TAG); - } - - private ConversationFragment getConversationFragment() { - return (ConversationFragment) getSupportFragmentManager().findFragmentByTag( - ConversationFragment.FRAGMENT_TAG); - } - - @Override // From ContactPickerFragmentHost - public void onGetOrCreateNewConversation(final String conversationId) { - Assert.isTrue(conversationId != null); - mUiState.onGetOrCreateConversation(conversationId); - } - - @Override // From ContactPickerFragmentHost - public void onBackButtonPressed() { - onBackPressed(); - } - - @Override // From ContactPickerFragmentHost - public void onInitiateAddMoreParticipants() { - mUiState.onAddMoreParticipants(); - } - - - @Override - public void onParticipantCountChanged(final boolean canAddMoreParticipants) { - mUiState.onParticipantCountUpdated(canAddMoreParticipants); - } - - @Override // From ConversationFragmentHost - public void onStartComposeMessage() { - mUiState.onStartMessageCompose(); - } - - @Override // From ConversationFragmentHost - public void onConversationMetadataUpdated() { - invalidateActionBar(); - } - - @Override // From ConversationFragmentHost - public void onConversationMessagesUpdated(final int numberOfMessages) { - } - - @Override // From ConversationFragmentHost - public void onConversationParticipantDataLoaded(final int numberOfParticipants) { - } - - @Override // From ConversationFragmentHost - public boolean isActiveAndFocused() { - return !mIsPaused && hasWindowFocus(); - } - - @Override // From ConversationActivityUiStateListener - public void onConversationContactPickerUiStateChanged(final int oldState, final int newState, - final boolean animate) { - Assert.isTrue(oldState != newState); - updateUiState(animate); - } - - private void updateUiState(final boolean animate) { - if (mInstanceStateSaved || mIsPaused) { - return; - } - Assert.notNull(mUiState); - final Intent intent = getIntent(); - final String conversationId = mUiState.getConversationId(); - - final FragmentManager fragmentManager = getSupportFragmentManager(); - final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); - - final boolean needConversationFragment = mUiState.shouldShowConversationFragment(); - final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment(); - ConversationFragment conversationFragment = getConversationFragment(); - - // Set up the conversation fragment. - if (needConversationFragment) { - Assert.notNull(conversationId); - if (conversationFragment == null) { - conversationFragment = new ConversationFragment(); - fragmentTransaction.add(R.id.conversation_fragment_container, - conversationFragment, ConversationFragment.FRAGMENT_TAG); - } - final MessageData draftData = intent.getParcelableExtra( - UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); - if (!needContactPickerFragment) { - // Once the user has committed the audience,remove the draft data from the - // intent to prevent reuse - intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); - } - conversationFragment.setHost(this); - conversationFragment.setConversationInfo(this, conversationId, draftData); - } else if (conversationFragment != null) { - // Don't save draft to DB when removing conversation fragment and switching to - // contact picking mode. The draft is intended for the new group. - conversationFragment.suppressWriteDraft(); - fragmentTransaction.remove(conversationFragment); - } - - // Set up the contact picker fragment. - ContactPickerFragment contactPickerFragment = getContactPicker(); - if (needContactPickerFragment) { - if (contactPickerFragment == null) { - contactPickerFragment = new ContactPickerFragment(); - fragmentTransaction.add(R.id.contact_picker_fragment_container, - contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG); - } - contactPickerFragment.setHost(this); - contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(), - animate); - } else if (contactPickerFragment != null) { - fragmentTransaction.remove(contactPickerFragment); - } - - fragmentTransaction.commit(); - invalidateActionBar(); - } - - @Override - public void onFinishCurrentConversation() { - // Simply finish the current activity. The current design is to leave any empty - // conversations as is. - finishAfterTransition(); - } - - @Override - public boolean shouldResumeComposeMessage() { - return mUiState.shouldResumeComposeMessage(); - } - - @SuppressWarnings("MissingSuperCall") // TODO: fix me - @Override - protected void onActivityResult(final int requestCode, final int resultCode, - final Intent data) { - if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS && - resultCode == RESULT_OK) { - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null) { - conversationFragment.onAttachmentChoosen(); - } else { - LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " + - "AttachmentChooserActivity!"); - } - } else if (resultCode == FINISH_RESULT_CODE) { - finish(); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/ConversationActivity.kt new file mode 100644 index 00000000..6db97658 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationActivity.kt @@ -0,0 +1,134 @@ +package com.android.messaging.ui.conversation + +import android.content.Intent +import android.os.Bundle +import android.text.TextUtils +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.navigation.ConversationNavGraph +import com.android.messaging.ui.conversationlist.ConversationListActivity +import com.android.messaging.ui.core.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class ConversationActivity : ComponentActivity() { + + private var launchGeneration = 0 + private var launchRequest: ConversationEntryLaunchRequest? by mutableStateOf(value = null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + launchGeneration = savedInstanceState?.getInt(LAUNCH_GENERATION_STATE_KEY) ?: 0 + + if (applyIntent(intent = intent, launchGeneration = launchGeneration)) { + return + } + + enableEdgeToEdge() + + setContent { + AppTheme { + ConversationNavGraph( + launchRequest = launchRequest, + onConversationDetailsClick = ::launchConversationDetails, + onFinish = ::finishAfterTransition, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + launchGeneration += 1 + applyIntent(intent = intent, launchGeneration = launchGeneration) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putInt(LAUNCH_GENERATION_STATE_KEY, launchGeneration) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode == FINISH_RESULT_CODE) { + finish() + } + } + + private fun applyIntent( + intent: Intent, + launchGeneration: Int, + ): Boolean { + setIntent(intent) + + val goToConversationList = intent.getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, + false, + ) + + if (goToConversationList) { + redirectToConversationList() + return true + } + + launchRequest = ConversationEntryLaunchRequest( + launchGeneration = launchGeneration, + conversationId = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID), + draftData = intent.getParcelableExtra( + UIIntents.UI_INTENT_EXTRA_DRAFT_DATA, + MessageData::class.java, + ), + startupAttachmentUri = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI) + ?.takeUnless(TextUtils::isEmpty), + startupAttachmentType = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE) + ?.takeUnless(TextUtils::isEmpty), + messagePosition = intent + .getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1) + .takeIf { position -> position >= 0 }, + isLaunchedFromBubble = isLaunchedFromBubble, + ) + + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA) + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION) + + return false + } + + private fun redirectToConversationList() { + finish() + + Intent(this, ConversationListActivity::class.java) + .apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + } + .let(::startActivity) + } + + private fun launchConversationDetails(conversationId: String) { + UIIntents.get().launchPeopleAndOptionsActivity( + this, + conversationId, + ) + } + + companion object { + const val FINISH_RESULT_CODE: Int = 1 + + private const val LAUNCH_GENERATION_STATE_KEY = "launch_generation" + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java deleted file mode 100644 index 1469c939..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java +++ /dev/null @@ -1,306 +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.ui.conversation; - -import android.os.Parcel; -import android.os.Parcelable; - -import com.android.messaging.ui.contact.ContactPickerFragment; -import com.android.messaging.util.Assert; -import com.google.common.annotations.VisibleForTesting; - -/** - * Keeps track of the different UI states that the ConversationActivity may be in. This acts as - * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the - * ConversationActivity about any state UI change so it can update the visuals. This class - * implements Parcelable and it's persisted across activity tear down and relaunch. - */ -public class ConversationActivityUiState implements Parcelable, Cloneable { - interface ConversationActivityUiStateHost { - void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate); - } - - /*------ Overall UI states (conversation & contact picker) ------*/ - - /** Only a full screen conversation is showing. */ - public static final int STATE_CONVERSATION_ONLY = 1; - /** Only a full screen contact picker is showing asking user to pick the initial contact. */ - public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2; - /** - * Only a full screen contact picker is showing asking user to pick more participants. This - * happens after the user picked the initial contact, and then decide to go back and add more. - */ - public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3; - /** - * Only a full screen contact picker is showing asking user to pick more participants. However - * user has reached max number of conversation participants and can add no more. - */ - public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4; - /** - * A hybrid mode where the conversation view + contact chips view are showing. This happens - * right after the user picked the initial contact for which a 1-1 conversation is fetched or - * created. - */ - public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5; - - // The overall UI state of the ConversationActivity. - private int mConversationContactUiState; - - // The currently displayed conversation (if any). - private String mConversationId; - - // Indicates whether we should put focus in the compose message view when the - // ConversationFragment is attached. This is a transient state that's not persisted as - // part of the parcelable. - private boolean mPendingResumeComposeMessage = false; - - // The owner ConversationActivity. This is not parceled since the instance always change upon - // object reuse. - private ConversationActivityUiStateHost mHost; - - // Indicates the owning ConverastionActivity is in the process of updating its UI presentation - // to be in sync with the UI states. Outside of the UI updates, the UI states here should - // ALWAYS be consistent with the actual states of the activity. - private int mUiUpdateCount; - - /** - * Create a new instance with an initial conversation id. - */ - ConversationActivityUiState(final String conversationId) { - // The conversation activity may be initialized with only one of two states: - // Conversation-only (when there's a conversation id) or picking initial contact - // (when no conversation id is given). - mConversationId = conversationId; - mConversationContactUiState = conversationId == null ? - STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY; - } - - public void setHost(final ConversationActivityUiStateHost host) { - mHost = host; - } - - public boolean shouldShowConversationFragment() { - return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW || - mConversationContactUiState == STATE_CONVERSATION_ONLY; - } - - public boolean shouldShowContactPickerFragment() { - return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT || - mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; - } - - /** - * Returns whether there's a pending request to resume message compose (i.e. set focus to - * the compose message view and show the soft keyboard). If so, this request will be served - * when the conversation fragment get created and resumed. This happens when the user commits - * participant selection for a group conversation and goes back to the conversation fragment. - * Since conversation fragment creation happens asynchronously, we issue and track this - * pending request for it to be eventually fulfilled. - */ - public boolean shouldResumeComposeMessage() { - if (mPendingResumeComposeMessage) { - // This is a one-shot operation that just keeps track of the pending resume compose - // state. This is also a non-critical operation so we don't care about failure case. - mPendingResumeComposeMessage = false; - return true; - } - return false; - } - - public int getDesiredContactPickingMode() { - switch (mConversationContactUiState) { - case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS: - return ContactPickerFragment.MODE_PICK_MORE_CONTACTS; - case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS: - return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS; - case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT: - return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT; - case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW: - return ContactPickerFragment.MODE_CHIPS_ONLY; - default: - Assert.fail("Invalid contact picking mode for ConversationActivity!"); - return ContactPickerFragment.MODE_UNDEFINED; - } - } - - public String getConversationId() { - return mConversationId; - } - - /** - * Called whenever the contact picker fragment successfully fetched or created a conversation. - */ - public void onGetOrCreateConversation(final String conversationId) { - int newState = STATE_CONVERSATION_ONLY; - if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) { - newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; - } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) { - newState = STATE_CONVERSATION_ONLY; - } else { - // New conversation should only be created when we are in one of the contact picking - // modes. - Assert.fail("Invalid conversation activity state: can't create conversation!"); - } - mConversationId = conversationId; - performUiStateUpdate(newState, true); - } - - /** - * Called when the user started composing message. If we are in the hybrid chips state, we - * should commit to enter the conversation only state. - */ - public void onStartMessageCompose() { - // This cannot happen when we are in one of the full-screen contact picking states. - Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT && - mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS && - mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS); - if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { - performUiStateUpdate(STATE_CONVERSATION_ONLY, true); - } - } - - /** - * Called when the user initiated an action to add more participants in the hybrid state, - * namely clicking on the "add more participants" button or entered a new contact chip via - * auto-complete. - */ - public void onAddMoreParticipants() { - if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { - mPendingResumeComposeMessage = true; - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true); - } else { - // This is only possible in the hybrid state. - Assert.fail("Invalid conversation activity state: can't add more participants!"); - } - } - - /** - * Called each time the number of participants is updated to check against the limit and - * update the ui state accordingly. - */ - public void onParticipantCountUpdated(final boolean canAddMoreParticipants) { - if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS - && !canAddMoreParticipants) { - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false); - } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS - && canAddMoreParticipants) { - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false); - } - } - - private void performUiStateUpdate(final int conversationContactState, final boolean animate) { - // This starts one UI update cycle, during which we allow the conversation activity's - // UI presentation to be temporarily out of sync with the states here. - beginUiUpdate(); - - if (conversationContactState != mConversationContactUiState) { - final int oldState = mConversationContactUiState; - mConversationContactUiState = conversationContactState; - notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate); - } - endUiUpdate(); - } - - private void notifyOnOverallUiStateChanged( - final int oldState, final int newState, final boolean animate) { - // Always verify state validity whenever we have a state change. - assertValidState(); - Assert.isTrue(isUiUpdateInProgress()); - - // Only do this if we are still attached to the host. mHost can be null if the host - // activity is already destroyed, but due to timing the contained UI components may still - // receive events such as focus change and trigger a callback to the Ui state. We'd like - // to guard against those cases. - if (mHost != null) { - mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate); - } - } - - private void assertValidState() { - // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to - // start a conversation. - Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) == - (mConversationId == null)); - } - - private void beginUiUpdate() { - mUiUpdateCount++; - } - - private void endUiUpdate() { - if (--mUiUpdateCount < 0) { - Assert.fail("Unbalanced Ui updates!"); - } - } - - private boolean isUiUpdateInProgress() { - return mUiUpdateCount > 0; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(final Parcel dest, final int flags) { - dest.writeInt(mConversationContactUiState); - dest.writeString(mConversationId); - } - - private ConversationActivityUiState(final Parcel in) { - mConversationContactUiState = in.readInt(); - mConversationId = in.readString(); - - // Always verify state validity whenever we initialize states. - assertValidState(); - } - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public ConversationActivityUiState createFromParcel(final Parcel in) { - return new ConversationActivityUiState(in); - } - - @Override - public ConversationActivityUiState[] newArray(final int size) { - return new ConversationActivityUiState[size]; - } - }; - - @Override - protected ConversationActivityUiState clone() { - try { - return (ConversationActivityUiState) super.clone(); - } catch (CloneNotSupportedException e) { - Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " + - "reference?"); - } - return null; - } - - /** - * allows for overridding the internal UI state. Should never be called except by test code. - */ - @VisibleForTesting - void testSetUiState(final int uiState) { - mConversationContactUiState = uiState; - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java deleted file mode 100644 index b836172b..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java +++ /dev/null @@ -1,479 +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.ui.conversation; - -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Rect; -import android.graphics.drawable.StateListDrawable; -import android.os.Handler; -import android.util.StateSet; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.View.MeasureSpec; -import android.view.View.OnLayoutChangeListener; -import android.view.ViewGroupOverlay; -import android.widget.ImageView; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.util.Dates; - -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -/** - * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within - * the conversation and allows quickly moving to another position by dragging the scrollbar thumb - * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the - * date/time of the first visible message at the current position. - */ -public class ConversationFastScroller extends RecyclerView.OnScrollListener implements - OnLayoutChangeListener, RecyclerView.OnItemTouchListener { - - /** - * Creates a {@link ConversationFastScroller} instance, attached to the provided - * {@link RecyclerView}. - * - * @param rv the conversation RecyclerView - * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or - * {@code POSITION_LEFT_SIDE}) - * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported - * (the feature requires Jellybean MR2 or newer) - */ - public static ConversationFastScroller addTo(RecyclerView rv, int position) { - return new ConversationFastScroller(rv, position); - } - - public static final int POSITION_RIGHT_SIDE = 0; - public static final int POSITION_LEFT_SIDE = 1; - - private static final int MIN_PAGES_TO_ENABLE = 7; - private static final int SHOW_ANIMATION_DURATION_MS = 150; - private static final int HIDE_ANIMATION_DURATION_MS = 300; - private static final int HIDE_DELAY_MS = 1500; - - private final Context mContext; - private final RecyclerView mRv; - private final ViewGroupOverlay mOverlay; - private final ImageView mTrackImageView; - private final ImageView mThumbImageView; - private final TextView mPreviewTextView; - - private final int mTrackWidth; - private final int mThumbHeight; - private final int mPreviewHeight; - private final int mPreviewMinWidth; - private final int mPreviewMarginTop; - private final int mPreviewMarginLeftRight; - private final int mTouchSlop; - - private final Rect mContainer = new Rect(); - private final Handler mHandler = new Handler(); - - // Whether to render the scrollbar on the right side (otherwise it'll be on the left). - private final boolean mPosRight; - - // Whether the scrollbar is currently visible (it may still be animating). - private boolean mVisible = false; - - // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). - private boolean mPendingHide = false; - - // Whether the user is currently dragging the thumb up or down. - private boolean mDragging = false; - - // Animations responsible for hiding the scrollbar & preview. May be null. - private AnimatorSet mHideAnimation; - private ObjectAnimator mHidePreviewAnimation; - - private final Runnable mHideTrackRunnable = new Runnable() { - @Override - public void run() { - hide(true /* animate */); - mPendingHide = false; - } - }; - - private ConversationFastScroller(RecyclerView rv, int position) { - mContext = rv.getContext(); - mRv = rv; - mRv.addOnLayoutChangeListener(this); - mRv.addOnScrollListener(this); - mRv.addOnItemTouchListener(this); - mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { - @Override - public void onChanged() { - updateScrollPos(); - } - }); - mPosRight = (position == POSITION_RIGHT_SIDE); - - // Cache the dimensions we'll need during layout - final Resources res = mContext.getResources(); - mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); - mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); - mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); - mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); - mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); - mPreviewMarginLeftRight = res.getDimensionPixelOffset( - R.dimen.fastscroll_preview_margin_left_right); - mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); - - final LayoutInflater inflator = LayoutInflater.from(mContext); - mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); - mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); - mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); - - refreshConversationThemeColor(); - - // Add the fast scroll views to the overlay, so they are rendered above the list - mOverlay = rv.getOverlay(); - mOverlay.add(mTrackImageView); - mOverlay.add(mThumbImageView); - mOverlay.add(mPreviewTextView); - - hide(false /* animate */); - mPreviewTextView.setAlpha(0f); - } - - public void refreshConversationThemeColor() { - mPreviewTextView.setBackground( - ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); - - final StateListDrawable drawable = new StateListDrawable(); - drawable.addState(new int[]{ android.R.attr.state_pressed }, - ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); - drawable.addState(StateSet.WILD_CARD, - ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); - mThumbImageView.setImageDrawable(drawable); - } - - @Override - public void onScrollStateChanged(final RecyclerView view, final int newState) { - if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - // Only show the scrollbar once the user starts scrolling - if (!mVisible && isEnabled()) { - show(); - } - cancelAnyPendingHide(); - } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { - // Hide the scrollbar again after scrolling stops - hideAfterDelay(); - } - } - - private boolean isEnabled() { - final int range = mRv.computeVerticalScrollRange(); - final int extent = mRv.computeVerticalScrollExtent(); - - if (range == 0 || extent == 0) { - return false; // Conversation isn't long enough to scroll - } - // Only enable scrollbars for conversations long enough that they would require several - // flings to scroll through. - final float pages = (float) range / extent; - return (pages > MIN_PAGES_TO_ENABLE); - } - - private void show() { - if (mHideAnimation != null && mHideAnimation.isRunning()) { - mHideAnimation.cancel(); - } - // Slide the scrollbar in from the side - ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); - ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); - AnimatorSet animation = new AnimatorSet(); - animation.playTogether(trackSlide, thumbSlide); - animation.setDuration(SHOW_ANIMATION_DURATION_MS); - animation.start(); - - mVisible = true; - updateScrollPos(); - } - - private void hideAfterDelay() { - cancelAnyPendingHide(); - mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); - mPendingHide = true; - } - - private void cancelAnyPendingHide() { - if (mPendingHide) { - mHandler.removeCallbacks(mHideTrackRunnable); - } - } - - private void hide(boolean animate) { - final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; - if (animate) { - // Slide the scrollbar off to the side - ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, - hiddenTranslationX); - ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, - hiddenTranslationX); - mHideAnimation = new AnimatorSet(); - mHideAnimation.playTogether(trackSlide, thumbSlide); - mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); - mHideAnimation.start(); - } else { - mTrackImageView.setTranslationX(hiddenTranslationX); - mThumbImageView.setTranslationX(hiddenTranslationX); - } - - mVisible = false; - } - - private void showPreview() { - if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { - mHidePreviewAnimation.cancel(); - } - mPreviewTextView.setAlpha(1f); - } - - private void hidePreview() { - mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); - mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); - mHidePreviewAnimation.start(); - } - - @Override - public void onScrolled(final RecyclerView view, final int dx, final int dy) { - updateScrollPos(); - } - - private void updateScrollPos() { - if (!mVisible) { - return; - } - final int verticalScrollLength = mContainer.height() - mThumbHeight; - final int verticalScrollStart = mContainer.top + mThumbHeight / 2; - - final float scrollRatio = computeScrollRatio(); - final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); - layoutThumb(thumbCenterY); - - if (mDragging) { - updatePreviewText(); - layoutPreview(thumbCenterY); - } - } - - /** - * Returns the current position in the conversation, as a value between 0 and 1, inclusive. - * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. - */ - private float computeScrollRatio() { - final int range = mRv.computeVerticalScrollRange(); - final int extent = mRv.computeVerticalScrollExtent(); - int offset = mRv.computeVerticalScrollOffset(); - - if (range == 0 || extent == 0) { - // If the conversation doesn't scroll, we're at the bottom. - return 1.0f; - } - final int scrollRange = range - extent; - offset = Math.min(offset, scrollRange); - return offset / (float) scrollRange; - } - - private void updatePreviewText() { - final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); - final int pos = lm.findFirstVisibleItemPosition(); - if (pos == RecyclerView.NO_POSITION) { - return; - } - final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); - if (vh == null) { - // This can happen if the messages update while we're dragging the thumb. - return; - } - final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; - final ConversationMessageData messageData = messageView.getData(); - final long timestamp = messageData.getReceivedTimeStamp(); - final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); - mPreviewTextView.setText(timestampText); - } - - @Override - public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { - if (!mVisible) { - return false; - } - // If the user presses down on the scroll thumb, we'll start intercepting events from the - // RecyclerView so we can handle the move events while they're dragging it up/down. - final int action = e.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - if (isInsideThumb(e.getX(), e.getY())) { - startDrag(); - return true; - } - break; - case MotionEvent.ACTION_MOVE: - if (mDragging) { - return true; - } - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - if (mDragging) { - cancelDrag(); - } - return false; - } - return false; - } - - private boolean isInsideThumb(float x, float y) { - final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; - final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; - - if (x < hitTargetLeft || x > hitTargetRight) { - return false; - } - if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { - return false; - } - return true; - } - - @Override - public void onTouchEvent(RecyclerView rv, MotionEvent e) { - if (!mDragging) { - return; - } - final int action = e.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_MOVE: - handleDragMove(e.getY()); - break; - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - cancelDrag(); - break; - } - } - - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - } - - private void startDrag() { - mDragging = true; - mThumbImageView.setPressed(true); - updateScrollPos(); - showPreview(); - cancelAnyPendingHide(); - } - - private void handleDragMove(float y) { - final int verticalScrollLength = mContainer.height() - mThumbHeight; - final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); - - // Convert the desired position from px to a scroll position in the conversation. - float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; - dragScrollRatio = Math.max(dragScrollRatio, 0.0f); - dragScrollRatio = Math.min(dragScrollRatio, 1.0f); - - // Scroll the RecyclerView to a new position. - final int itemCount = mRv.getAdapter().getItemCount(); - final int itemPos = (int)((itemCount - 1) * dragScrollRatio); - mRv.scrollToPosition(itemPos); - } - - private void cancelDrag() { - mDragging = false; - mThumbImageView.setPressed(false); - hidePreview(); - hideAfterDelay(); - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (!mVisible) { - hide(false /* animate */); - } - // The container is the size of the RecyclerView that's visible on screen. We have to - // exclude the top padding, because it's usually hidden behind the conversation action bar. - mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); - layoutTrack(); - updateScrollPos(); - } - - private void layoutTrack() { - int trackHeight = Math.max(0, mContainer.height()); - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); - mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); - - int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; - int top = mContainer.top; - int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); - int bottom = mContainer.bottom; - mTrackImageView.layout(left, top, right, bottom); - } - - private void layoutThumb(int centerY) { - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); - mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); - - int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; - int top = centerY - (mThumbImageView.getHeight() / 2); - int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); - int bottom = top + mThumbHeight; - mThumbImageView.layout(left, top, right, bottom); - } - - private void layoutPreview(int centerY) { - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); - mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); - - // Ensure that the preview bubble is at least as wide as it is tall - if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); - mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); - } - final int previewMinY = mContainer.top + mPreviewMarginTop; - - final int left, right; - if (mPosRight) { - right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; - left = right - mPreviewTextView.getMeasuredWidth(); - } else { - left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; - right = left + mPreviewTextView.getMeasuredWidth(); - } - - int bottom = centerY; - int top = bottom - mPreviewTextView.getMeasuredHeight(); - if (top < previewMinY) { - top = previewMinY; - bottom = top + mPreviewTextView.getMeasuredHeight(); - } - mPreviewTextView.layout(left, top, right, bottom); - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java deleted file mode 100644 index 5f186dc3..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationFragment.java +++ /dev/null @@ -1,1661 +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.ui.conversation; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.DownloadManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.res.Configuration; -import android.database.Cursor; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; -import android.os.Parcelable; -import android.telephony.PhoneNumberUtils; -import android.text.TextUtils; -import android.view.ActionMode; -import android.view.Display; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.MessagingContentProvider; -import com.android.messaging.datamodel.action.InsertNewMessageAction; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.datamodel.data.ConversationParticipantsData; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.AttachmentPreview; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.SnackBar; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.animation.PopupTransitionAnimation; -import com.android.messaging.ui.contact.AddContactsConfirmationDialog; -import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; -import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; -import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; -import com.android.messaging.ui.mediapicker.MediaPicker; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.ChangeDefaultSmsAppHelper; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.ImeUtil; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.PhoneUtils; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.TextUtil; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.UriUtil; -import com.google.common.annotations.VisibleForTesting; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -import androidx.appcompat.app.ActionBar; -import androidx.core.text.BidiFormatter; -import androidx.core.text.TextDirectionHeuristicsCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; -import androidx.loader.app.LoaderManager; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -/** - * Shows a list of messages/parts comprising a conversation. - */ -public class ConversationFragment extends Fragment implements ConversationDataListener, - IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost, - DraftMessageDataListener { - - public interface ConversationFragmentHost extends ImeUtil.ImeStateHost { - void onStartComposeMessage(); - void onConversationMetadataUpdated(); - boolean shouldResumeComposeMessage(); - void onFinishCurrentConversation(); - void invalidateActionBar(); - ActionMode startActionMode(ActionMode.Callback callback); - void dismissActionMode(); - ActionMode getActionMode(); - void onConversationMessagesUpdated(int numberOfMessages); - void onConversationParticipantDataLoaded(int numberOfParticipants); - boolean isActiveAndFocused(); - } - - public static final String FRAGMENT_TAG = "conversation"; - - static final int REQUEST_CHOOSE_ATTACHMENTS = 2; - private static final int JUMP_SCROLL_THRESHOLD = 15; - // We animate the message from draft to message list, if we the message doesn't show up in the - // list within this time limit, then we just do a fade in animation instead - public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; - - private ComposeMessageView mComposeMessageView; - private RecyclerView mRecyclerView; - private ConversationMessageAdapter mAdapter; - private ConversationFastScroller mFastScroller; - - private View mConversationComposeDivider; - private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; - - private String mConversationId; - // If the fragment receives a draft as part of the invocation this is set - private MessageData mIncomingDraft; - - // This binding keeps track of our associated ConversationData instance - // A binding should have the lifetime of the owning component, - // don't recreate, unbind and bind if you need new data - @VisibleForTesting - final Binding mBinding = BindingBase.createBinding(this); - - // Saved Instance State Data - only for temporal data which is nice to maintain but not - // critical for correctness. - private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState"; - private Parcelable mListState; - - private ConversationFragmentHost mHost; - - protected List mFilterResults; - - // The minimum scrolling distance between RecyclerView's scroll change event beyong which - // a fling motion is considered fast, in which case we'll delay load image attachments for - // perf optimization. - private int mFastFlingThreshold; - - // ConversationMessageView that is currently selected - private ConversationMessageView mSelectedMessage; - - // Attachment data for the attachment within the selected message that was long pressed - private MessagePartData mSelectedAttachment; - - // Normally, as soon as draft message is loaded, we trust the UI state held in - // ComposeMessageView to be the only source of truth (incl. the conversation self id). However, - // there can be external events that forces the UI state to change, such as SIM state changes - // or SIM auto-switching on receiving a message. This receiver is used to receive such - // local broadcast messages and reflect the change in the UI. - private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - final String conversationId = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); - final String selfId = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID); - Assert.notNull(conversationId); - Assert.notNull(selfId); - if (isBound() && TextUtils - .equals(mBinding.getData().getConversationId(), conversationId)) { - mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId); - } - } - }; - - // Flag to prevent writing draft to DB on pause - private boolean mSuppressWriteDraft; - - // Indicates whether local draft should be cleared due to external draft changes that must - // be reloaded from db - private boolean mClearLocalDraft; - private ImmutableBindingRef mDraftMessageDataModel; - - private boolean isScrolledToBottom() { - if (mRecyclerView.getChildCount() == 0) { - return true; - } - final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1); - int lastVisibleItem = ((LinearLayoutManager) mRecyclerView - .getLayoutManager()).findLastVisibleItemPosition(); - if (lastVisibleItem < 0) { - // If the recyclerView height is 0, then the last visible item position is -1 - // Try to compute the position of the last item, even though it's not visible - final long id = mRecyclerView.getChildItemId(lastView); - final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id); - if (holder != null) { - lastVisibleItem = holder.getAdapterPosition(); - } - } - final int totalItemCount = mRecyclerView.getAdapter().getItemCount(); - final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount); - return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight(); - } - - private void scrollToBottom(final boolean smoothScroll) { - if (mAdapter.getItemCount() > 0) { - scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll); - } - } - - private int mScrollToDismissThreshold; - private final RecyclerView.OnScrollListener mListScrollListener = - new RecyclerView.OnScrollListener() { - // Keeps track of cumulative scroll delta during a scroll event, which we may use to - // hide the media picker & co. - private int mCumulativeScrollDelta; - private boolean mScrollToDismissHandled; - private boolean mWasScrolledToBottom = true; - private int mScrollState = RecyclerView.SCROLL_STATE_IDLE; - - @Override - public void onScrollStateChanged(final RecyclerView view, final int newState) { - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - // Reset scroll states. - mCumulativeScrollDelta = 0; - mScrollToDismissHandled = false; - } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - mRecyclerView.getItemAnimator().endAnimations(); - } - mScrollState = newState; - } - - @Override - public void onScrolled(final RecyclerView view, final int dx, final int dy) { - if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING && - !mScrollToDismissHandled) { - mCumulativeScrollDelta += dy; - // Dismiss the keyboard only when the user scroll up (into the past). - if (mCumulativeScrollDelta < -mScrollToDismissThreshold) { - mComposeMessageView.hideAllComposeInputs(false /* animate */); - mScrollToDismissHandled = true; - } - } - if (mWasScrolledToBottom != isScrolledToBottom()) { - mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1); - mWasScrolledToBottom = isScrolledToBottom(); - } - } - }; - - private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { - @Override - public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { - if (mSelectedMessage == null) { - return false; - } - final ConversationMessageData data = mSelectedMessage.getData(); - final MenuInflater menuInflater = getActivity().getMenuInflater(); - menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); - menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); - menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); - - // ShareActionProvider does not work with ActionMode. So we use a normal menu item. - menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); - menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); - menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); - - // TODO: We may want to support copying attachments in the future, but it's - // unclear which attachment to pick when we make this context menu at the message level - // instead of the part level - menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); - - return true; - } - - @Override - public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { - return true; - } - - @Override - public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { - final ConversationMessageData data = mSelectedMessage.getData(); - final String messageId = data.getMessageId(); - int itemId = menuItem.getItemId(); - if (itemId == R.id.save_attachment) { - final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(getActivity()); - for (final MessagePartData part : data.getAttachments()) { - saveAttachmentTask.addAttachmentToSave(part.getContentUri(), - part.getContentType()); - } - if (saveAttachmentTask.getAttachmentCount() > 0) { - saveAttachmentTask.executeOnThreadPool(); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.action_delete_message) { - if (mSelectedMessage != null) { - deleteMessage(messageId); - } - return true; - } - if (itemId == R.id.action_download) { - if (mSelectedMessage != null) { - retryDownload(messageId); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.action_send) { - if (mSelectedMessage != null) { - retrySend(messageId); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.copy_text) { - Assert.isTrue(data.hasText()); - final ClipboardManager clipboard = (ClipboardManager) getActivity() - .getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip( - ClipData.newPlainText(null /* label */, data.getText())); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.details_menu) { - MessageDetailsDialog.show( - getActivity(), data, mBinding.getData().getParticipants(), - mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.share_message_menu) { - shareMessage(data); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.forward_message_menu) {// TODO: Currently we are forwarding one part at a time, instead of - // the entire message. Change this to forwarding the entire message when we - // use message-based cursor in conversation. - final MessageData message = mBinding.getData().createForwardedMessage(data); - UIIntents.get().launchForwardMessageActivity(getActivity(), message); - mHost.dismissActionMode(); - return true; - } - return false; - } - - private void shareMessage(final ConversationMessageData data) { - // Figure out what to share. - MessagePartData attachmentToShare = mSelectedAttachment; - // If the user long-pressed on the background, we will share the text (if any) - // or the first attachment. - if (mSelectedAttachment == null - && TextUtil.isAllWhitespace(data.getText())) { - final List attachments = data.getAttachments(); - if (attachments.size() > 0) { - attachmentToShare = attachments.get(0); - } - } - - final Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - if (attachmentToShare == null) { - shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); - shareIntent.setType("text/plain"); - } else { - shareIntent.putExtra( - Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); - shareIntent.setType(attachmentToShare.getContentType()); - } - final CharSequence title = getResources().getText(R.string.action_share); - startActivity(Intent.createChooser(shareIntent, title)); - } - - @Override - public void onDestroyActionMode(final ActionMode actionMode) { - selectMessage(null); - } - }; - - /** - * {@inheritDoc} from Fragment - */ - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mFastFlingThreshold = getResources().getDimensionPixelOffset( - R.dimen.conversation_fast_fling_threshold); - mAdapter = new ConversationMessageAdapter(getActivity(), null, this, - null, - // Sets the item click listener on the Recycler item views. - new View.OnClickListener() { - @Override - public void onClick(final View v) { - final ConversationMessageView messageView = (ConversationMessageView) v; - handleMessageClick(messageView); - } - }, - new View.OnLongClickListener() { - @Override - public boolean onLongClick(final View view) { - selectMessage((ConversationMessageView) view); - return true; - } - } - ); - } - - /** - * setConversationInfo() may be called before or after onCreate(). When a user initiate a - * conversation from compose, the ConversationActivity creates this fragment and calls - * setConversationInfo(), so it happens before onCreate(). However, when the activity is - * restored from saved instance state, the ConversationFragment is created automatically by - * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since - * the ability to start loading data depends on both methods being called, we need to start - * loading when onActivityCreated() is called, which is guaranteed to happen after both. - */ - @Override - public void onActivityCreated(final Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - // Delay showing the message list until the participant list is loaded. - mRecyclerView.setVisibility(View.INVISIBLE); - mBinding.ensureBound(); - mBinding.getData().init(LoaderManager.getInstance(this), mBinding); - - // Build the input manager with all its required dependencies and pass it along to the - // compose message view. - final ConversationInputManager inputManager = new ConversationInputManager( - getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(), - mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState); - mComposeMessageView.setInputManager(inputManager); - mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding)); - mHost.invalidateActionBar(); - - mDraftMessageDataModel = - BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel()); - mDraftMessageDataModel.getData().addListener(this); - } - - public void onAttachmentChoosen() { - // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft - // and reload draft on resume. - mClearLocalDraft = true; - } - - private int getScrollToMessagePosition() { - final Activity activity = getActivity(); - if (activity == null) { - return -1; - } - - final Intent intent = activity.getIntent(); - if (intent == null) { - return -1; - } - - return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); - } - - private void clearScrollToMessagePosition() { - final Activity activity = getActivity(); - if (activity == null) { - return; - } - - final Intent intent = activity.getIntent(); - if (intent == null) { - return; - } - intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); - } - - private final Handler mHandler = new Handler(); - - /** - * {@inheritDoc} from Fragment - */ - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View view = inflater.inflate(R.layout.conversation_fragment, container, false); - mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); - final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); - manager.setStackFromEnd(true); - manager.setReverseLayout(false); - mRecyclerView.setHasFixedSize(true); - mRecyclerView.setLayoutManager(manager); - mRecyclerView.setItemAnimator(new DefaultItemAnimator() { - private final List mAddAnimations = new ArrayList(); - private PopupTransitionAnimation mPopupTransitionAnimation; - - @Override - public boolean animateAdd(final ViewHolder holder) { - final ConversationMessageView view = - (ConversationMessageView) holder.itemView; - final ConversationMessageData data = view.getData(); - endAnimation(holder); - final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp(); - if (data.getReceivedTimeStamp() == - InsertNewMessageAction.getLastSentMessageTimestamp() && - !data.getIsIncoming() && - timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) { - final ConversationMessageBubbleView messageBubble = - (ConversationMessageBubbleView) view - .findViewById(R.id.message_content); - final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView); - final View composeBubbleView = mComposeMessageView.findViewById( - R.id.compose_message_text); - final Rect composeBubbleRect = - UiUtils.getMeasuredBoundsOnScreen(composeBubbleView); - final AttachmentPreview attachmentView = - (AttachmentPreview) mComposeMessageView.findViewById( - R.id.attachment_draft_view); - final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView); - if (attachmentView.getVisibility() == View.VISIBLE) { - startRect.top = attachmentRect.top; - } else { - startRect.top = composeBubbleRect.top; - } - startRect.top -= view.getPaddingTop(); - startRect.bottom = - composeBubbleRect.bottom; - startRect.left += view.getPaddingRight(); - - view.setAlpha(0); - mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); - mPopupTransitionAnimation.setOnStartCallback(new Runnable() { - @Override - public void run() { - final int startWidth = composeBubbleRect.width(); - attachmentView.onMessageAnimationStart(); - messageBubble.kickOffMorphAnimation(startWidth, - messageBubble.findViewById(R.id.message_text_and_info) - .getMeasuredWidth()); - } - }); - mPopupTransitionAnimation.setOnStopCallback(new Runnable() { - @Override - public void run() { - view.setAlpha(1); - dispatchAddFinished(holder); - } - }); - mPopupTransitionAnimation.startAfterLayoutComplete(); - mAddAnimations.add(holder); - return true; - } else { - return super.animateAdd(holder); - } - } - - @Override - public void endAnimation(final ViewHolder holder) { - if (mAddAnimations.remove(holder)) { - holder.itemView.clearAnimation(); - } - super.endAnimation(holder); - } - - @Override - public void endAnimations() { - for (final ViewHolder holder : mAddAnimations) { - holder.itemView.clearAnimation(); - } - mAddAnimations.clear(); - if (mPopupTransitionAnimation != null) { - mPopupTransitionAnimation.cancel(); - } - super.endAnimations(); - } - }); - mRecyclerView.setAdapter(mAdapter); - - if (savedInstanceState != null) { - mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); - } - - mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider); - mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop(); - mRecyclerView.addOnScrollListener(mListScrollListener); - mFastScroller = ConversationFastScroller.addTo(mRecyclerView, - UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE : - ConversationFastScroller.POSITION_RIGHT_SIDE); - - mComposeMessageView = (ComposeMessageView) - view.findViewById(R.id.message_compose_view_container); - // Bind the compose message view to the DraftMessageData - mComposeMessageView.bind(DataModel.get().createDraftMessageData( - mBinding.getData().getConversationId()), this); - - return view; - } - - private void scrollToPosition(final int targetPosition, final boolean smoothScroll) { - if (smoothScroll) { - final int maxScrollDelta = JUMP_SCROLL_THRESHOLD; - - final LinearLayoutManager layoutManager = - (LinearLayoutManager) mRecyclerView.getLayoutManager(); - final int firstVisibleItemPosition = - layoutManager.findFirstVisibleItemPosition(); - final int delta = targetPosition - firstVisibleItemPosition; - final int intermediatePosition; - - if (delta > maxScrollDelta) { - intermediatePosition = Math.max(0, targetPosition - maxScrollDelta); - } else if (delta < -maxScrollDelta) { - final int count = layoutManager.getItemCount(); - intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta); - } else { - intermediatePosition = -1; - } - if (intermediatePosition != -1) { - mRecyclerView.scrollToPosition(intermediatePosition); - } - mRecyclerView.smoothScrollToPosition(targetPosition); - } else { - mRecyclerView.scrollToPosition(targetPosition); - } - } - - private int getScrollPositionFromBottom() { - final LinearLayoutManager layoutManager = - (LinearLayoutManager) mRecyclerView.getLayoutManager(); - final int lastVisibleItem = - layoutManager.findLastVisibleItemPosition(); - return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0); - } - - /** - * Display a photo using the Photoviewer component. - */ - @Override - public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { - displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); - } - - public static void displayPhoto(final Uri photoUri, final Rect imageBounds, - final boolean isDraft, final String conversationId, final Activity activity) { - final Uri imagesUri = - isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId) - : MessagingContentProvider.buildConversationImagesUri(conversationId); - UIIntents.get().launchFullScreenPhotoViewer( - activity, photoUri, imageBounds, imagesUri); - } - - private void selectMessage(final ConversationMessageView messageView) { - selectMessage(messageView, null /* attachment */); - } - - private void selectMessage(final ConversationMessageView messageView, - final MessagePartData attachment) { - mSelectedMessage = messageView; - if (mSelectedMessage == null) { - mAdapter.setSelectedMessage(null); - mHost.dismissActionMode(); - mSelectedAttachment = null; - return; - } - mSelectedAttachment = attachment; - mAdapter.setSelectedMessage(messageView.getData().getMessageId()); - mHost.startActionMode(mMessageActionModeCallback); - } - - @Override - public void onSaveInstanceState(final Bundle outState) { - super.onSaveInstanceState(outState); - if (mListState != null) { - outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); - } - mComposeMessageView.saveInputState(outState); - } - - @Override - public void onResume() { - super.onResume(); - - if (mIncomingDraft == null) { - mComposeMessageView.requestDraftMessage(mClearLocalDraft); - } else { - mComposeMessageView.setDraftMessage(mIncomingDraft); - mIncomingDraft = null; - } - mClearLocalDraft = false; - - // On resume, check if there's a pending request for resuming message compose. This - // may happen when the user commits the contact selection for a group conversation and - // goes from compose back to the conversation fragment. - if (mHost.shouldResumeComposeMessage()) { - mComposeMessageView.resumeComposeMessage(); - } - - setConversationFocus(); - - // On resume, invalidate all message views to show the updated timestamp. - mAdapter.notifyDataSetChanged(); - - LocalBroadcastManager.getInstance(getActivity()).registerReceiver( - mConversationSelfIdChangeReceiver, - new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION)); - } - - void setConversationFocus() { - // Cancelling notifications causes bubbles to be removed from the screen - if (mHost.isActiveAndFocused()) { - mBinding.getData().setFocus(!getActivity().isLaunchedFromBubble()); - } - } - - @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { - if (mHost.getActionMode() != null) { - return; - } - - inflater.inflate(R.menu.conversation_menu, menu); - - final ConversationData data = mBinding.getData(); - - // Disable the "people & options" item if we haven't loaded participants yet. - menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded()); - - // See if we can show add contact action. - final ParticipantData participant = data.getOtherParticipant(); - final boolean addContactActionVisible = (participant != null - && TextUtils.isEmpty(participant.getLookupKey())); - menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); - - // See if we should show archive or unarchive. - final boolean isArchived = data.getIsArchived(); - menu.findItem(R.id.action_archive).setVisible(!isArchived); - menu.findItem(R.id.action_unarchive).setVisible(isArchived); - - // Conditionally enable the phone call button. - final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() && - data.getParticipantPhoneNumber() != null); - menu.findItem(R.id.action_call).setVisible(supportCallAction); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.action_people_and_options) { - Assert.isTrue(mBinding.getData().getParticipantsLoaded()); - UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); - return true; - } - if (itemId == R.id.action_call) { - final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); - Assert.notNull(phoneNumber); - // Can't make a call to emergency numbers using ACTION_CALL. - if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { - UiUtils.showToast(R.string.disallow_emergency_call); - } - else { - final View targetView = getActivity().findViewById(R.id.action_call); - Point centerPoint; - if (targetView != null) { - final int screenLocation[] = new int[2]; - targetView.getLocationOnScreen(screenLocation); - final int centerX = screenLocation[0] + targetView.getWidth() / 2; - final int centerY = screenLocation[1] + targetView.getHeight() / 2; - centerPoint = new Point(centerX, centerY); - } - else { - // In the overflow menu, just use the center of the screen. - final Display display = - getActivity().getWindowManager().getDefaultDisplay(); - centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); - } - UIIntents.get() - .launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); - } - return true; - } - if (itemId == R.id.action_archive) { - mBinding.getData().archiveConversation(mBinding); - closeConversation(mConversationId); - return true; - } - if (itemId == R.id.action_unarchive) { - mBinding.getData().unarchiveConversation(mBinding); - return true; - } - if (itemId == R.id.action_settings) { - return true; - } - if (itemId == R.id.action_add_contact) { - final ParticipantData participant = mBinding.getData().getOtherParticipant(); - Assert.notNull(participant); - final String destination = participant.getNormalizedDestination(); - final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant); - (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); - return true; - } - if (itemId == R.id.action_delete) { - if (isReadyForAction()) { - new AlertDialog.Builder(getActivity()) - .setTitle(getResources().getQuantityString( - R.plurals.delete_conversations_confirmation_dialog_title, 1)) - .setPositiveButton(R.string.delete_conversation_confirmation_button, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - deleteConversation(); - } - }) - .setNegativeButton(R.string.delete_conversation_decline_button, null) - .show(); - } - else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - /** - * {@inheritDoc} from ConversationDataListener - */ - @Override - public void onConversationMessagesCursorUpdated(final ConversationData data, - final Cursor cursor, final ConversationMessageData newestMessage, - final boolean isSync) { - mBinding.ensureBound(data); - - // This needs to be determined before swapping cursor, which may change the scroll state. - final boolean scrolledToBottom = isScrolledToBottom(); - final int positionFromBottom = getScrollPositionFromBottom(); - - // If participants not loaded, assume 1:1 since that's the 99% case - final boolean oneOnOne = - !data.getParticipantsLoaded() || data.getOtherParticipant() != null; - mAdapter.setOneOnOne(oneOnOne, false /* invalidate */); - - // Ensure that the action bar is updated with the current data. - invalidateOptionsMenu(); - final Cursor oldCursor = mAdapter.swapCursor(cursor); - - if (cursor != null && oldCursor == null) { - if (mListState != null) { - mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); - // RecyclerView restores scroll states without triggering scroll change events, so - // we need to manually ensure that they are correctly handled. - mListScrollListener.onScrolled(mRecyclerView, 0, 0); - } - } - - if (isSync) { - // This is a message sync. Syncing messages changes cursor item count, which would - // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same - // relative position from the bottom (because RV is stacked from bottom), so that it - // stays relatively put as we sync. - final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0); - scrollToPosition(position, false /* smoothScroll */); - } else if (newestMessage != null) { - // Show a snack bar notification if we are not scrolled to the bottom and the new - // message is an incoming message. - if (!scrolledToBottom && newestMessage.getIsIncoming()) { - // If the conversation activity is started but not resumed (if another dialog - // activity was in the foregrond), we will show a system notification instead of - // the snack bar. - if (mBinding.getData().isFocused()) { - UiUtils.showSnackBarWithCustomAction(getActivity(), - getView().getRootView(), - getString(R.string.in_conversation_notify_new_message_text), - SnackBar.Action.createCustomAction(new Runnable() { - @Override - public void run() { - scrollToBottom(true /* smoothScroll */); - mComposeMessageView.hideAllComposeInputs(false /* animate */); - } - }, - getString(R.string.in_conversation_notify_new_message_action)), - null /* interactions */, - SnackBar.Placement.above(mComposeMessageView)); - } - } else { - // We are either already scrolled to the bottom or this is an outgoing message, - // scroll to the bottom to reveal it. - // Don't smooth scroll if we were already at the bottom; instead, we scroll - // immediately so RecyclerView's view animation will take place. - scrollToBottom(!scrolledToBottom); - } - } - - if (cursor != null) { - mHost.onConversationMessagesUpdated(cursor.getCount()); - - // Are we coming from a widget click where we're told to scroll to a particular item? - final int scrollToPos = getScrollToMessagePosition(); - if (scrollToPos >= 0) { - if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { - LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " + - " scrollToPos: " + scrollToPos + - " cursorCount: " + cursor.getCount()); - } - scrollToPosition(scrollToPos, true /*smoothScroll*/); - clearScrollToMessagePosition(); - } - } - - mHost.invalidateActionBar(); - } - - /** - * {@inheritDoc} from ConversationDataListener - */ - @Override - public void onConversationMetadataUpdated(final ConversationData conversationData) { - mBinding.ensureBound(conversationData); - - if (mSelectedMessage != null && mSelectedAttachment != null) { - // We may have just sent a message and the temp attachment we selected is now gone. - // and it was replaced with some new attachment. Since we don't know which one it - // is we shouldn't reselect it (unless there is just one) In the multi-attachment - // case we would just deselect the message and allow the user to reselect, otherwise we - // may act on old temp data and may crash. - final List currentAttachments = mSelectedMessage.getData().getAttachments(); - if (currentAttachments.size() == 1) { - mSelectedAttachment = currentAttachments.get(0); - } else if (!currentAttachments.contains(mSelectedAttachment)) { - selectMessage(null); - } - } - // Ensure that the action bar is updated with the current data. - invalidateOptionsMenu(); - mHost.onConversationMetadataUpdated(); - mAdapter.notifyDataSetChanged(); - } - - public void setConversationInfo(final Context context, final String conversationId, - final MessageData draftData) { - // TODO: Eventually I would like the Factory to implement - // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId)); - if (!mBinding.isBound()) { - mConversationId = conversationId; - mIncomingDraft = draftData; - mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); - } else { - Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - // Unbind all the views that we bound to data - if (mComposeMessageView != null) { - mComposeMessageView.unbind(); - } - - // And unbind this fragment from its data - mBinding.unbind(); - mConversationId = null; - } - - void suppressWriteDraft() { - mSuppressWriteDraft = true; - } - - @Override - public void onPause() { - super.onPause(); - if (mComposeMessageView != null && !mSuppressWriteDraft) { - mComposeMessageView.writeDraftMessage(); - } - mSuppressWriteDraft = false; - mBinding.getData().unsetFocus(); - mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); - - LocalBroadcastManager.getInstance(getActivity()) - .unregisterReceiver(mConversationSelfIdChangeReceiver); - } - - @Override - public void onConfigurationChanged(final Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mRecyclerView.getItemAnimator().endAnimations(); - } - - // TODO: Remove isBound and replace it with ensureBound after b/15704674. - public boolean isBound() { - return mBinding.isBound(); - } - - private FragmentManager getFragmentManagerToUse() { - return getChildFragmentManager(); - } - - public MediaPicker getMediaPicker() { - return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( - MediaPicker.FRAGMENT_TAG); - } - - @Override - public void sendMessage(final MessageData message) { - if (isReadyForAction()) { - if (ensureKnownRecipients()) { - // Merge the caption text from attachments into the text body of the messages - message.consolidateText(); - - mBinding.getData().sendMessage(mBinding, message); - mComposeMessageView.resetMediaPickerState(); - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded"); - } - } else { - warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - sendMessage(message); - } - }); - } - } - - public void setHost(final ConversationFragmentHost host) { - mHost = host; - } - - public String getConversationName() { - return mBinding.getData().getConversationName(); - } - - @Override - public void onComposeEditTextFocused() { - mHost.onStartComposeMessage(); - } - - @Override - public void onAttachmentsCleared() { - // When attachments are removed, reset transient media picker state such as image selection. - mComposeMessageView.resetMediaPickerState(); - } - - /** - * Called to check if all conditions are nominal and a "go" for some action, such as deleting - * a message, that requires this app to be the default app. This is also a precondition - * required for sending a draft. - * @return true if all conditions are nominal and we're ready to send a message - */ - @Override - public boolean isReadyForAction() { - return UiUtils.isReadyForAction(); - } - - /** - * When there's some condition that prevents an operation, such as sending a message, - * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair - * that condition. - * @param sending - true if we're called during a sending operation - * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds - * positively to the condition prompt and resolves the condition. If null, - * the user will be shown a toast to tap the send button again. - */ - @Override - public void warnOfMissingActionConditions(final boolean sending, - final Runnable commandToRunAfterActionConditionResolved) { - if (mChangeDefaultSmsAppHelper == null) { - mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); - } - mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending, - commandToRunAfterActionConditionResolved, mComposeMessageView, - getView().getRootView(), - getActivity(), this); - } - - private boolean ensureKnownRecipients() { - final ConversationData conversationData = mBinding.getData(); - - if (!conversationData.getParticipantsLoaded()) { - // We can't tell yet whether or not we have an unknown recipient - return false; - } - - final ConversationParticipantsData participants = conversationData.getParticipants(); - for (final ParticipantData participant : participants) { - - - if (participant.isUnknownSender()) { - UiUtils.showToast(R.string.unknown_sender); - return false; - } - } - - return true; - } - - public void retryDownload(final String messageId) { - if (isReadyForAction()) { - mBinding.getData().downloadMessage(mBinding, messageId); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - } - - public void retrySend(final String messageId) { - if (isReadyForAction()) { - if (ensureKnownRecipients()) { - mBinding.getData().resendMessage(mBinding, messageId); - } - } else { - warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - retrySend(messageId); - } - - }); - } - } - - void deleteMessage(final String messageId) { - if (isReadyForAction()) { - final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) - .setTitle(R.string.delete_message_confirmation_dialog_title) - .setMessage(R.string.delete_message_confirmation_dialog_text) - .setPositiveButton(R.string.delete_message_confirmation_button, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, final int which) { - mBinding.getData().deleteMessage(mBinding, messageId); - } - }) - .setNegativeButton(android.R.string.cancel, null); - - builder.setOnDismissListener(dialog -> mHost.dismissActionMode()); - builder.create().show(); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - mHost.dismissActionMode(); - } - } - - public void deleteConversation() { - if (isReadyForAction()) { - final Context context = getActivity(); - mBinding.getData().deleteConversation(mBinding); - closeConversation(mConversationId); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - } - - @Override - public void closeConversation(final String conversationId) { - if (TextUtils.equals(conversationId, mConversationId)) { - mHost.onFinishCurrentConversation(); - // TODO: Explicitly transition to ConversationList (or just go back)? - } - } - - @Override - public void onConversationParticipantDataLoaded(final ConversationData data) { - mBinding.ensureBound(data); - if (mBinding.getData().getParticipantsLoaded()) { - final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null; - mAdapter.setOneOnOne(oneOnOne, true /* invalidate */); - - // refresh the options menu which will enable the "people & options" item. - invalidateOptionsMenu(); - - mHost.invalidateActionBar(); - - mRecyclerView.setVisibility(View.VISIBLE); - mHost.onConversationParticipantDataLoaded - (mBinding.getData().getNumberOfParticipantsExcludingSelf()); - } - } - - @Override - public void onSubscriptionListDataLoaded(final ConversationData data) { - mBinding.ensureBound(data); - mAdapter.notifyDataSetChanged(); - } - - @Override - public void promptForSelfPhoneNumber() { - if (mComposeMessageView != null) { - // Avoid bug in system which puts soft keyboard over dialog after orientation change - ImeUtil.hideSoftInput(requireActivity(), mComposeMessageView); - } - - final FragmentTransaction ft = requireActivity().getSupportFragmentManager().beginTransaction(); - final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog - .newInstance(getConversationSelfSubId()); - dialog.setTargetFragment(this, 0/*requestCode*/); - dialog.show(ft, null/*tag*/); - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - if (mChangeDefaultSmsAppHelper == null) { - mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); - } - mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null); - } - - public boolean hasMessages() { - return mAdapter != null && mAdapter.getItemCount() > 0; - } - - public boolean onBackPressed() { - if (mComposeMessageView.onBackPressed()) { - return true; - } - return false; - } - - public boolean onNavigationUpPressed() { - return mComposeMessageView.onNavigationUpPressed(); - } - - @Override - public boolean onAttachmentClick(final ConversationMessageView messageView, - final MessagePartData attachment, final Rect imageBounds, final boolean longPress) { - if (longPress) { - selectMessage(messageView, attachment); - return true; - } else if (messageView.getData().getOneClickResendMessage()) { - handleMessageClick(messageView); - return true; - } - - if (attachment.isImage()) { - displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */); - } - - if (attachment.isVCard()) { - UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri()); - } - - return false; - } - - private void handleMessageClick(final ConversationMessageView messageView) { - if (messageView != mSelectedMessage) { - final ConversationMessageData data = messageView.getData(); - final boolean isReadyToSend = isReadyForAction(); - if (data.getOneClickResendMessage()) { - // Directly resend the message on tap if it's failed - retrySend(data.getMessageId()); - selectMessage(null); - } else if (data.getShowResendMessage() && isReadyToSend) { - // Select the message to show the resend/download/delete options - selectMessage(messageView); - } else if (data.getShowDownloadMessage() && isReadyToSend) { - // Directly download the message on tap - retryDownload(data.getMessageId()); - } else { - // Let the toast from warnOfMissingActionConditions show and skip - // selecting - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - selectMessage(null); - } - } else { - selectMessage(null); - } - } - - private static class AttachmentToSave { - public final Uri uri; - public final String contentType; - public Uri persistedUri; - - AttachmentToSave(final Uri uri, final String contentType) { - this.uri = uri; - this.contentType = contentType; - } - } - - public static class SaveAttachmentTask extends SafeAsyncTask { - private final Context mContext; - private final List mAttachmentsToSave = new ArrayList<>(); - - public SaveAttachmentTask(final Context context, final Uri contentUri, - final String contentType) { - mContext = context; - addAttachmentToSave(contentUri, contentType); - } - - public SaveAttachmentTask(final Context context) { - mContext = context; - } - - public void addAttachmentToSave(final Uri contentUri, final String contentType) { - mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); - } - - public int getAttachmentCount() { - return mAttachmentsToSave.size(); - } - - @Override - protected Void doInBackgroundTimed(final Void... arg) { - final File appDir = new File(Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_PICTURES), - mContext.getResources().getString(R.string.app_name)); - final File downloadDir = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS); - for (final AttachmentToSave attachment : mAttachmentsToSave) { - final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) - || ContentType.isVideoType(attachment.contentType); - attachment.persistedUri = UriUtil.persistContent(attachment.uri, - isImageOrVideo ? appDir : downloadDir, attachment.contentType); - } - return null; - } - - @Override - protected void onPostExecute(final Void result) { - int failCount = 0; - int imageCount = 0; - int videoCount = 0; - int otherCount = 0; - for (final AttachmentToSave attachment : mAttachmentsToSave) { - if (attachment.persistedUri == null) { - failCount++; - continue; - } - - // Inform MediaScanner about the new file - final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); - scanFileIntent.setData(attachment.persistedUri); - mContext.sendBroadcast(scanFileIntent); - - if (ContentType.isImageType(attachment.contentType)) { - imageCount++; - } else if (ContentType.isVideoType(attachment.contentType)) { - videoCount++; - } else { - otherCount++; - // Inform DownloadManager of the file so it will show in the "downloads" app - final DownloadManager downloadManager = - (DownloadManager) mContext.getSystemService( - Context.DOWNLOAD_SERVICE); - final String filePath = attachment.persistedUri.getPath(); - final File file = new File(filePath); - - if (file.exists()) { - downloadManager.addCompletedDownload( - file.getName() /* title */, - mContext.getString( - R.string.attachment_file_description) /* description */, - true /* isMediaScannerScannable */, - attachment.contentType, - file.getAbsolutePath(), - file.length(), - false /* showNotification */); - } - } - } - - String message; - if (failCount > 0) { - message = mContext.getResources().getQuantityString( - R.plurals.attachment_save_error, failCount, failCount); - } else { - int messageId = R.plurals.attachments_saved; - if (otherCount > 0) { - if (imageCount + videoCount == 0) { - messageId = R.plurals.attachments_saved_to_downloads; - } - } else { - if (videoCount == 0) { - messageId = R.plurals.photos_saved_to_album; - } else if (imageCount == 0) { - messageId = R.plurals.videos_saved_to_album; - } else { - messageId = R.plurals.attachments_saved_to_album; - } - } - final String appName = mContext.getResources().getString(R.string.app_name); - final int count = imageCount + videoCount + otherCount; - message = mContext.getResources().getQuantityString( - messageId, count, count, appName); - } - UiUtils.showToastAtBottom(message); - } - } - - private void invalidateOptionsMenu() { - final Activity activity = getActivity(); - // TODO: Add the supportInvalidateOptionsMenu call to the host activity. - if (activity == null || !(activity instanceof BugleActionBarActivity)) { - return; - } - ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu(); - } - - @Override - public void setOptionsMenuVisibility(final boolean visible) { - setHasOptionsMenu(visible); - } - - @Override - public int getConversationSelfSubId() { - final String selfParticipantId = mComposeMessageView.getConversationSelfId(); - final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId); - // If the self id or the self participant data hasn't been loaded yet, fallback to - // the default setting. - return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId(); - } - - @Override - public void invalidateActionBar() { - mHost.invalidateActionBar(); - } - - @Override - public void dismissActionMode() { - mHost.dismissActionMode(); - } - - @Override - public void selectSim(final SubscriptionListEntry subscriptionData) { - mComposeMessageView.selectSim(subscriptionData); - mHost.onStartComposeMessage(); - } - - @Override - public void onStartComposeMessage() { - mHost.onStartComposeMessage(); - } - - @Override - public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( - final String selfParticipantId, final boolean excludeDefault) { - // TODO: ConversationMessageView is the only one using this. We should probably - // inject this into the view during binding in the ConversationMessageAdapter. - return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId, - excludeDefault); - } - - @Override - public SimSelectorView getSimSelectorView() { - return (SimSelectorView) getView().findViewById(R.id.sim_selector); - } - - @Override - public MediaPicker createMediaPicker() { - return new MediaPicker(getActivity()); - } - - @Override - public void notifyOfAttachmentLoadFailed() { - UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); - } - - @Override - public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { - warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, - getActivity(), tooManyVideos); - } - - public static void warnOfExceedingMessageLimit(final boolean sending, - final ComposeMessageView composeMessageView, final String conversationId, - final Activity activity, final boolean tooManyVideos) { - final AlertDialog.Builder builder = - new AlertDialog.Builder(activity) - .setTitle(R.string.mms_attachment_limit_reached); - - if (sending) { - if (tooManyVideos) { - builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending); - } else { - builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending) - .setNegativeButton(R.string.attachment_limit_reached_send_anyway, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int which) { - composeMessageView.sendMessageIgnoreMessageSizeLimit(); - } - }); - } - builder.setPositiveButton(android.R.string.ok, new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, final int which) { - showAttachmentChooser(conversationId, activity); - }}); - } else { - builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing) - .setPositiveButton(android.R.string.ok, null); - } - builder.show(); - } - - @Override - public void showAttachmentChooser() { - showAttachmentChooser(mConversationId, getActivity()); - } - - public static void showAttachmentChooser(final String conversationId, - final Activity activity) { - UIIntents.get().launchAttachmentChooserActivity(activity, - conversationId, REQUEST_CHOOSE_ATTACHMENTS); - } - - private void updateActionAndStatusBarColor(final ActionBar actionBar) { - final int themeColor = ConversationDrawables.get().getConversationThemeColor(); - actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); - UiUtils.setStatusBarColor(getActivity(), themeColor); - } - - public void updateActionBar(final ActionBar actionBar) { - if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { - updateActionAndStatusBarColor(actionBar); - // We update this regardless of whether or not the action bar is showing so that we - // don't get a race when it reappears. - actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); - actionBar.setDisplayHomeAsUpEnabled(true); - // Reset the back arrow to its default - actionBar.setHomeAsUpIndicator(0); - View customView = actionBar.getCustomView(); - if (customView == null || customView.getId() != R.id.conversation_title_container) { - final LayoutInflater inflator = (LayoutInflater) - getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - customView = inflator.inflate(R.layout.action_bar_conversation_name, null); - customView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - onBackPressed(); - } - }); - actionBar.setCustomView(customView); - } - - final TextView conversationNameView = - (TextView) customView.findViewById(R.id.conversation_title); - final String conversationName = getConversationName(); - if (!TextUtils.isEmpty(conversationName)) { - // RTL : To format conversation title if it happens to be phone numbers. - final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); - final String formattedName = bidiFormatter.unicodeWrap( - UiUtils.commaEllipsize( - conversationName, - conversationNameView.getPaint(), - conversationNameView.getWidth(), - getString(R.string.plus_one), - getString(R.string.plus_n)).toString(), - TextDirectionHeuristicsCompat.LTR); - conversationNameView.setText(formattedName); - // In case phone numbers are mixed in the conversation name, we need to vocalize it. - final String vocalizedConversationName = - AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); - conversationNameView.setContentDescription(vocalizedConversationName); - getActivity().setTitle(conversationName); - } else { - final String appName = getString(R.string.app_name); - conversationNameView.setText(appName); - getActivity().setTitle(appName); - } - - // When conversation is showing and media picker is not showing, then hide the action - // bar only when we are in landscape mode, with IME open. - if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { - actionBar.hide(); - } else { - actionBar.show(); - } - } - } - - @Override - public boolean shouldShowSubjectEditor() { - return true; - } - - @Override - public boolean shouldHideAttachmentsWhenSimSelectorShown() { - return false; - } - - @Override - public void showHideSimSelector(final boolean show) { - // no-op for now - } - - @Override - public int getSimSelectorItemLayoutId() { - return R.layout.sim_selector_item_view; - } - - @Override - public Uri getSelfSendButtonIconUri() { - return null; // use default button icon uri - } - - @Override - public int overrideCounterColor() { - return -1; // don't override the color - } - - @Override - public void onAttachmentsChanged(final boolean haveAttachments) { - // no-op for now - } - - @Override - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - mDraftMessageDataModel.ensureBound(data); - // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore - // other changes. When the widget changes an attachment, we need to reload the draft. - if (changeFlags == - (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) { - mClearLocalDraft = true; // force a reload of the draft in onResume - } - } - - @Override - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - // no-op for now - } - - @Override - public void onDraftAttachmentLoadFailed() { - // no-op for now - } - - @Override - public int getAttachmentsClearedFlags() { - return DraftMessageData.ATTACHMENTS_CHANGED; - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java deleted file mode 100644 index cf98dbed..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationInput.java +++ /dev/null @@ -1,103 +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.ui.conversation; - -import android.os.Bundle; -import androidx.appcompat.app.ActionBar; - -/** - * The base class for a method of user input, e.g. media picker. - */ -public abstract class ConversationInput { - /** - * The host component where all input components are contained. This is typically the - * conversation fragment but may be mocked in test code. - */ - public interface ConversationInputBase { - boolean showHideInternal(final ConversationInput target, final boolean show, - final boolean animate); - String getInputStateKey(final ConversationInput input); - void beginUpdate(); - void handleOnShow(final ConversationInput target); - void endUpdate(); - } - - protected boolean mShowing; - protected ConversationInputBase mConversationInputBase; - - public abstract boolean show(boolean animate); - public abstract boolean hide(boolean animate); - - public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) { - mConversationInputBase = baseHost; - mShowing = isShowing; - } - - public boolean onBackPressed() { - if (mShowing) { - mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */); - return true; - } - return false; - } - - public boolean onNavigationUpPressed() { - return false; - } - - /** - * Toggle the visibility of this view. - * @param animate - * @return true if the view is now shown, false if it now hidden - */ - public boolean toggle(final boolean animate) { - mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */); - return mShowing; - } - - public void saveState(final Bundle savedState) { - savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing); - } - - public void restoreState(final Bundle savedState) { - // Things are hidden by default, so only handle show. - if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) { - mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */); - } - } - - public boolean updateActionBar(final ActionBar actionBar) { - return false; - } - - /** - * Update our visibility flag in response to visibility change, both for actions - * initiated by this class (through the show/hide methods), and for external changes - * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of - * handling an input showing, we will hide all other inputs to ensure they are mutually - * exclusive. - */ - protected void onVisibilityChanged(final boolean visible) { - if (mShowing != visible) { - mConversationInputBase.beginUpdate(); - mShowing = visible; - if (visible) { - mConversationInputBase.handleOnShow(this); - } - mConversationInputBase.endUpdate(); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java deleted file mode 100644 index bacafc7c..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationInputManager.java +++ /dev/null @@ -1,551 +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.ui.conversation; - -import android.content.Context; -import android.os.Bundle; -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; - -import android.widget.EditText; - -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.mediapicker.MediaPicker; -import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ImeUtil; -import com.android.messaging.util.ImeUtil.ImeStateHost; -import com.google.common.annotations.VisibleForTesting; - -import java.util.Collection; - -/** - * Manages showing/hiding/persisting different mutually exclusive UI components nested in - * ConversationFragment that take user inputs, i.e. media picker, SIM selector and - * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way - * as the other components). - */ -public class ConversationInputManager implements ConversationInput.ConversationInputBase { - /** - * The host component where all input components are contained. This is typically the - * conversation fragment but may be mocked in test code. - */ - public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider { - void invalidateActionBar(); - void setOptionsMenuVisibility(boolean visible); - void dismissActionMode(); - void selectSim(SubscriptionListEntry subscriptionData); - void onStartComposeMessage(); - SimSelectorView getSimSelectorView(); - MediaPicker createMediaPicker(); - void showHideSimSelector(boolean show); - int getSimSelectorItemLayoutId(); - } - - /** - * The "sink" component where all inputs components will direct the user inputs to. This is - * typically the ComposeMessageView but may be mocked in test code. - */ - public interface ConversationInputSink { - void onMediaItemsSelected(Collection items); - void onMediaItemsUnselected(MessagePartData item); - void onPendingAttachmentAdded(PendingAttachmentData pendingItem); - void resumeComposeMessage(); - EditText getComposeEditText(); - void setAccessibility(boolean enabled); - } - - private final ConversationInputHost mHost; - private final ConversationInputSink mSink; - - /** Dependencies injected from the host during construction */ - private final FragmentManager mFragmentManager; - private final Context mContext; - private final ImeStateHost mImeStateHost; - private final ImmutableBindingRef mConversationDataModel; - private final ImmutableBindingRef mDraftDataModel; - - private final ConversationInput[] mInputs; - private final ConversationMediaPicker mMediaInput; - private final ConversationSimSelector mSimInput; - private final ConversationImeKeyboard mImeInput; - private int mUpdateCount; - - private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() { - @Override - public void onImeStateChanged(final boolean imeOpen) { - mImeInput.onVisibilityChanged(imeOpen); - } - }; - - private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { - @Override - public void onConversationParticipantDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - } - - @Override - public void onSubscriptionListDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData()); - } - }; - - public ConversationInputManager( - final Context context, - final ConversationInputHost host, - final ConversationInputSink sink, - final ImeStateHost imeStateHost, - final FragmentManager fm, - final BindingBase conversationDataModel, - final BindingBase draftDataModel, - final Bundle savedState) { - mHost = host; - mSink = sink; - mFragmentManager = fm; - mContext = context; - mImeStateHost = imeStateHost; - mConversationDataModel = BindingBase.createBindingReference(conversationDataModel); - mDraftDataModel = BindingBase.createBindingReference(draftDataModel); - - // Register listeners on dependencies. - mImeStateHost.registerImeStateObserver(mImeStateObserver); - mConversationDataModel.getData().addConversationDataListener(mDataListener); - - // Initialize the inputs - mMediaInput = new ConversationMediaPicker(this); - mSimInput = new SimSelector(this); - mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen()); - mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput }; - - if (savedState != null) { - for (int i = 0; i < mInputs.length; i++) { - mInputs[i].restoreState(savedState); - } - } - updateHostOptionsMenu(); - } - - public void onDetach() { - mImeStateHost.unregisterImeStateObserver(mImeStateObserver); - // Don't need to explicitly unregister for data model events. It will unregister all - // listeners automagically on unbind. - } - - public void onSaveInputState(final Bundle savedState) { - for (int i = 0; i < mInputs.length; i++) { - mInputs[i].saveState(savedState); - } - } - - @Override - public String getInputStateKey(final ConversationInput input) { - return input.getClass().getCanonicalName() + "_savedstate_"; - } - - public boolean onBackPressed() { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].onBackPressed()) { - return true; - } - } - return false; - } - - public boolean onNavigationUpPressed() { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].onNavigationUpPressed()) { - return true; - } - } - return false; - } - - public void resetMediaPickerState() { - mMediaInput.resetViewHolderState(); - } - - public void showHideMediaPicker(final boolean show, final boolean animate) { - showHideInternal(mMediaInput, show, animate); - } - - /** - * Show or hide the sim selector - * @param show visibility - * @param animate whether to animate the change in visibility - * @return true if the state of the visibility was changed - */ - public boolean showHideSimSelector(final boolean show, final boolean animate) { - return showHideInternal(mSimInput, show, animate); - } - - public void showHideImeKeyboard(final boolean show, final boolean animate) { - showHideInternal(mImeInput, show, animate); - } - - public void hideAllInputs(final boolean animate) { - beginUpdate(); - for (int i = 0; i < mInputs.length; i++) { - showHideInternal(mInputs[i], false, animate); - } - endUpdate(); - } - - /** - * Toggle the visibility of the sim selector. - * @param animate - * @param subEntry - * @return true if the view is now shown, false if it now hidden - */ - public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) { - mSimInput.setSelected(subEntry); - return mSimInput.toggle(animate); - } - - public boolean updateActionBar(final ActionBar actionBar) { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].mShowing) { - return mInputs[i].updateActionBar(actionBar); - } - } - return false; - } - - @VisibleForTesting - boolean isMediaPickerVisible() { - return mMediaInput.mShowing; - } - - @VisibleForTesting - boolean isSimSelectorVisible() { - return mSimInput.mShowing; - } - - @VisibleForTesting - boolean isImeKeyboardVisible() { - return mImeInput.mShowing; - } - - @VisibleForTesting - void testNotifyImeStateChanged(final boolean imeOpen) { - mImeStateObserver.onImeStateChanged(imeOpen); - } - - /** - * returns true if the state of the visibility was actually changed - */ - @Override - public boolean showHideInternal(final ConversationInput target, final boolean show, - final boolean animate) { - if (!mConversationDataModel.isBound()) { - return false; - } - - if (target.mShowing == show) { - return false; - } - beginUpdate(); - boolean success; - if (!show) { - success = target.hide(animate); - } else { - success = target.show(animate); - } - - if (success) { - target.onVisibilityChanged(show); - } - endUpdate(); - return true; - } - - @Override - public void handleOnShow(final ConversationInput target) { - if (!mConversationDataModel.isBound()) { - return; - } - beginUpdate(); - - // All inputs are mutually exclusive. Showing one will hide everything else. - // The one exception, is that the keyboard and location media chooser can be open at the - // time to enable searching within that chooser - for (int i = 0; i < mInputs.length; i++) { - final ConversationInput currInput = mInputs[i]; - if (currInput != target) { - // TODO : If there's more exceptions we will want to make this more - // generic - if (currInput instanceof ConversationMediaPicker && - target instanceof ConversationImeKeyboard && - mMediaInput.getExistingOrCreateMediaPicker() != null && - mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) { - // Allow the keyboard and location mediaPicker to be open at the same time, - // but ensure the media picker is full screen to allow enough room - mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true); - continue; - } - showHideInternal(currInput, false /* show */, false /* animate */); - } - } - // Always dismiss action mode on show. - mHost.dismissActionMode(); - // Invoking any non-keyboard input UI is treated as starting message compose. - if (target != mImeInput) { - mHost.onStartComposeMessage(); - } - endUpdate(); - } - - @Override - public void beginUpdate() { - mUpdateCount++; - } - - @Override - public void endUpdate() { - Assert.isTrue(mUpdateCount > 0); - if (--mUpdateCount == 0) { - // Always try to update the host action bar after every update cycle. - mHost.invalidateActionBar(); - } - } - - private void updateHostOptionsMenu() { - mHost.setOptionsMenuVisibility(!mMediaInput.isOpen()); - } - - /** - * Manages showing/hiding the media picker in conversation. - */ - private class ConversationMediaPicker extends ConversationInput { - public ConversationMediaPicker(ConversationInputBase baseHost) { - super(baseHost, false); - } - - private MediaPicker mMediaPicker; - - @Override - public boolean show(boolean animate) { - if (mMediaPicker == null) { - mMediaPicker = getExistingOrCreateMediaPicker(); - setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor()); - mMediaPicker.setSubscriptionDataProvider(mHost); - mMediaPicker.setDraftMessageDataModel(mDraftDataModel); - mMediaPicker.setListener(new MediaPickerListener() { - @Override - public void onOpened() { - handleStateChange(); - } - - @Override - public void onFullScreenChanged(boolean fullScreen) { - // When we're full screen, we want to disable accessibility on the - // ComposeMessageView controls (attach button, message input, sim chooser) - // that are hiding underneath the action bar. - mSink.setAccessibility(!fullScreen /*enabled*/); - handleStateChange(); - } - - @Override - public void onDismissed() { - // Re-enable accessibility on all controls now that the media picker is - // going away. - mSink.setAccessibility(true /*enabled*/); - handleStateChange(); - } - - private void handleStateChange() { - onVisibilityChanged(isOpen()); - mHost.invalidateActionBar(); - updateHostOptionsMenu(); - } - - @Override - public void onItemsSelected(final Collection items, - final boolean resumeCompose) { - mSink.onMediaItemsSelected(items); - mHost.invalidateActionBar(); - if (resumeCompose) { - mSink.resumeComposeMessage(); - } - } - - @Override - public void onItemUnselected(final MessagePartData item) { - mSink.onMediaItemsUnselected(item); - mHost.invalidateActionBar(); - } - - @Override - public void onConfirmItemSelection() { - mSink.resumeComposeMessage(); - } - - @Override - public void onPendingItemAdded(final PendingAttachmentData pendingItem) { - mSink.onPendingAttachmentAdded(pendingItem); - } - - @Override - public void onChooserSelected(final int chooserIndex) { - mHost.invalidateActionBar(); - mHost.dismissActionMode(); - } - }); - } - - mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); - - return isOpen(); - } - - @Override - public boolean hide(boolean animate) { - if (mMediaPicker != null) { - mMediaPicker.dismiss(animate); - } - return !isOpen(); - } - - public void resetViewHolderState() { - if (mMediaPicker != null) { - mMediaPicker.resetViewHolderState(); - } - } - - public void setConversationThemeColor(final int themeColor) { - if (mMediaPicker != null) { - mMediaPicker.setConversationThemeColor(themeColor); - } - } - - private boolean isOpen() { - return (mMediaPicker != null && mMediaPicker.isOpen()); - } - - private MediaPicker getExistingOrCreateMediaPicker() { - if (mMediaPicker != null) { - return mMediaPicker; - } - MediaPicker mediaPicker = (MediaPicker) - mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG); - if (mediaPicker == null) { - mediaPicker = mHost.createMediaPicker(); - if (mediaPicker == null) { - return null; // this use of ComposeMessageView doesn't support media picking - } - mFragmentManager.beginTransaction().replace( - R.id.mediapicker_container, - mediaPicker, - MediaPicker.FRAGMENT_TAG).commit(); - } - return mediaPicker; - } - - @Override - public boolean updateActionBar(ActionBar actionBar) { - if (isOpen()) { - mMediaPicker.updateActionBar(actionBar); - return true; - } - return false; - } - - @Override - public boolean onNavigationUpPressed() { - if (isOpen() && mMediaPicker.isFullScreen()) { - return onBackPressed(); - } - return super.onNavigationUpPressed(); - } - - public boolean onBackPressed() { - if (mMediaPicker != null && mMediaPicker.onBackPressed()) { - return true; - } - return super.onBackPressed(); - } - } - - /** - * Manages showing/hiding the SIM selector in conversation. - */ - private class SimSelector extends ConversationSimSelector { - public SimSelector(ConversationInputBase baseHost) { - super(baseHost); - } - - @Override - protected SimSelectorView getSimSelectorView() { - return mHost.getSimSelectorView(); - } - - @Override - public int getSimSelectorItemLayoutId() { - return mHost.getSimSelectorItemLayoutId(); - } - - @Override - protected void selectSim(SubscriptionListEntry item) { - mHost.selectSim(item); - } - - @Override - public boolean show(boolean animate) { - final boolean result = super.show(animate); - mHost.showHideSimSelector(true /*show*/); - return result; - } - - @Override - public boolean hide(boolean animate) { - final boolean result = super.hide(animate); - mHost.showHideSimSelector(false /*show*/); - return result; - } - } - - /** - * Manages showing/hiding the IME keyboard in conversation. - */ - private class ConversationImeKeyboard extends ConversationInput { - public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) { - super(baseHost, isShowing); - } - - @Override - public boolean show(boolean animate) { - ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText()); - return true; - } - - @Override - public boolean hide(boolean animate) { - ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText()); - return true; - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java deleted file mode 100644 index fb04bb40..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java +++ /dev/null @@ -1,117 +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.ui.conversation; - -import android.content.Context; -import android.database.Cursor; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.CursorRecyclerAdapter; -import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; -import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; -import com.android.messaging.util.Assert; - -import java.util.HashSet; -import java.util.List; - -/** - * Provides an interface to expose Conversation Message Cursor data to a UI widget like a - * RecyclerView. - */ -public class ConversationMessageAdapter extends - CursorRecyclerAdapter { - - private final ConversationMessageViewHost mHost; - private final AsyncImageViewDelayLoader mImageViewDelayLoader; - private final View.OnClickListener mViewClickListener; - private final View.OnLongClickListener mViewLongClickListener; - private boolean mOneOnOne; - private String mSelectedMessageId; - - public ConversationMessageAdapter(final Context context, final Cursor cursor, - final ConversationMessageViewHost host, - final AsyncImageViewDelayLoader imageViewDelayLoader, - final View.OnClickListener viewClickListener, - final View.OnLongClickListener longClickListener) { - super(context, cursor, 0); - mHost = host; - mViewClickListener = viewClickListener; - mViewLongClickListener = longClickListener; - mImageViewDelayLoader = imageViewDelayLoader; - setHasStableIds(true); - } - - @Override - public void bindViewHolder(final ConversationMessageViewHolder holder, - final Context context, final Cursor cursor) { - Assert.isTrue(holder.mView instanceof ConversationMessageView); - final ConversationMessageView conversationMessageView = - (ConversationMessageView) holder.mView; - conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId); - } - - @Override - public ConversationMessageViewHolder createViewHolder(final Context context, - final ViewGroup parent, final int viewType) { - final LayoutInflater layoutInflater = LayoutInflater.from(context); - final ConversationMessageView conversationMessageView = (ConversationMessageView) - layoutInflater.inflate(R.layout.conversation_message_view, null); - conversationMessageView.setHost(mHost); - conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader); - return new ConversationMessageViewHolder(conversationMessageView, - mViewClickListener, mViewLongClickListener); - } - - public void setSelectedMessage(final String messageId) { - mSelectedMessageId = messageId; - notifyDataSetChanged(); - } - - public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) { - if (mOneOnOne != oneOnOne) { - mOneOnOne = oneOnOne; - if (invalidate) { - notifyDataSetChanged(); - } - } - } - - /** - * ViewHolder that holds a ConversationMessageView. - */ - public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder { - final View mView; - - /** - * @param viewClickListener a View.OnClickListener that should define the interaction when - * an item in the RecyclerView is clicked. - */ - public ConversationMessageViewHolder(final View itemView, - final View.OnClickListener viewClickListener, - final View.OnLongClickListener viewLongClickListener) { - super(itemView); - mView = itemView; - - mView.setOnClickListener(viewClickListener); - mView.setOnLongClickListener(viewLongClickListener); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java deleted file mode 100644 index ef6aeb4a..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java +++ /dev/null @@ -1,132 +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.ui.conversation; - -import android.animation.Animator; -import android.animation.Animator.AnimatorListener; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.util.AttributeSet; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import com.android.messaging.R; -import com.android.messaging.annotation.VisibleForAnimation; -import com.android.messaging.datamodel.data.ConversationMessageBubbleData; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.util.UiUtils; - -/** - * Shows the message bubble for one conversation message. It is able to animate size changes - * by morphing when the message content changes size. - */ -// TODO: Move functionality from ConversationMessageView into this class as appropriate -public class ConversationMessageBubbleView extends LinearLayout { - private int mIntrinsicWidth; - private int mMorphedWidth; - private ObjectAnimator mAnimator; - private boolean mShouldAnimateWidthChange; - private final ConversationMessageBubbleData mData; - private int mRunningStartWidth; - private ViewGroup mBubbleBackground; - - public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mData = new ConversationMessageBubbleData(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - - final int newIntrinsicWidth = getMeasuredWidth(); - if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) { - if (mShouldAnimateWidthChange) { - kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth); - } - mIntrinsicWidth = newIntrinsicWidth; - } - - if (mMorphedWidth > 0) { - mBubbleBackground.getLayoutParams().width = mMorphedWidth; - } else { - mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; - } - mBubbleBackground.requestLayout(); - } - - @VisibleForAnimation - public void setMorphWidth(final int width) { - mMorphedWidth = width; - requestLayout(); - } - - public void bind(final ConversationMessageData data) { - final boolean changed = mData.bind(data); - // Animate width change only when we are binding to the same message, so that we may - // animate view size changes on the same message bubble due to things like status text - // change. - // Don't animate width change when the bubble contains attachments. Width animation is - // only suitable for text-only messages (where the bubble size change due to status or - // time stamp changes). - mShouldAnimateWidthChange = !changed && !data.hasAttachments(); - if (mAnimator == null) { - mMorphedWidth = 0; - } - } - - public void kickOffMorphAnimation(final int oldWidth, final int newWidth) { - if (mAnimator != null) { - mAnimator.setIntValues(mRunningStartWidth, newWidth); - return; - } - mRunningStartWidth = oldWidth; - mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth); - mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); - mAnimator.addListener(new AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) { - } - - @Override - public void onAnimationEnd(Animator animator) { - mAnimator = null; - mMorphedWidth = 0; - // Allow the bubble to resize if, for example, the status text changed during - // the animation. This will snap to the bigger size if needed. This is intentional - // as animating immediately after looks really bad and switching layout params - // during the original animation does not achieve the desired effect. - mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; - mBubbleBackground.requestLayout(); - } - - @Override - public void onAnimationCancel(Animator animator) { - } - - @Override - public void onAnimationRepeat(Animator animator) { - } - }); - mAnimator.start(); - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java deleted file mode 100644 index 4cc11f2f..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageView.java +++ /dev/null @@ -1,1195 +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.ui.conversation; - -import android.content.Context; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.format.Formatter; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.ImageView.ScaleType; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.datamodel.media.ImageRequestDescriptor; -import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor; -import com.android.messaging.datamodel.media.UriImageRequestDescriptor; -import com.android.messaging.sms.MmsUtils; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; -import com.android.messaging.ui.AudioAttachmentView; -import com.android.messaging.ui.ContactIconView; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.MultiAttachmentLayout; -import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; -import com.android.messaging.ui.PersonItemView; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.VideoThumbnailView; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.ImageUtils; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.PhoneUtils; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.YouTubeUtil; - -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.function.Predicate; - -import androidx.annotation.Nullable; - -/** - * The view for a single entry in a conversation. - */ -public class ConversationMessageView extends FrameLayout implements View.OnClickListener, - View.OnLongClickListener, OnAttachmentClickListener { - public interface ConversationMessageViewHost { - boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment, - Rect imageBounds, boolean longPress); - SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId, - boolean excludeDefault); - } - - private final ConversationMessageData mData; - - private LinearLayout mMessageAttachmentsView; - private MultiAttachmentLayout mMultiAttachmentView; - private AsyncImageView mMessageImageView; - private TextView mMessageTextView; - private boolean mMessageTextHasLinks; - private boolean mMessageHasYouTubeLink; - private TextView mStatusTextView; - private TextView mTitleTextView; - private TextView mMmsInfoTextView; - private LinearLayout mMessageTitleLayout; - private TextView mSenderNameTextView; - private ContactIconView mContactIconView; - private ConversationMessageBubbleView mMessageBubble; - private View mSubjectView; - private TextView mSubjectLabel; - private TextView mSubjectText; - private View mDeliveredBadge; - private ViewGroup mMessageMetadataView; - private ViewGroup mMessageTextAndInfoView; - private TextView mSimNameView; - - private boolean mOneOnOne; - private ConversationMessageViewHost mHost; - - public ConversationMessageView(final Context context, final AttributeSet attrs) { - super(context, attrs); - // TODO: we should switch to using Binding and DataModel factory methods. - mData = new ConversationMessageData(); - } - - @Override - protected void onFinishInflate() { - mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); - mContactIconView.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View view) { - ConversationMessageView.this.performLongClick(); - return true; - } - }); - - mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments); - mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments); - mMultiAttachmentView.setOnAttachmentClickListener(this); - - mMessageImageView = (AsyncImageView) findViewById(R.id.message_image); - mMessageImageView.setOnClickListener(this); - mMessageImageView.setOnLongClickListener(this); - - mMessageTextView = (TextView) findViewById(R.id.message_text); - mMessageTextView.setOnClickListener(this); - IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this); - - mStatusTextView = (TextView) findViewById(R.id.message_status); - mTitleTextView = (TextView) findViewById(R.id.message_title); - mMmsInfoTextView = (TextView) findViewById(R.id.mms_info); - mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout); - mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name); - mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content); - mSubjectView = findViewById(R.id.subject_container); - mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label); - mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text); - mDeliveredBadge = findViewById(R.id.smsDeliveredBadge); - mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata); - mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info); - mSimNameView = (TextView) findViewById(R.id.sim_name); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec); - final int iconSize = getResources() - .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); - - final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY); - - mContactIconView.measure(iconMeasureSpec, iconMeasureSpec); - - final int arrowWidth = - getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width); - - // We need to subtract contact icon width twice from the horizontal space to get - // the max leftover space because we want the message bubble to extend no further than the - // starting position of the message bubble in the opposite direction. - final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2 - - arrowWidth - getPaddingLeft() - getPaddingRight(); - final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace, - MeasureSpec.AT_MOST); - - mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec); - - final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(), - mMessageBubble.getMeasuredHeight()); - setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop()); - } - - @Override - protected void onLayout(final boolean changed, final int left, final int top, final int right, - final int bottom) { - final boolean isRtl = AccessibilityUtil.isLayoutRtl(this); - - final int iconWidth = mContactIconView.getMeasuredWidth(); - final int iconHeight = mContactIconView.getMeasuredHeight(); - final int iconTop = getPaddingTop(); - final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight(); - final int contentHeight = mMessageBubble.getMeasuredHeight(); - final int contentTop = iconTop; - - final int iconLeft; - final int contentLeft; - if (mData.getIsIncoming()) { - if (isRtl) { - iconLeft = (right - left) - getPaddingRight() - iconWidth; - contentLeft = iconLeft - contentWidth; - } else { - iconLeft = getPaddingLeft(); - contentLeft = iconLeft + iconWidth; - } - } else { - if (isRtl) { - iconLeft = getPaddingLeft(); - contentLeft = iconLeft + iconWidth; - } else { - iconLeft = (right - left) - getPaddingRight() - iconWidth; - contentLeft = iconLeft - contentWidth; - } - } - - mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight); - - mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth, - contentTop + contentHeight); - } - - /** - * Fills in the data associated with this view. - * - * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. - */ - public void bind(final Cursor cursor) { - bind(cursor, true, null); - } - - /** - * Fills in the data associated with this view. - * - * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. - * @param oneOnOne Whether this is a 1:1 conversation - */ - public void bind(final Cursor cursor, - final boolean oneOnOne, final String selectedMessageId) { - mOneOnOne = oneOnOne; - - // Update our UI model - mData.bind(cursor); - setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId)); - - // Update text and image content for the view. - updateViewContent(); - - // Update colors and layout parameters for the view. - updateViewAppearance(); - - updateContentDescription(); - } - - public void setHost(final ConversationMessageViewHost host) { - mHost = host; - } - - /** - * Sets a delay loader instance to manage loading / resuming of image attachments. - */ - public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { - Assert.notNull(mMessageImageView); - mMessageImageView.setDelayLoader(delayLoader); - mMultiAttachmentView.setImageViewDelayLoader(delayLoader); - } - - public ConversationMessageData getData() { - return mData; - } - - /** - * Returns whether we should show simplified visual style for the message view (i.e. hide the - * avatar and bubble arrow, reduce padding). - */ - private boolean shouldShowSimplifiedVisualStyle() { - return mData.getCanClusterWithPreviousMessage(); - } - - /** - * Returns whether we need to show message bubble arrow. We don't show arrow if the message - * contains media attachments or if shouldShowSimplifiedVisualStyle() is true. - */ - private boolean shouldShowMessageBubbleArrow() { - return !shouldShowSimplifiedVisualStyle() - && !(mData.hasAttachments() || mMessageHasYouTubeLink); - } - - /** - * Returns whether we need to show a message bubble for text content. - */ - private boolean shouldShowMessageTextBubble() { - if (mData.hasText()) { - return true; - } - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - if (!TextUtils.isEmpty(subjectText)) { - return true; - } - return false; - } - - private void updateViewContent() { - updateMessageContent(); - int titleResId = -1; - int statusResId = -1; - String statusText = null; - switch(mData.getStatus()) { - case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: - titleResId = R.string.message_title_downloading; - statusResId = R.string.message_status_downloading; - break; - - case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_manual_download; - if (isSelected()) { - statusResId = R.string.message_status_download_action; - } else { - statusResId = R.string.message_status_download; - } - } - break; - - case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_download_failed; - statusResId = R.string.message_status_download_error; - } - break; - - case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_download_failed; - if (isSelected()) { - statusResId = R.string.message_status_download_action; - } else { - statusResId = R.string.message_status_download; - } - } - break; - - case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: - case MessageData.BUGLE_STATUS_OUTGOING_SENDING: - statusResId = R.string.message_status_sending; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: - case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: - statusResId = R.string.message_status_send_retrying; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: - statusResId = R.string.message_status_send_failed_emergency_number; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED: - // don't show the error state unless we're the default sms app - if (PhoneUtils.getDefault().isDefaultSmsApp()) { - if (isSelected()) { - statusResId = R.string.message_status_resend; - } else { - statusResId = MmsUtils.mapRawStatusToErrorResourceId( - mData.getStatus(), mData.getRawTelephonyStatus()); - } - break; - } - // FALL THROUGH HERE - - case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: - case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: - case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: - default: - if (!mData.getCanClusterWithNextMessage()) { - statusText = mData.getFormattedReceivedTimeStamp(); - } - break; - } - - final boolean titleVisible = (titleResId >= 0); - if (titleVisible) { - final String titleText = getResources().getString(titleResId); - mTitleTextView.setText(titleText); - - final String mmsInfoText = getResources().getString( - R.string.mms_info, - Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()), - DateUtils.formatDateTime( - getContext(), - mData.getMmsExpiry(), - DateUtils.FORMAT_SHOW_DATE | - DateUtils.FORMAT_SHOW_TIME | - DateUtils.FORMAT_NUMERIC_DATE | - DateUtils.FORMAT_NO_YEAR)); - mMmsInfoTextView.setText(mmsInfoText); - mMessageTitleLayout.setVisibility(View.VISIBLE); - } else { - mMessageTitleLayout.setVisibility(View.GONE); - } - - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - final boolean subjectVisible = !TextUtils.isEmpty(subjectText); - - final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage() - && mData.getIsIncoming(); - if (senderNameVisible) { - mSenderNameTextView.setText(mData.getSenderDisplayName()); - mSenderNameTextView.setVisibility(View.VISIBLE); - } else { - mSenderNameTextView.setVisibility(View.GONE); - } - - if (statusResId >= 0) { - statusText = getResources().getString(statusResId); - } - - // We set the text even if the view will be GONE for accessibility - mStatusTextView.setText(statusText); - final boolean statusVisible = !TextUtils.isEmpty(statusText); - if (statusVisible) { - mStatusTextView.setVisibility(View.VISIBLE); - } else { - mStatusTextView.setVisibility(View.GONE); - } - - final boolean deliveredBadgeVisible = - mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; - mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE); - - // Update the sim indicator. - final boolean showSimIconAsIncoming = mData.getIsIncoming() && - (!mData.hasAttachments() || shouldShowMessageTextBubble()); - final SubscriptionListEntry subscriptionEntry = - mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(), - true /* excludeDefault */); - final boolean simNameVisible = subscriptionEntry != null && - !TextUtils.isEmpty(subscriptionEntry.displayName) && - !mData.getCanClusterWithNextMessage(); - if (simNameVisible) { - final String simNameText = mData.getIsIncoming() ? getResources().getString( - R.string.incoming_sim_name_text, subscriptionEntry.displayName) : - subscriptionEntry.displayName; - mSimNameView.setText(simNameText); - mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor( - R.color.timestamp_text_incoming) : subscriptionEntry.displayColor); - mSimNameView.setVisibility(VISIBLE); - } else { - mSimNameView.setText(null); - mSimNameView.setVisibility(GONE); - } - - final boolean metadataVisible = senderNameVisible || statusVisible - || deliveredBadgeVisible || simNameVisible; - mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE); - - final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible - || mData.hasText() || metadataVisible; - mMessageTextAndInfoView.setVisibility( - messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE); - - if (shouldShowSimplifiedVisualStyle()) { - mContactIconView.setVisibility(View.GONE); - mContactIconView.setImageResourceUri(null); - } else { - mContactIconView.setVisibility(View.VISIBLE); - final Uri avatarUri = AvatarUriUtil.createAvatarUri( - mData.getSenderProfilePhotoUri(), - mData.getSenderFullName(), - mData.getSenderNormalizedDestination(), - mData.getSenderContactLookupKey()); - mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(), - mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination()); - } - } - - private void updateMessageContent() { - // We must update the text before the attachments since we search the text to see if we - // should make a preview youtube image in the attachments - updateMessageText(); - updateMessageAttachments(); - updateMessageSubject(); - mMessageBubble.bind(mData); - } - - private void updateMessageAttachments() { - // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically. - bindAttachmentsOfSameType(sVideoFilter, - R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class); - bindAttachmentsOfSameType(sAudioFilter, - R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class); - bindAttachmentsOfSameType(sVCardFilter, - R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class); - - // Bind image attachments. If there are multiple, they are shown in a collage view. - final List imageParts = mData.getAttachments(sImageFilter); - if (imageParts.size() > 1) { - Collections.sort(imageParts, sImageComparator); - mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size()); - mMultiAttachmentView.setVisibility(View.VISIBLE); - } else { - mMultiAttachmentView.setVisibility(View.GONE); - } - - // In the case that we have no image attachments and exactly one youtube link in a message - // then we will show a preview. - String youtubeThumbnailUrl = null; - String originalYoutubeLink = null; - if (mMessageTextHasLinks && imageParts.size() == 0) { - CharSequence messageTextWithSpans = mMessageTextView.getText(); - final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0, - messageTextWithSpans.length(), URLSpan.class); - for (URLSpan span : spans) { - String url = span.getURL(); - String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url); - if (!TextUtils.isEmpty(youtubeLinkForUrl)) { - if (TextUtils.isEmpty(youtubeThumbnailUrl)) { - // Save the youtube link if we don't already have one - youtubeThumbnailUrl = youtubeLinkForUrl; - originalYoutubeLink = url; - } else { - // We already have a youtube link. This means we have two youtube links so - // we shall show none. - youtubeThumbnailUrl = null; - originalYoutubeLink = null; - break; - } - } - } - } - // We need to keep track if we have a youtube link in the message so that we will not show - // the arrow - mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl); - - // We will show the message image view if there is one attachment or one youtube link - if (imageParts.size() == 1 || mMessageHasYouTubeLink) { - // Get the display metrics for a hint for how large to pull the image data into - final WindowManager windowManager = (WindowManager) getContext(). - getSystemService(Context.WINDOW_SERVICE); - final DisplayMetrics displayMetrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(displayMetrics); - - final int iconSize = getResources() - .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); - final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize; - - if (imageParts.size() == 1) { - final MessagePartData imagePart = imageParts.get(0); - // If the image is big, we want to scale it down to save memory since we're going to - // scale it down to fit into the bubble width. We don't constrain the height. - final ImageRequestDescriptor imageRequest = - new MessagePartImageRequestDescriptor(imagePart, - desiredWidth, - MessagePartData.UNSPECIFIED_SIZE, - false); - adjustImageViewBounds(imagePart); - mMessageImageView.setImageResourceId(imageRequest); - mMessageImageView.setTag(imagePart); - } else { - // Youtube Thumbnail image - final ImageRequestDescriptor imageRequest = - new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth, - MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */, - true /* isStatic */, false /* cropToCircle */, - ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, - ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); - mMessageImageView.setImageResourceId(imageRequest); - mMessageImageView.setTag(originalYoutubeLink); - } - mMessageImageView.setVisibility(View.VISIBLE); - } else { - mMessageImageView.setImageResourceId(null); - mMessageImageView.setVisibility(View.GONE); - } - - // Show the message attachments container if any of its children are visible - boolean attachmentsVisible = false; - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView.getVisibility() == View.VISIBLE) { - attachmentsVisible = true; - break; - } - } - mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE); - } - - private void bindAttachmentsOfSameType(final Predicate attachmentTypeFilter, - final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder, - final Class attachmentViewClass) { - final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); - - // Iterate through all attachments of a particular type (video, audio, etc). - // Find the first attachment index that matches the given type if possible. - int attachmentViewIndex = -1; - View existingAttachmentView; - do { - existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex); - } while (existingAttachmentView != null && - !(attachmentViewClass.isInstance(existingAttachmentView))); - - for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) { - View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); - if (!attachmentViewClass.isInstance(attachmentView)) { - attachmentView = layoutInflater.inflate(attachmentViewLayoutRes, - mMessageAttachmentsView, false /* attachToRoot */); - attachmentView.setOnClickListener(this); - attachmentView.setOnLongClickListener(this); - mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex); - } - viewBinder.bindView(attachmentView, attachment); - attachmentView.setTag(attachment); - attachmentView.setVisibility(View.VISIBLE); - attachmentViewIndex++; - } - // If there are unused views left over, unbind or remove them. - while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) { - final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); - if (attachmentViewClass.isInstance(attachmentView)) { - mMessageAttachmentsView.removeViewAt(attachmentViewIndex); - } else { - // No more views of this type; we're done. - break; - } - } - } - - private void updateMessageSubject() { - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - final boolean subjectVisible = !TextUtils.isEmpty(subjectText); - - if (subjectVisible) { - mSubjectText.setText(subjectText); - mSubjectView.setVisibility(View.VISIBLE); - } else { - mSubjectView.setVisibility(View.GONE); - } - } - - private void updateMessageText() { - final String text = mData.getText(); - if (!TextUtils.isEmpty(text)) { - mMessageTextView.setText(text); - // Linkify phone numbers, web urls, emails, and map addresses to allow users to - // click on them and take the default intent. - mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL); - mMessageTextView.setVisibility(View.VISIBLE); - } else { - mMessageTextView.setVisibility(View.GONE); - mMessageTextHasLinks = false; - } - } - - private void updateViewAppearance() { - final Resources res = getResources(); - final ConversationDrawables drawableProvider = ConversationDrawables.get(); - final boolean incoming = mData.getIsIncoming(); - final boolean outgoing = !incoming; - final boolean showArrow = shouldShowMessageBubbleArrow(); - - final int messageTopPaddingClustered = - res.getDimensionPixelSize(R.dimen.message_padding_same_author); - final int messageTopPaddingDefault = - res.getDimensionPixelSize(R.dimen.message_padding_default); - final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width); - final int messageTextMinHeightDefault = res.getDimensionPixelSize( - R.dimen.conversation_message_contact_icon_size); - final int messageTextLeftRightPadding = res.getDimensionPixelOffset( - R.dimen.message_text_left_right_padding); - final int textTopPaddingDefault = res.getDimensionPixelOffset( - R.dimen.message_text_top_padding); - final int textBottomPaddingDefault = res.getDimensionPixelOffset( - R.dimen.message_text_bottom_padding); - - // These values depend on whether the message has text, attachments, or both. - // We intentionally don't set defaults, so the compiler will tell us if we forget - // to set one of them, or if we set one more than once. - final int contentLeftPadding, contentRightPadding; - final Drawable textBackground; - final int textMinHeight; - final int textTopMargin; - final int textTopPadding, textBottomPadding; - final int textLeftPadding, textRightPadding; - - if (mData.hasAttachments()) { - if (shouldShowMessageTextBubble()) { - // Text and attachment(s) - contentLeftPadding = incoming ? arrowWidth : 0; - contentRightPadding = outgoing ? arrowWidth : 0; - textBackground = drawableProvider.getBubbleDrawable( - isSelected(), - incoming, - false /* needArrow */, - mData.hasIncomingErrorStatus()); - textMinHeight = messageTextMinHeightDefault; - textTopMargin = messageTopPaddingClustered; - textTopPadding = textTopPaddingDefault; - textBottomPadding = textBottomPaddingDefault; - textLeftPadding = messageTextLeftRightPadding; - textRightPadding = messageTextLeftRightPadding; - mMessageTextView.setTextIsSelectable(isSelected()); - } else { - // Attachment(s) only - contentLeftPadding = incoming ? arrowWidth : 0; - contentRightPadding = outgoing ? arrowWidth : 0; - textBackground = null; - textMinHeight = 0; - textTopMargin = 0; - textTopPadding = 0; - textBottomPadding = 0; - textLeftPadding = 0; - textRightPadding = 0; - } - } else { - // Text only - contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0; - contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0; - textBackground = drawableProvider.getBubbleDrawable( - isSelected(), - incoming, - shouldShowMessageBubbleArrow(), - mData.hasIncomingErrorStatus()); - textMinHeight = messageTextMinHeightDefault; - textTopMargin = 0; - textTopPadding = textTopPaddingDefault; - textBottomPadding = textBottomPaddingDefault; - mMessageTextView.setTextIsSelectable(isSelected()); - if (showArrow && incoming) { - textLeftPadding = messageTextLeftRightPadding + arrowWidth; - } else { - textLeftPadding = messageTextLeftRightPadding; - } - if (showArrow && outgoing) { - textRightPadding = messageTextLeftRightPadding + arrowWidth; - } else { - textRightPadding = messageTextLeftRightPadding; - } - } - - // These values do not depend on whether the message includes attachments - final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) : - (Gravity.END | Gravity.CENTER_VERTICAL); - final int messageTopPadding = shouldShowSimplifiedVisualStyle() ? - messageTopPaddingClustered : messageTopPaddingDefault; - final int metadataTopPadding = res.getDimensionPixelOffset( - R.dimen.message_metadata_top_padding); - - // Update the message text/info views - ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground); - mMessageTextAndInfoView.setMinimumHeight(textMinHeight); - final LinearLayout.LayoutParams textAndInfoLayoutParams = - (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams(); - textAndInfoLayoutParams.topMargin = textTopMargin; - - if (UiUtils.isRtlMode()) { - // Need to switch right and left padding in RtL mode - mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding, - textBottomPadding); - mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0); - } else { - mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding, - textBottomPadding); - mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0); - } - - // Update the message row and message bubble views - setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0); - mMessageBubble.setGravity(gravity); - updateMessageAttachmentsAppearance(gravity); - - mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0); - - updateTextAppearance(); - - requestLayout(); - } - - private void updateContentDescription() { - StringBuilder description = new StringBuilder(); - - Resources res = getResources(); - String separator = res.getString(R.string.enumeration_comma); - - // Sender information - boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) || - mMessageTextHasLinks); - if (mData.getIsIncoming()) { - int senderResId = hasPlainTextMessage - ? R.string.incoming_text_sender_content_description - : R.string.incoming_sender_content_description; - description.append(res.getString(senderResId, mData.getSenderDisplayName())); - } else { - int senderResId = hasPlainTextMessage - ? R.string.outgoing_text_sender_content_description - : R.string.outgoing_sender_content_description; - description.append(res.getString(senderResId)); - } - - if (mSubjectView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mSubjectText.getText()); - } - - if (mMessageTextView.getVisibility() == View.VISIBLE) { - // If the message has hyperlinks, we will let the user navigate to the text message so - // that the hyperlink can be clicked. Otherwise, the text message does not need to - // be reachable. - if (mMessageTextHasLinks) { - mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); - } else { - mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - description.append(separator); - description.append(mMessageTextView.getText()); - } - } - - if (mMessageTitleLayout.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mTitleTextView.getText()); - - description.append(separator); - description.append(mMmsInfoTextView.getText()); - } - - if (mStatusTextView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mStatusTextView.getText()); - } - - if (mSimNameView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mSimNameView.getText()); - } - - if (mDeliveredBadge.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(res.getString(R.string.delivered_status_content_description)); - } - - setContentDescription(description); - } - - private void updateMessageAttachmentsAppearance(final int gravity) { - mMessageAttachmentsView.setGravity(gravity); - - // Tint image/video attachments when selected - final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint); - if (mMessageImageView.getVisibility() == View.VISIBLE) { - if (isSelected()) { - mMessageImageView.setColorFilter(selectedImageTint); - } else { - mMessageImageView.clearColorFilter(); - } - } - if (mMultiAttachmentView.getVisibility() == View.VISIBLE) { - if (isSelected()) { - mMultiAttachmentView.setColorFilter(selectedImageTint); - } else { - mMultiAttachmentView.clearColorFilter(); - } - } - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView instanceof VideoThumbnailView - && attachmentView.getVisibility() == View.VISIBLE) { - final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView; - if (isSelected()) { - videoView.setColorFilter(selectedImageTint); - } else { - videoView.clearColorFilter(); - } - } - } - - // If there are multiple attachment bubbles in a single message, add some separation. - final int multipleAttachmentPadding = - getResources().getDimensionPixelSize(R.dimen.message_padding_same_author); - - boolean previousVisibleView = false; - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView.getVisibility() == View.VISIBLE) { - final int margin = previousVisibleView ? multipleAttachmentPadding : 0; - ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin; - // updateViewAppearance calls requestLayout() at the end, so we don't need to here - previousVisibleView = true; - } - } - } - - private void updateTextAppearance() { - int messageColorResId; - int statusColorResId = -1; - int infoColorResId = -1; - int timestampColorResId; - int subjectLabelColorResId; - if (isSelected()) { - messageColorResId = R.color.message_text_color_incoming; - statusColorResId = R.color.message_action_status_text; - infoColorResId = R.color.message_action_info_text; - if (shouldShowMessageTextBubble()) { - timestampColorResId = R.color.message_action_timestamp_text; - subjectLabelColorResId = R.color.message_action_timestamp_text; - } else { - // If there's no text, the timestamp will be shown below the attachments, - // against the conversation view background. - timestampColorResId = R.color.timestamp_text_outgoing; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - } - } else { - messageColorResId = (mData.getIsIncoming() ? - R.color.message_text_color_incoming : R.color.message_text_color_outgoing); - statusColorResId = messageColorResId; - infoColorResId = R.color.timestamp_text_incoming; - switch(mData.getStatus()) { - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED: - case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: - timestampColorResId = R.color.message_failed_timestamp_text; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: - case MessageData.BUGLE_STATUS_OUTGOING_SENDING: - case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: - case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: - case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: - case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: - timestampColorResId = R.color.timestamp_text_outgoing; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - break; - - case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: - case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: - messageColorResId = R.color.message_text_color_incoming_download_failed; - timestampColorResId = R.color.message_download_failed_timestamp_text; - subjectLabelColorResId = R.color.message_text_color_incoming_download_failed; - statusColorResId = R.color.message_download_failed_status_text; - infoColorResId = R.color.message_info_text_incoming_download_failed; - break; - - case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: - timestampColorResId = R.color.message_text_color_incoming; - subjectLabelColorResId = R.color.message_text_color_incoming; - infoColorResId = R.color.timestamp_text_incoming; - break; - - case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: - default: - timestampColorResId = R.color.timestamp_text_incoming; - subjectLabelColorResId = R.color.timestamp_text_incoming; - infoColorResId = -1; // Not used - break; - } - } - final int messageColor = getResources().getColor(messageColorResId); - mMessageTextView.setTextColor(messageColor); - mMessageTextView.setLinkTextColor(messageColor); - mSubjectText.setTextColor(messageColor); - if (statusColorResId >= 0) { - mTitleTextView.setTextColor(getResources().getColor(statusColorResId)); - } - if (infoColorResId >= 0) { - mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId)); - } - if (timestampColorResId == R.color.timestamp_text_incoming && - mData.hasAttachments() && !shouldShowMessageTextBubble()) { - timestampColorResId = R.color.timestamp_text_outgoing; - } - mStatusTextView.setTextColor(getResources().getColor(timestampColorResId)); - - mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId)); - mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId)); - } - - /** - * If we don't know the size of the image, we want to show it in a fixed-sized frame to - * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to - * take on normal layout params. - */ - private void adjustImageViewBounds(final MessagePartData imageAttachment) { - Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType())); - final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams(); - if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE || - imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) { - // We don't know the size of the image attachment, enable letterboxing on the image - // and show a fixed sized attachment. This should happen at most once per image since - // after the image is loaded we then save the image dimensions to the db so that the - // next time we can display the full size. - layoutParams.width = getResources() - .getDimensionPixelSize(R.dimen.image_attachment_fallback_width); - layoutParams.height = getResources() - .getDimensionPixelSize(R.dimen.image_attachment_fallback_height); - mMessageImageView.setScaleType(ScaleType.CENTER_CROP); - } else { - layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; - // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However, - // FIT_CENTER works better for small images as it enlarges the image such that the - // minimum size ("android:minWidth" etc) is honored. - mMessageImageView.setScaleType(ScaleType.FIT_CENTER); - } - } - - @Override - public void onClick(final View view) { - final Object tag = view.getTag(); - if (tag instanceof MessagePartData) { - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); - onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */); - } else if (tag instanceof String) { - // Currently the only object that would make a tag of a string is a youtube preview - // image - UIIntents.get().launchBrowserForUrl(getContext(), (String) tag); - } - } - - @Override - public boolean onLongClick(final View view) { - if (view == mMessageTextView) { - // Avoid trying to reselect the message - if (isSelected()) { - return false; - } - - // Preemptively handle the long click event on message text so it's not handled by - // the link spans. - return performLongClick(); - } - - final Object tag = view.getTag(); - if (tag instanceof MessagePartData) { - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); - return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */); - } - - return false; - } - - @Override - public boolean onAttachmentClick(final MessagePartData attachment, - final Rect viewBoundsOnScreen, final boolean longPress) { - return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress); - } - - public ContactIconView getContactIconView() { - return mContactIconView; - } - - // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView - static final Comparator sImageComparator = new Comparator(){ - @Override - public int compare(final MessagePartData x, final MessagePartData y) { - return x.getPartId().compareTo(y.getPartId()); - } - }; - - static final Predicate sVideoFilter = MessagePartData::isVideo; - - static final Predicate sAudioFilter = MessagePartData::isAudio; - - static final Predicate sVCardFilter = MessagePartData::isVCard; - - static final Predicate sImageFilter = MessagePartData::isImage; - - interface AttachmentViewBinder { - void bindView(View view, MessagePartData attachment); - void unbind(View view); - } - - final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming()); - } - - @Override - public void unbind(final View view) { - ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming()); - } - }; - - final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - final AudioAttachmentView audioView = (AudioAttachmentView) view; - audioView.bindMessagePartData(attachment, mData.getIsIncoming(), isSelected()); - audioView.setBackground(ConversationDrawables.get().getBubbleDrawable( - isSelected(), mData.getIsIncoming(), false /* needArrow */, - mData.hasIncomingErrorStatus())); - } - - @Override - public void unbind(final View view) { - ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming(), false); - } - }; - - final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - final PersonItemView personView = (PersonItemView) view; - personView.bind(DataModel.get().createVCardContactItemData(getContext(), - attachment)); - personView.setBackground(ConversationDrawables.get().getBubbleDrawable( - isSelected(), mData.getIsIncoming(), false /* needArrow */, - mData.hasIncomingErrorStatus())); - final int nameTextColorRes; - final int detailsTextColorRes; - if (isSelected()) { - nameTextColorRes = R.color.message_text_color_incoming; - detailsTextColorRes = R.color.message_text_color_incoming; - } else { - nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming - : R.color.message_text_color_outgoing; - detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming - : R.color.timestamp_text_outgoing; - } - personView.setNameTextColor(getResources().getColor(nameTextColorRes)); - personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes)); - } - - @Override - public void unbind(final View view) { - ((PersonItemView) view).bind(null); - } - }; - - /** - * A helper class that allows us to handle long clicks on linkified message text view (i.e. to - * select the message) so it's not handled by the link spans to launch apps for the links. - */ - private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener { - private boolean mIsLongClick; - private final OnLongClickListener mDelegateLongClickListener; - - /** - * Ignore long clicks on linkified texts for a given text view. - * @param textView the TextView to ignore long clicks on - * @param longClickListener a delegate OnLongClickListener to be called when the view is - * long clicked. - */ - public static void ignoreLinkLongClick(final TextView textView, - @Nullable final OnLongClickListener longClickListener) { - final IgnoreLinkLongClickHelper helper = - new IgnoreLinkLongClickHelper(longClickListener); - textView.setOnLongClickListener(helper); - textView.setOnTouchListener(helper); - } - - private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) { - mDelegateLongClickListener = longClickListener; - } - - @Override - public boolean onLongClick(final View v) { - // Record that this click is a long click. - mIsLongClick = true; - if (mDelegateLongClickListener != null) { - return mDelegateLongClickListener.onLongClick(v); - } - return false; - } - - @Override - public boolean onTouch(final View v, final MotionEvent event) { - if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) { - // This touch event is a long click, preemptively handle this touch event so that - // the link span won't get a onClicked() callback. - mIsLongClick = false; - return false; - } - - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - mIsLongClick = false; - } - return false; - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java deleted file mode 100644 index f7e10169..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java +++ /dev/null @@ -1,122 +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.ui.conversation; - -import android.content.Context; -import android.text.TextUtils; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.ThreadUtil; - -import androidx.core.util.Pair; - -/** - * Manages showing/hiding the SIM selector in conversation. - */ -abstract class ConversationSimSelector extends ConversationInput { - private SimSelectorView mSimSelectorView; - private Pair mPendingShow; - private boolean mDataReady; - private String mSelectedSimText; - - public ConversationSimSelector(ConversationInputBase baseHost) { - super(baseHost, false); - } - - public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) { - ensureSimSelectorView(); - mSimSelectorView.bind(subscriptionListData); - mDataReady = subscriptionListData != null && subscriptionListData.hasData(); - if (mPendingShow != null && mDataReady) { - final boolean show = mPendingShow.first; - final boolean animate = mPendingShow.second; - ThreadUtil.getMainThreadHandler().post(new Runnable() { - @Override - public void run() { - // This will No-Op if we are no longer attached to the host. - mConversationInputBase.showHideInternal(ConversationSimSelector.this, - show, animate); - } - }); - mPendingShow = null; - } - } - - private void announcedSelectedSim() { - final Context context = Factory.get().getApplicationContext(); - if (AccessibilityUtil.isTouchExplorationEnabled(context) && - !TextUtils.isEmpty(mSelectedSimText)) { - AccessibilityUtil.announceForAccessibilityCompat( - mSimSelectorView, null, - context.getString(R.string.selected_sim_content_message, mSelectedSimText)); - } - } - - public void setSelected(final SubscriptionListEntry subEntry) { - mSelectedSimText = subEntry == null ? null : subEntry.displayName; - } - - @Override - public boolean show(boolean animate) { - announcedSelectedSim(); - return showHide(true, animate); - } - - @Override - public boolean hide(boolean animate) { - return showHide(false, animate); - } - - private boolean showHide(final boolean show, final boolean animate) { - if (mDataReady) { - mSimSelectorView.showOrHide(show, animate); - return mSimSelectorView.isOpen() == show; - } else { - mPendingShow = Pair.create(show, animate); - return false; - } - } - - private void ensureSimSelectorView() { - if (mSimSelectorView == null) { - // Grab the SIM selector view from the host. This class assumes ownership of it. - mSimSelectorView = getSimSelectorView(); - mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId()); - mSimSelectorView.setListener(new SimSelectorViewListener() { - - @Override - public void onSimSelectorVisibilityChanged(boolean visible) { - onVisibilityChanged(visible); - } - - @Override - public void onSimItemClicked(SubscriptionListEntry item) { - selectSim(item); - } - }); - } - } - - protected abstract SimSelectorView getSimSelectorView(); - protected abstract void selectSim(final SubscriptionListEntry item); - protected abstract int getSimSelectorItemLayoutId(); - -} diff --git a/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt new file mode 100644 index 00000000..eaf07203 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation + +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalResources +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel + +@Composable +internal fun ConversationSubscriptionLabel.resolveDisplayName(): String { + return resolveDisplayName(resources = LocalResources.current) +} + +internal fun ConversationSubscriptionLabel.resolveDisplayName( + resources: Resources, +): String { + return when (this) { + is ConversationSubscriptionLabel.Named -> name + + is ConversationSubscriptionLabel.Slot -> { + resources.getString(R.string.sim_slot_identifier, slotId.toString()) + } + + is ConversationSubscriptionLabel.DebugFake -> { + resources.getString(R.string.debug_emulated_sim_display_name, slotId.toString()) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt new file mode 100644 index 00000000..e9624d65 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -0,0 +1,110 @@ +package com.android.messaging.ui.conversation + +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver + +internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar" +internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" +internal const val CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG = + "conversation_attachment_contact_menu_item" +internal const val CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG = + "conversation_attachment_audio_menu_item" +internal const val CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG = + "conversation_attachment_media_menu_item" +internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = + "conversation_attachment_preview_list" +internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = "conversation_add_people_button" +internal const val CONVERSATION_CALL_BUTTON_TEST_TAG = "conversation_call_button" +internal const val CONVERSATION_OVERFLOW_BUTTON_TEST_TAG = "conversation_overflow_button" +internal const val CONVERSATION_ARCHIVE_BUTTON_TEST_TAG = "conversation_archive_button" +internal const val CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG = "conversation_unarchive_button" +internal const val CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG = "conversation_add_contact_button" +internal const val CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG = + "conversation_delete_conversation_button" +internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" +internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" +internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val CONVERSATION_MMS_INDICATOR_TEST_TAG = "conversation_mms_indicator" +internal const val CONVERSATION_SEGMENT_COUNTER_TEST_TAG = "conversation_segment_counter" +internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG = + "conversation_inline_audio_attachment_play_button" +internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = + "conversation_inline_audio_attachment_progress" +internal const val CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG = "conversation_audio_recording_bar" +internal const val CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG = + "conversation_audio_recording_cancel_button" +internal const val CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG = + "conversation_audio_recording_lock_affordance" +internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_confirm_button" +internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = "new_chat_create_group_next_button" +internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = + "new_chat_contact_resolving_indicator" +internal const val NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG = "new_chat_sim_selector_chip" +internal const val NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG = "new_chat_sim_selector_dropdown" +internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" +internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" +internal const val CONVERSATION_TEXT_FIELD_TEST_TAG = "conversation_text_field" +internal const val CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG = "conversation_top_app_bar_title" +internal const val CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG = + "conversation_sim_selector_menu_item" +internal const val CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG = "conversation_sim_selector_sheet" +internal const val CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG = + "conversation_show_subject_field_menu_item" +internal const val CONVERSATION_SUBJECT_CHIP_TEST_TAG = "conversation_subject_chip" +internal const val CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG = + "conversation_subject_chip_clear_button" +internal const val CONVERSATION_SUBJECT_DIALOG_TEST_TAG = "conversation_subject_dialog" +internal const val CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG = + "conversation_subject_dialog_text_field" +internal const val CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG = + "conversation_subject_dialog_clear_button" + +internal fun conversationSimSelectorItemTestTag(selfParticipantId: String): String { + return "conversation_sim_selector_item_$selfParticipantId" +} + +internal fun newChatSimSelectorItemTestTag(selfParticipantId: String): String { + return "new_chat_sim_selector_item_$selfParticipantId" +} + +internal fun conversationMessageItemTestTag(messageId: String): String { + return "conversation_message_item_$messageId" +} + +internal fun conversationAttachmentPreviewItemTestTag(attachmentKey: String): String { + return "conversation_attachment_preview_item_$attachmentKey" +} + +internal fun conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey: String, +): String { + return "conversation_attachment_preview_remove_button_$attachmentKey" +} + +internal fun newChatContactRowTestTag(contactId: String): String { + return "new_chat_contact_row_$contactId" +} + +internal fun newChatContactDestinationRowTestTag( + contactId: String, + destination: String, +): String { + return "new_chat_contact_destination_row_${contactId}_$destination" +} + +internal fun addParticipantsContactRowTestTag(contactId: String): String { + return "add_participants_contact_row_$contactId" +} + +internal fun addParticipantsContactDestinationRowTestTag( + contactId: String, + destination: String, +): String { + return "add_participants_contact_destination_row_${contactId}_$destination" +} + +internal val conversationShapeSemanticsKey = SemanticsPropertyKey( + name = "conversation_shape", +) + +internal var SemanticsPropertyReceiver.conversationShape by conversationShapeSemanticsKey diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java deleted file mode 100644 index ed5aaabd..00000000 --- a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java +++ /dev/null @@ -1,93 +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.ui.conversation; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.widget.EditText; - -import androidx.fragment.app.DialogFragment; - -import com.android.messaging.R; -import com.android.messaging.datamodel.ParticipantRefresh; -import com.android.messaging.util.BuglePrefs; -import com.android.messaging.util.UiUtils; - -/** - * The dialog for the user to enter the phone number of their sim. - */ -public class EnterSelfPhoneNumberDialog extends DialogFragment { - private EditText mEditText; - private int mSubId; - - public static EnterSelfPhoneNumberDialog newInstance(final int subId) { - final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog(); - dialog.mSubId = subId; - return dialog; - } - - @Override - public Dialog onCreateDialog(final Bundle savedInstanceState) { - final Context context = getActivity(); - final LayoutInflater inflater = getLayoutInflater(); - mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false); - - final AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.enter_phone_number_title) - .setMessage(R.string.enter_phone_number_text) - .setView(mEditText) - .setNegativeButton(android.R.string.cancel, - new DialogInterface.OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - dismiss(); - } - }) - .setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - final String newNumber = mEditText.getText().toString(); - dismiss(); - if (!TextUtils.isEmpty(newNumber)) { - savePhoneNumberInPrefs(newNumber); - // TODO: Remove this toast and just auto-send - // the message instead - UiUtils.showToast( - R.string - .toast_after_setting_default_sms_app_for_message_send); - } - } - }); - return builder.create(); - } - - private void savePhoneNumberInPrefs(final String newPhoneNumber) { - final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); - subPrefs.putString(getString(R.string.mms_phone_number_pref_key), - newPhoneNumber); - // Update the self participants so the new phone number will be reflected - // everywhere in the UI. - ParticipantRefresh.refreshSelfParticipants(); - } -} diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java deleted file mode 100644 index 4c229707..00000000 --- a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java +++ /dev/null @@ -1,47 +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.ui.conversation; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.LinearLayout; - -import com.android.messaging.R; - -public class MessageBubbleBackground extends LinearLayout { - private final int mSnapWidthPixels; - - public MessageBubbleBackground(Context context, AttributeSet attrs) { - super(context, attrs); - mSnapWidthPixels = context.getResources().getDimensionPixelSize( - R.dimen.conversation_bubble_width_snap); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - final int widthPadding = getPaddingLeft() + getPaddingRight(); - int bubbleWidth = getMeasuredWidth() - widthPadding; - final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding; - // Round up to next snapWidthPixels - bubbleWidth = Math.min(maxWidth, - (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels)); - super.onMeasure( - MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY), - heightMeasureSpec); - } -} diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java deleted file mode 100644 index 551042a8..00000000 --- a/src/com/android/messaging/ui/conversation/SimIconView.java +++ /dev/null @@ -1,54 +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.ui.conversation; - -import android.content.Context; -import android.graphics.Outline; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewOutlineProvider; - -import com.android.messaging.ui.ContactIconView; - -/** - * Shows SIM avatar icon in the SIM switcher / Self-send button. - */ -public class SimIconView extends ContactIconView { - public SimIconView(Context context, AttributeSet attrs) { - super(context, attrs); - setOutlineProvider(new ViewOutlineProvider() { - @Override - public void getOutline(View v, Outline outline) { - outline.setOval(0, 0, v.getWidth(), v.getHeight()); - } - }); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - if (isClickable()) { - return super.onTouchEvent(event); - } - return true; - } - - @Override - protected void maybeInitializeOnClickListener() { - // TODO: SIM icon view shouldn't consume or handle clicks, but it should if - // this is the send button for the only SIM in the device or if MSIM is not supported. - } -} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java deleted file mode 100644 index 3058d31c..00000000 --- a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java +++ /dev/null @@ -1,90 +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.ui.conversation; - -import android.content.Context; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.view.View; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.util.Assert; - -/** - * Shows a view for a SIM in the SIM selector. - */ -public class SimSelectorItemView extends LinearLayout { - public interface HostInterface { - void onSimItemClicked(SubscriptionListEntry item); - } - - private SubscriptionListEntry mData; - private TextView mNameTextView; - private TextView mDetailsTextView; - private SimIconView mSimIconView; - private HostInterface mHost; - - public SimSelectorItemView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected void onFinishInflate() { - mNameTextView = (TextView) findViewById(R.id.name); - mDetailsTextView = (TextView) findViewById(R.id.details); - mSimIconView = (SimIconView) findViewById(R.id.sim_icon); - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - mHost.onSimItemClicked(mData); - } - }); - } - - public void bind(final SubscriptionListEntry simEntry) { - Assert.notNull(simEntry); - mData = simEntry; - updateViewAppearance(); - } - - public void setHostInterface(final HostInterface host) { - mHost = host; - } - - private void updateViewAppearance() { - Assert.notNull(mData); - final String displayName = mData.displayName; - if (TextUtils.isEmpty(displayName)) { - mNameTextView.setVisibility(GONE); - } else { - mNameTextView.setVisibility(VISIBLE); - mNameTextView.setText(displayName); - } - - final String details = mData.displayDestination; - if (TextUtils.isEmpty(details)) { - mDetailsTextView.setVisibility(GONE); - } else { - mDetailsTextView.setVisibility(VISIBLE); - mDetailsTextView.setText(details); - } - - mSimIconView.setImageResourceUri(mData.iconUri); - } -} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java deleted file mode 100644 index b07ff19a..00000000 --- a/src/com/android/messaging/ui/conversation/SimSelectorView.java +++ /dev/null @@ -1,169 +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.ui.conversation; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.TranslateAnimation; -import android.widget.ArrayAdapter; -import android.widget.FrameLayout; -import android.widget.ListView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.util.UiUtils; - -import java.util.ArrayList; -import java.util.List; - -/** - * Displays a SIM selector above the compose message view and overlays the message list. - */ -public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface { - public interface SimSelectorViewListener { - void onSimItemClicked(SubscriptionListEntry item); - void onSimSelectorVisibilityChanged(boolean visible); - } - - private ListView mSimListView; - private final SimSelectorAdapter mAdapter; - private boolean mShow; - private SimSelectorViewListener mListener; - private int mItemLayoutId; - - public SimSelectorView(Context context, AttributeSet attrs) { - super(context, attrs); - mAdapter = new SimSelectorAdapter(getContext()); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mSimListView = (ListView) findViewById(R.id.sim_list); - mSimListView.setAdapter(mAdapter); - - // Clicking anywhere outside the switcher list should dismiss. - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - showOrHide(false, true); - } - }); - } - - public void bind(final SubscriptionListData data) { - mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault()); - } - - public void setItemLayoutId(final int layoutId) { - mItemLayoutId = layoutId; - } - - public void setListener(final SimSelectorViewListener listener) { - mListener = listener; - } - - public void toggleVisibility() { - showOrHide(!mShow, true); - } - - public void showOrHide(final boolean show, final boolean animate) { - final boolean oldShow = mShow; - mShow = show && mAdapter.getCount() > 1; - if (oldShow != mShow) { - if (mListener != null) { - mListener.onSimSelectorVisibilityChanged(mShow); - } - - if (animate) { - // Fade in the background pane. - setVisibility(VISIBLE); - setAlpha(mShow ? 0.0f : 1.0f); - animate().alpha(mShow ? 1.0f : 0.0f) - .setDuration(UiUtils.REVEAL_ANIMATION_DURATION) - .withEndAction(new Runnable() { - @Override - public void run() { - setAlpha(1.0f); - setVisibility(mShow ? VISIBLE : GONE); - } - }); - } else { - setVisibility(mShow ? VISIBLE : GONE); - } - - // Slide in the SIM selector list via a translate animation. - mSimListView.setVisibility(mShow ? VISIBLE : GONE); - if (animate) { - mSimListView.clearAnimation(); - final TranslateAnimation translateAnimation = new TranslateAnimation( - Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, - Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f, - Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f); - translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); - translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION); - mSimListView.startAnimation(translateAnimation); - } - } - } - - /** - * An adapter that takes a list of SubscriptionListEntry and displays them as a list of - * available SIMs in the SIM selector. - */ - private class SimSelectorAdapter extends ArrayAdapter { - public SimSelectorAdapter(final Context context) { - super(context, R.layout.sim_selector_item_view, new ArrayList()); - } - - public void bindData(final List newList) { - clear(); - addAll(newList); - notifyDataSetChanged(); - } - - @Override - public View getView(final int position, final View convertView, final ViewGroup parent) { - SimSelectorItemView itemView; - if (convertView != null && convertView instanceof SimSelectorItemView) { - itemView = (SimSelectorItemView) convertView; - } else { - final LayoutInflater inflater = (LayoutInflater) getContext() - .getSystemService(Context.LAYOUT_INFLATER_SERVICE); - itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId, - parent, false); - itemView.setHostInterface(SimSelectorView.this); - } - itemView.bind(getItem(position)); - return itemView; - } - } - - @Override - public void onSimItemClicked(SubscriptionListEntry item) { - mListener.onSimItemClicked(item); - showOrHide(false, true); - } - - public boolean isOpen() { - return mShow; - } -} diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt new file mode 100644 index 00000000..7d216809 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt @@ -0,0 +1,165 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, +) + +package com.android.messaging.ui.conversation.addparticipants + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.R +import com.android.messaging.ui.conversation.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.addParticipantsContactDestinationRowTestTag +import com.android.messaging.ui.conversation.addParticipantsContactRowTestTag +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionStrings +import com.android.messaging.util.UiUtils +import kotlinx.collections.immutable.toImmutableSet + +@Composable +internal fun AddParticipantsScreen( + conversationId: String, + modifier: Modifier = Modifier, + onNavigateBack: () -> Unit = {}, + onNavigateToConversation: (String) -> Unit = {}, + screenModel: AddParticipantsModel = hiltViewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(conversationId, screenModel) { + screenModel.onConversationIdChanged(conversationId = conversationId) + } + + LaunchedEffect(screenModel, onNavigateToConversation) { + screenModel.effects.collect { effect -> + when (effect) { + is AddParticipantsEffect.NavigateToConversation -> { + onNavigateToConversation(effect.conversationId) + } + + is AddParticipantsEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + } + } + } + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + title = { + Text(text = stringResource(id = R.string.conversation_add_people)) + }, + ) + }, + ) { contentPadding -> + AddParticipantsRecipientSelectionContent( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + uiState = uiState, + onLoadMore = screenModel::onLoadMore, + onQueryChanged = screenModel::onQueryChanged, + onConfirmClick = screenModel::onConfirmClick, + onRecipientClick = screenModel::onRecipientClicked, + ) + } +} + +@Composable +private fun AddParticipantsRecipientSelectionContent( + uiState: AddParticipantsUiState, + onConfirmClick: () -> Unit, + onLoadMore: () -> Unit, + onQueryChanged: (String) -> Unit, + onRecipientClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val primaryAction = when { + uiState.selectedRecipientDestinations.isNotEmpty() -> { + RecipientSelectionPrimaryActionUiState( + text = stringResource(id = R.string.conversation_add_people), + isEnabled = !uiState.isLoadingConversationParticipants && + !uiState.recipientPickerUiState.isLoading && + !uiState.isResolvingConversation, + isLoading = uiState.isResolvingConversation, + testTag = ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG, + ) + } + + else -> null + } + + RecipientSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = uiState.recipientPickerUiState.copy( + isLoading = uiState.isLoadingConversationParticipants || + uiState.recipientPickerUiState.isLoading, + ), + primaryAction = primaryAction, + selectedRecipientDestinations = uiState.selectedRecipientDestinations.toImmutableSet(), + isQueryEnabled = !uiState.isResolvingConversation && + !uiState.isLoadingConversationParticipants, + ), + strings = RecipientSelectionStrings( + queryPrefixText = stringResource(id = R.string.to_address_label), + queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), + ), + rowDecorators = RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + addParticipantsContactRowTestTag(contactId = item.id) + }, + destinationRowTestTag = { item, destination -> + addParticipantsContactDestinationRowTestTag( + contactId = item.id, + destination = destination, + ) + }, + ), + onRecipientDestinationClick = { _, destination -> + onRecipientClick(destination) + }, + modifier = modifier, + onLoadMore = onLoadMore, + onPrimaryActionClick = onConfirmClick, + onQueryChanged = onQueryChanged, + ) +} diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt new file mode 100644 index 00000000..8396d0df --- /dev/null +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt @@ -0,0 +1,300 @@ +package com.android.messaging.ui.conversation.addparticipants + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.R +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +internal interface AddParticipantsModel { + val effects: Flow + val uiState: StateFlow + + fun onConversationIdChanged(conversationId: String?) + fun onLoadMore() + fun onQueryChanged(query: String) + fun onRecipientClicked(destination: String) + fun onConfirmClick() +} + +@HiltViewModel +internal class AddParticipantsViewModel @Inject constructor( + private val contactDestinationFormatter: ContactDestinationFormatter, + private val conversationParticipantsRepository: ConversationParticipantsRepository, + private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, + private val recipientPickerDelegate: RecipientPickerDelegate, + private val resolveConversationId: ResolveConversationId, + private val savedStateHandle: SavedStateHandle, + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ViewModel(), + AddParticipantsModel { + + private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( + key = CONVERSATION_ID_KEY, + initialValue = null, + ) + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val localUiState = MutableStateFlow( + value = LocalAddParticipantsUiState(), + ) + + override val effects = _effects.asSharedFlow() + + override val uiState: StateFlow = combine( + localUiState, + recipientPickerDelegate.state, + ) { localState, recipientPickerUiState -> + AddParticipantsUiState( + existingParticipants = localState.existingParticipants, + isLoadingConversationParticipants = localState.isLoadingConversationParticipants, + isResolvingConversation = localState.isResolvingConversation, + recipientPickerUiState = recipientPickerUiState, + selectedRecipientDestinations = localState.selectedRecipientDestinations, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = AddParticipantsUiState( + existingParticipants = localUiState.value.existingParticipants, + isLoadingConversationParticipants = localUiState + .value + .isLoadingConversationParticipants, + isResolvingConversation = localUiState.value.isResolvingConversation, + recipientPickerUiState = recipientPickerDelegate.state.value, + selectedRecipientDestinations = localUiState.value.selectedRecipientDestinations, + ), + ) + + init { + recipientPickerDelegate.bind(scope = viewModelScope) + bindConversationParticipants() + } + + private fun bindConversationParticipants() { + viewModelScope.launch(mainDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + updateLocalUiState( + localUiState.value.copy( + existingParticipants = persistentEmptyParticipants(), + existingParticipantCanonicalDestinations = persistentSetOf(), + isLoadingConversationParticipants = conversationId != null, + isResolvingConversation = false, + selectedRecipientDestinations = persistentEmptyDestinations(), + ), + ) + recipientPickerDelegate.onExcludedDestinationsChanged( + destinations = emptySet(), + ) + + if (conversationId == null) { + return@collectLatest + } + + conversationParticipantsRepository + .getParticipants(conversationId = conversationId) + .collect { participants -> + val canonicalDestinations = participants + .map { participant -> + contactDestinationFormatter.canonicalize( + value = participant.destination, + ) + } + .toImmutableSet() + + val selectedDestinations = localUiState.value + .selectedRecipientDestinations + .filterNot { selectedDestination -> + selectedDestination in canonicalDestinations + } + .toImmutableList() + + updateLocalUiState( + localUiState.value.copy( + existingParticipants = participants, + existingParticipantCanonicalDestinations = canonicalDestinations, + isLoadingConversationParticipants = false, + selectedRecipientDestinations = selectedDestinations, + ), + ) + + recipientPickerDelegate.onExcludedDestinationsChanged( + destinations = participants + .map { participant -> + participant.destination + } + .toSet(), + ) + } + } + } + } + + override fun onConversationIdChanged(conversationId: String?) { + if (conversationId != conversationIdFlow.value) { + savedStateHandle[CONVERSATION_ID_KEY] = conversationId + } + } + + override fun onLoadMore() { + recipientPickerDelegate.onLoadMore() + } + + override fun onQueryChanged(query: String) { + recipientPickerDelegate.onQueryChanged(query = query) + } + + override fun onRecipientClicked(destination: String) { + val trimmedDestination = destination.trim() + val currentUiState = localUiState.value + + val shouldIgnoreRecipientClick = trimmedDestination.isEmpty() || + currentUiState.isLoadingConversationParticipants || + currentUiState.isResolvingConversation || + trimmedDestination in currentUiState.existingParticipantCanonicalDestinations + + if (shouldIgnoreRecipientClick) { + return + } + + val nextSelectedDestinations = when { + trimmedDestination in currentUiState.selectedRecipientDestinations -> { + currentUiState.selectedRecipientDestinations - trimmedDestination + } + + else -> { + currentUiState.selectedRecipientDestinations + trimmedDestination + } + } + + updateLocalUiState( + currentUiState.copy( + selectedRecipientDestinations = nextSelectedDestinations.toImmutableList(), + ), + ) + } + + override fun onConfirmClick() { + val currentUiState = localUiState.value + + val shouldIgnoreConfirmClick = currentUiState.isLoadingConversationParticipants || + currentUiState.isResolvingConversation || + currentUiState.selectedRecipientDestinations.isEmpty() + + if (shouldIgnoreConfirmClick) { + return + } + + val allDestinations = ( + currentUiState.existingParticipants.map { participant -> + participant.destination + } + currentUiState.selectedRecipientDestinations + ).distinct() + + if (isConversationRecipientLimitExceeded(participantCount = allDestinations.size)) { + showMessage(messageResId = R.string.too_many_participants) + return + } + + viewModelScope.launch(mainDispatcher) { + updateLocalUiState( + currentUiState.copy( + isResolvingConversation = true, + ), + ) + + when (val result = resolveConversationId(destinations = allDestinations)) { + is ResolveConversationIdResult.Resolved -> { + updateLocalUiState( + localUiState.value.copy( + isResolvingConversation = false, + selectedRecipientDestinations = persistentEmptyDestinations(), + ), + ) + _effects.tryEmit( + AddParticipantsEffect.NavigateToConversation( + conversationId = result.conversationId, + ), + ) + } + + ResolveConversationIdResult.EmptyDestinations, + ResolveConversationIdResult.NotResolved, + -> { + updateLocalUiState( + localUiState.value.copy( + isResolvingConversation = false, + ), + ) + showMessage(messageResId = R.string.conversation_creation_failure) + } + } + } + } + + private fun showMessage(messageResId: Int) { + _effects.tryEmit( + AddParticipantsEffect.ShowMessage( + messageResId = messageResId, + ), + ) + } + + private fun updateLocalUiState(uiState: LocalAddParticipantsUiState) { + localUiState.value = uiState + } + + private fun persistentEmptyParticipants(): PersistentList { + return persistentListOf() + } + + private fun persistentEmptyDestinations(): PersistentList { + return persistentListOf() + } + + private data class LocalAddParticipantsUiState( + val existingParticipants: ImmutableList = persistentListOf(), + val existingParticipantCanonicalDestinations: ImmutableSet = persistentSetOf(), + val isLoadingConversationParticipants: Boolean = true, + val isResolvingConversation: Boolean = false, + val selectedRecipientDestinations: ImmutableList = persistentListOf(), + ) + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt new file mode 100644 index 00000000..4abea796 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.conversation.addparticipants.model + +internal sealed interface AddParticipantsEffect { + + data class NavigateToConversation( + val conversationId: String, + ) : AddParticipantsEffect + + data class ShowMessage( + val messageResId: Int, + ) : AddParticipantsEffect +} diff --git a/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt new file mode 100644 index 00000000..3d9d3845 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.addparticipants.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class AddParticipantsUiState( + val existingParticipants: ImmutableList = persistentListOf(), + val isLoadingConversationParticipants: Boolean = true, + val isResolvingConversation: Boolean = false, + val recipientPickerUiState: RecipientPickerUiState = RecipientPickerUiState(), + val selectedRecipientDestinations: ImmutableList = persistentListOf(), +) diff --git a/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt new file mode 100644 index 00000000..ce841d68 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt @@ -0,0 +1,141 @@ +package com.android.messaging.ui.conversation.attachment.mapper + +import com.android.messaging.R +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import javax.inject.Inject + +internal interface ConversationVCardAttachmentUiModelMapper { + fun map( + metadata: ConversationVCardAttachmentMetadata?, + ): ConversationVCardAttachmentUiModel +} + +internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor() : + ConversationVCardAttachmentUiModelMapper { + + override fun map( + metadata: ConversationVCardAttachmentMetadata?, + ): ConversationVCardAttachmentUiModel { + return mapConversationVCardAttachmentUiModel( + metadata = metadata, + defaultTitleTextResId = R.string.notification_vcard, + defaultSubtitleTextResId = R.string.vcard_tap_hint, + failedSubtitleTextResId = R.string.failed_loading_vcard, + loadingSubtitleTextResId = R.string.loading_vcard, + locationTitleTextResId = R.string.notification_location, + ) + } + + private fun mapConversationVCardAttachmentUiModel( + metadata: ConversationVCardAttachmentMetadata?, + defaultTitleTextResId: Int?, + defaultSubtitleTextResId: Int?, + failedSubtitleTextResId: Int, + loadingSubtitleTextResId: Int, + locationTitleTextResId: Int, + ): ConversationVCardAttachmentUiModel { + return when (metadata) { + ConversationVCardAttachmentMetadata.Failed -> { + createConversationContactUiModel( + avatarUri = null, + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = failedSubtitleTextResId, + ) + } + + ConversationVCardAttachmentMetadata.Loading -> { + createConversationContactUiModel( + avatarUri = null, + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = loadingSubtitleTextResId, + ) + } + + ConversationVCardAttachmentMetadata.Missing, + null, + -> { + createConversationContactUiModel( + avatarUri = null, + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = defaultSubtitleTextResId, + ) + } + + is ConversationVCardAttachmentMetadata.Loaded -> { + mapLoadedConversationVCardAttachmentUiModel( + metadata = metadata, + defaultTitleTextResId = defaultTitleTextResId, + defaultSubtitleTextResId = defaultSubtitleTextResId, + locationTitleTextResId = locationTitleTextResId, + ) + } + } + } + + private fun mapLoadedConversationVCardAttachmentUiModel( + metadata: ConversationVCardAttachmentMetadata.Loaded, + defaultTitleTextResId: Int?, + defaultSubtitleTextResId: Int?, + locationTitleTextResId: Int, + ): ConversationVCardAttachmentUiModel { + return when (metadata.type) { + ConversationVCardAttachmentType.CONTACT -> { + createConversationContactUiModel( + avatarUri = metadata.avatarUri, + titleText = metadata.displayName, + titleTextResId = if (metadata.displayName == null) { + defaultTitleTextResId + } else { + null + }, + subtitleText = metadata.details, + subtitleTextResId = if (metadata.details == null) { + defaultSubtitleTextResId + } else { + null + }, + ) + } + + ConversationVCardAttachmentType.LOCATION -> { + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.LOCATION, + avatarUri = metadata.avatarUri, + titleText = metadata.displayName, + titleTextResId = if (metadata.displayName == null) { + locationTitleTextResId + } else { + null + }, + subtitleText = metadata.locationAddress ?: metadata.details, + subtitleTextResId = null, + ) + } + } + } + + private fun createConversationContactUiModel( + avatarUri: String?, + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, + ): ConversationVCardAttachmentUiModel { + return ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + avatarUri = avatarUri, + titleText = titleText, + titleTextResId = titleTextResId, + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt new file mode 100644 index 00000000..12893ebe --- /dev/null +++ b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt @@ -0,0 +1,14 @@ +package com.android.messaging.ui.conversation.attachment.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType + +@Immutable +internal data class ConversationVCardAttachmentUiModel( + val type: ConversationVCardAttachmentType, + val avatarUri: String? = null, + val titleText: String? = null, + val titleTextResId: Int? = null, + val subtitleText: String? = null, + val subtitleTextResId: Int? = null, +) diff --git a/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt new file mode 100644 index 00000000..faaaaa9a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt @@ -0,0 +1,275 @@ +package com.android.messaging.ui.conversation.attachment.ui + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import com.android.messaging.util.ContentType + +private const val THUMBNAIL_FADE_IN_DURATION_MILLIS = 90 + +@Composable +internal fun ConversationMediaThumbnail( + modifier: Modifier = Modifier, + contentUri: String, + contentType: String, + size: IntSize, + contentScale: ContentScale = ContentScale.Crop, + crossfadeEnabled: Boolean = true, + backgroundColor: Color = Color.Unspecified, + useBitmapLoader: Boolean = false, + softenBitmap: Boolean = false, +) { + val context = LocalContext.current + + val contentUriAsUri = rememberContentUri(contentUri = contentUri) + val normalizedSize = remember(size) { + size.sanitized() + } + + val resolvedBackgroundColor = resolveBackgroundColor(backgroundColor = backgroundColor) + + val shouldUseCoilImageLoader = shouldUseCoilImageLoader( + contentType = contentType, + useBitmapLoader = useBitmapLoader, + ) + + when { + shouldUseCoilImageLoader -> { + CoilThumbnail( + modifier = modifier, + context = context, + contentUri = contentUriAsUri, + size = normalizedSize, + contentScale = contentScale, + crossfadeEnabled = crossfadeEnabled, + ) + } + + else -> { + BitmapThumbnail( + modifier = modifier, + contentUri = contentUriAsUri, + contentType = contentType, + size = normalizedSize, + contentScale = contentScale, + crossfadeEnabled = crossfadeEnabled, + backgroundColor = resolvedBackgroundColor, + softenBitmap = softenBitmap, + useBitmapLoader = useBitmapLoader, + ) + } + } +} + +@Composable +internal fun rememberConversationMediaThumbnailBitmap( + contentUri: Uri, + contentType: String, + size: IntSize, + softenBitmap: Boolean = false, +): Bitmap? { + val context = LocalContext.current + + val bitmap by produceState( + initialValue = null, + contentUri, + contentType, + size, + softenBitmap, + ) { + value = loadConversationMediaThumbnailBitmap( + contentResolver = context.contentResolver, + contentUri = contentUri, + contentType = contentType, + size = size, + softenBitmap = softenBitmap, + ) + } + + return bitmap +} + +@Composable +private fun BitmapThumbnail( + modifier: Modifier, + contentUri: Uri, + contentType: String, + size: IntSize, + contentScale: ContentScale, + crossfadeEnabled: Boolean, + backgroundColor: Color, + softenBitmap: Boolean, + useBitmapLoader: Boolean, +) { + val bitmap = rememberConversationMediaThumbnailBitmap( + contentUri = contentUri, + contentType = contentType, + size = size, + softenBitmap = softenBitmap, + ) + val bitmapAlpha = rememberThumbnailAlpha( + crossfadeEnabled = crossfadeEnabled, + isLoaded = bitmap != null, + animationLabel = "conversationMediaThumbnailBitmapAlpha", + ) + val filterQuality = resolveBitmapFilterQuality(useBitmapLoader = useBitmapLoader) + + Box(modifier = modifier) { + ThumbnailPlaceholder( + modifier = Modifier.fillMaxSize(), + backgroundColor = backgroundColor, + ) + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = contentScale, + filterQuality = filterQuality, + modifier = Modifier + .fillMaxSize() + .alpha(alpha = bitmapAlpha), + ) + } + } +} + +@Composable +private fun CoilThumbnail( + modifier: Modifier, + context: Context, + contentUri: Uri, + size: IntSize, + contentScale: ContentScale, + crossfadeEnabled: Boolean, +) { + val imageRequest = remember( + context, + contentUri, + size, + ) { + ImageRequest.Builder(context) + .data(contentUri) + .size(width = size.width, height = size.height) + .build() + } + + val isImageLoaded = remember(contentUri, size, crossfadeEnabled) { + mutableStateOf(value = !crossfadeEnabled) + } + + val visibilityAlpha = rememberThumbnailAlpha( + crossfadeEnabled = crossfadeEnabled, + isLoaded = isImageLoaded.value, + animationLabel = "conversationMediaThumbnailImageAlpha", + ) + + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = contentScale, + filterQuality = FilterQuality.Low, + modifier = modifier.alpha(alpha = visibilityAlpha), + onError = { + isImageLoaded.value = true + }, + onSuccess = { + isImageLoaded.value = true + }, + ) +} + +@Composable +private fun ThumbnailPlaceholder( + modifier: Modifier, + backgroundColor: Color, +) { + Surface( + modifier = modifier, + color = backgroundColor, + ) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + } + } +} + +@Composable +private fun rememberContentUri( + contentUri: String, +): Uri { + return remember(contentUri) { + contentUri.toUri() + } +} + +@Composable +private fun rememberThumbnailAlpha( + crossfadeEnabled: Boolean, + isLoaded: Boolean, + animationLabel: String, +): Float { + val targetAlpha = when { + !crossfadeEnabled || isLoaded -> 1f + else -> 0f + } + + val animatedAlpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = THUMBNAIL_FADE_IN_DURATION_MILLIS), + label = animationLabel, + ) + + return animatedAlpha +} + +@Composable +private fun resolveBackgroundColor( + backgroundColor: Color, +): Color { + return when { + backgroundColor != Color.Unspecified -> backgroundColor + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } +} + +private fun shouldUseCoilImageLoader( + contentType: String, + useBitmapLoader: Boolean, +): Boolean { + return ContentType.isImageType(contentType) && !useBitmapLoader +} + +private fun resolveBitmapFilterQuality(useBitmapLoader: Boolean): FilterQuality { + return when { + useBitmapLoader -> FilterQuality.Medium + else -> FilterQuality.Low + } +} diff --git a/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt new file mode 100644 index 00000000..617d1aa8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt @@ -0,0 +1,306 @@ +package com.android.messaging.ui.conversation.attachment.ui + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapFactory.Options +import android.net.Uri +import android.util.Size +import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.scale +import com.android.messaging.util.ContentType +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val FIRST_PASS_DIVISOR = 12 +private const val FIRST_PASS_MAXIMUM_SIZE = 24 +private const val FIRST_PASS_MINIMUM_SIZE = 12 +private const val SECOND_PASS_DIVISOR = 3 +private const val SECOND_PASS_MAXIMUM_SIZE = 48 +private const val SECOND_PASS_MINIMUM_SIZE = 24 + +internal suspend fun loadConversationMediaThumbnailBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return withContext(context = Dispatchers.IO) { + val rawBitmap = loadPlatformThumbnail( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) ?: loadFallbackBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = contentType, + size = size, + ) + + maybeSoftenBitmap( + bitmap = rawBitmap, + outputSize = size, + softenBitmap = softenBitmap, + ) + } +} + +private fun loadPlatformThumbnail( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + contentResolver.loadThumbnail( + contentUri, + Size(size.width, size.height), + null, + ) + }.getOrNull() +} + +private fun loadFallbackBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, +): Bitmap? { + return when { + ContentType.isImageType(contentType) -> { + loadImageBitmapFallback( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) + } + + ContentType.isVideoType(contentType) -> { + loadVideoFrameFallback( + contentUri = contentUri, + size = size, + ) + } + + else -> null + } +} + +private fun loadImageBitmapFallback( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + val decodeBoundsOptions = Options().apply { + inJustDecodeBounds = true + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) + } + + val decodeBitmapOptions = Options().apply { + inSampleSize = calculateBitmapSampleSize( + sourceWidth = decodeBoundsOptions.outWidth, + sourceHeight = decodeBoundsOptions.outHeight, + targetSize = size, + ) + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBitmapOptions) + } + }.getOrNull() +} + +private fun loadVideoFrameFallback( + contentUri: Uri, + size: IntSize, +): Bitmap? { + val retriever = MediaMetadataRetrieverWrapper() + + return try { + runCatching { + retriever.setDataSource(contentUri) + retriever.frameAtTime?.let { bitmap -> + scaleBitmapDownIfNeeded( + bitmap = bitmap, + targetSize = size, + ) + } + }.getOrNull() + } finally { + retriever.release() + } +} + +private fun maybeSoftenBitmap( + bitmap: Bitmap?, + outputSize: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return when { + bitmap == null -> null + !softenBitmap -> bitmap + + else -> { + createSoftenedBitmap( + sourceBitmap = bitmap, + outputSize = outputSize, + ) + } + } +} + +private fun createSoftenedBitmap( + sourceBitmap: Bitmap, + outputSize: IntSize, +): Bitmap { + val sanitizedOutputSize = outputSize.sanitized() + val targetWidth = sanitizedOutputSize.width + val targetHeight = sanitizedOutputSize.height + val centerCroppedBitmap = createCenterCroppedBitmap( + sourceBitmap = sourceBitmap, + targetSize = sanitizedOutputSize, + ) + + // Multi-pass downscaling keeps softened placeholders smooth without introducing blur kernels + val firstPassWidth = (targetWidth / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val firstPassHeight = (targetHeight / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val secondPassWidth = (targetWidth / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + val secondPassHeight = (targetHeight / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + + val firstPassBitmap = centerCroppedBitmap.scale(firstPassWidth, firstPassHeight) + val secondPassBitmap = firstPassBitmap.scale(secondPassWidth, secondPassHeight) + + return secondPassBitmap.scale(targetWidth, targetHeight) +} + +private fun createCenterCroppedBitmap( + sourceBitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val sanitizedTargetSize = targetSize.sanitized() + val targetAspectRatio = + sanitizedTargetSize.width.toFloat() / sanitizedTargetSize.height.toFloat() + val sourceAspectRatio = sourceBitmap.width.toFloat() / sourceBitmap.height.toFloat() + + val cropWidth: Int + val cropHeight: Int + + when { + sourceAspectRatio > targetAspectRatio -> { + cropHeight = sourceBitmap.height + cropWidth = (cropHeight * targetAspectRatio).toInt() + } + + else -> { + cropWidth = sourceBitmap.width + cropHeight = (cropWidth / targetAspectRatio).toInt() + } + } + + val left = (sourceBitmap.width - cropWidth) / 2 + val top = (sourceBitmap.height - cropHeight) / 2 + + return Bitmap.createBitmap( + sourceBitmap, + left, + top, + cropWidth.coerceAtLeast(minimumValue = 1), + cropHeight.coerceAtLeast(minimumValue = 1), + ) +} + +private fun calculateBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + targetSize: IntSize, +): Int { + if (sourceWidth <= 0 || sourceHeight <= 0) { + return 1 + } + + var sampleSize = 1 + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + while ( + canDoubleBitmapSampleSize( + sourceWidth = sourceWidth, + sourceHeight = sourceHeight, + sampleSize = sampleSize, + targetWidth = targetWidth, + targetHeight = targetHeight, + ) + ) { + sampleSize *= 2 + } + + return sampleSize +} + +private fun canDoubleBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + sampleSize: Int, + targetWidth: Int, + targetHeight: Int, +): Boolean { + val doubledSampleSize = sampleSize * 2 + val doubledDecodedWidth = sourceWidth / doubledSampleSize + val doubledDecodedHeight = sourceHeight / doubledSampleSize + + return doubledDecodedWidth >= targetWidth && + doubledDecodedHeight >= targetHeight +} + +private fun scaleBitmapDownIfNeeded( + bitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + if (bitmap.width <= targetWidth && bitmap.height <= targetHeight) { + return bitmap + } + + val widthScale = targetWidth.toFloat() / bitmap.width.toFloat() + val heightScale = targetHeight.toFloat() / bitmap.height.toFloat() + val scale = minOf(widthScale, heightScale) + + return bitmap.scale( + width = (bitmap.width * scale).toInt().coerceAtLeast(minimumValue = 1), + height = (bitmap.height * scale).toInt().coerceAtLeast(minimumValue = 1), + ) +} + +internal fun IntSize.sanitized(): IntSize { + if (width >= 1 && height >= 1) { + return this + } + + return IntSize( + width = width.coerceAtLeast(minimumValue = 1), + height = height.coerceAtLeast(minimumValue = 1), + ) +} diff --git a/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt new file mode 100644 index 00000000..20c48472 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt @@ -0,0 +1,224 @@ +package com.android.messaging.ui.conversation.attachment.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Place +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.util.UriUtil + +private val VCARD_AVATAR_SIZE = 36.dp +private val VCARD_AVATAR_ICON_SIZE = 20.dp + +@Composable +internal fun ConversationVCardAttachmentCardContent( + modifier: Modifier = Modifier, + type: ConversationVCardAttachmentType, + avatarUri: String?, + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, +) { + val title = resolveTitleText( + titleText = titleText, + titleTextResId = titleTextResId, + ) + + val subtitle = resolveSubtitleText( + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationVCardAttachmentLeadingVisual( + type = type, + avatarUri = avatarUri, + titleText = titleText, + ) + + Column( + modifier = Modifier.weight(weight = 1f, fill = false), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + subtitle?.let { subtitleText -> + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun ConversationVCardAttachmentLeadingVisual( + type: ConversationVCardAttachmentType, + avatarUri: String?, + titleText: String?, +) { + when (type) { + ConversationVCardAttachmentType.CONTACT -> { + ConversationVCardAttachmentAvatar( + avatarUri = avatarUri, + titleText = titleText, + ) + } + + ConversationVCardAttachmentType.LOCATION -> { + Box( + modifier = Modifier + .size(size = VCARD_AVATAR_SIZE), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(size = VCARD_AVATAR_ICON_SIZE), + imageVector = Icons.Rounded.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ConversationVCardAttachmentAvatar( + avatarUri: String?, + titleText: String?, +) { + val displayableAvatarUri = remember(avatarUri) { + displayableVCardAvatarUri(avatarUri = avatarUri) + } + + val label = remember(titleText) { + vCardAvatarLabel(titleText = titleText) + } + + Box( + modifier = Modifier + .size(size = VCARD_AVATAR_SIZE) + .clip(shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + ConversationVCardAttachmentAvatarFallback(label = label) + + displayableAvatarUri?.let { uri -> + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = uri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + } +} + +@Composable +private fun ConversationVCardAttachmentAvatarFallback( + label: String?, +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (label) { + null -> { + Icon( + modifier = Modifier.size(size = VCARD_AVATAR_ICON_SIZE), + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + + else -> { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } +} + +private fun displayableVCardAvatarUri(avatarUri: String?): String? { + return avatarUri + ?.takeIf { it.isNotBlank() } + ?.toUri() + ?.takeIf(UriUtil::isLocalResourceUri) + ?.toString() +} + +private fun vCardAvatarLabel(titleText: String?): String? { + return titleText + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.first() + ?.uppercaseChar() + ?.toString() +} + +@Composable +private fun resolveTitleText( + titleText: String?, + titleTextResId: Int?, +): String { + return titleText + ?: titleTextResId?.let { titleResId -> + stringResource(titleResId) + } + .orEmpty() +} + +@Composable +private fun resolveSubtitleText( + subtitleText: String?, + subtitleTextResId: Int?, +): String? { + return subtitleText ?: subtitleTextResId?.let { subtitleResId -> + stringResource(subtitleResId) + } +} diff --git a/src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt b/src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt new file mode 100644 index 00000000..f1553d58 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt @@ -0,0 +1,19 @@ +package com.android.messaging.ui.conversation.audio + +import java.util.Locale + +private const val MILLIS_PER_SECOND = 1_000L +private const val SECONDS_PER_MINUTE = 60L + +internal fun formatConversationAudioDuration(durationMillis: Long): String { + val totalSeconds = durationMillis / MILLIS_PER_SECOND + val minutes = totalSeconds / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE + + return String.format( + Locale.getDefault(), + "%02d:%02d", + minutes, + seconds, + ) +} diff --git a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt new file mode 100644 index 00000000..24dac7d3 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -0,0 +1,749 @@ +package com.android.messaging.ui.conversation.audio.delegate + +import android.net.Uri +import android.os.SystemClock +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +internal interface ConversationAudioRecordingDelegate : + ConversationScreenDelegate { + + fun startRecording(selfParticipantId: String) + + fun startLockedRecording(selfParticipantId: String) + + fun lockRecording(): Boolean + + fun finishRecording() + + fun cancelRecording() + + fun onScreenCleared() +} + +internal class ConversationAudioRecordingDelegateImpl @Inject constructor( + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, + private val subscriptionsRepository: SubscriptionsRepository, + private val conversationDraftDelegate: ConversationDraftDelegate, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationAudioRecordingDelegate { + + private val _state = MutableStateFlow(ConversationAudioRecordingUiState()) + override val state = _state.asStateFlow() + + private val sessionStateLock = Any() + + private var boundScope: CoroutineScope? = null + private var sessionState: AudioRecordingSessionState = AudioRecordingSessionState.Idle + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + scope.launch(defaultDispatcher) { + conversationIdFlow.drop(count = 1).collect { + cancelRecording() + } + } + } + + override fun startRecording(selfParticipantId: String) { + startRecording( + selfParticipantId = selfParticipantId, + queuedStartIntent = QueuedStartIntent.None, + ) + } + + override fun startLockedRecording(selfParticipantId: String) { + startRecording( + selfParticipantId = selfParticipantId, + queuedStartIntent = QueuedStartIntent.Lock, + ) + } + + private fun startRecording( + selfParticipantId: String, + queuedStartIntent: QueuedStartIntent, + ) { + val scope = boundScope ?: return + val startJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { + startRecordingInBackground( + scope = scope, + selfParticipantId = selfParticipantId, + ) + } + + val shouldStartJob = withSessionStateLock { + tryStartRecordingLocked(queuedStartIntent = queuedStartIntent) + } + + when { + shouldStartJob -> startJob.start() + else -> startJob.cancel() + } + } + + override fun lockRecording(): Boolean { + return withSessionStateLock { + tryLockRecordingLocked() + } + } + + override fun finishRecording() { + val scope = boundScope ?: return + val pendingAttachmentId = createPendingAudioAttachmentId() + + val finishJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { + finalizeRecording(pendingAttachmentId = pendingAttachmentId) + } + + val effect = withSessionStateLock { + finishRecordingLocked( + pendingAttachmentId = pendingAttachmentId, + finishJob = finishJob, + ) + } + + if (effect !is AudioRecordingEffect.StartFinalization) { + finishJob.cancel() + } + + runAudioRecordingEffect( + scope = scope, + effect = effect, + ) + } + + override fun cancelRecording() { + val scope = boundScope ?: return + val effect = withSessionStateLock { + cancelRecordingLocked() + } + + runAudioRecordingEffect( + scope = scope, + effect = effect, + ) + } + + override fun onScreenCleared() { + cancelRecording() + } + + private fun withSessionStateLock(block: () -> T): T { + return synchronized(sessionStateLock) { + block() + } + } + + private fun tryStartRecordingLocked(queuedStartIntent: QueuedStartIntent): Boolean { + if (sessionState !is AudioRecordingSessionState.Idle) { + return false + } + + sessionState = AudioRecordingSessionState.Starting(queuedStartIntent) + + publishUiStateLocked() + + return true + } + + private fun tryLockRecordingLocked(): Boolean { + return when (val currentSessionState = sessionState) { + is AudioRecordingSessionState.Starting -> { + lockStartingSessionLocked(currentSessionState) + } + + is AudioRecordingSessionState.Recording -> { + lockActiveSessionLocked(currentSessionState) + } + + else -> false + } + } + + private fun lockStartingSessionLocked( + currentSessionState: AudioRecordingSessionState.Starting, + ): Boolean { + if (currentSessionState.queuedIntent == QueuedStartIntent.Cancel) { + return false + } + + sessionState = currentSessionState.copy(queuedIntent = QueuedStartIntent.Lock) + publishUiStateLocked() + + return true + } + + private fun lockActiveSessionLocked( + currentSessionState: AudioRecordingSessionState.Recording, + ): Boolean { + if (currentSessionState.isLocked) { + return false + } + + sessionState = currentSessionState.copy(isLocked = true) + publishUiStateLocked() + + return true + } + + private fun finishRecordingLocked( + pendingAttachmentId: String, + finishJob: Job, + ): AudioRecordingEffect { + return when (val currentSessionState = sessionState) { + AudioRecordingSessionState.Idle, + is AudioRecordingSessionState.Finalizing, + -> AudioRecordingEffect.None + + is AudioRecordingSessionState.Starting -> { + sessionState = currentSessionState.copy( + queuedIntent = QueuedStartIntent.Cancel, + ) + publishUiStateLocked() + AudioRecordingEffect.None + } + + is AudioRecordingSessionState.Recording -> { + finishActiveRecordingLocked( + currentSessionState = currentSessionState, + pendingAttachmentId = pendingAttachmentId, + finishJob = finishJob, + ) + } + } + } + + private fun finishActiveRecordingLocked( + currentSessionState: AudioRecordingSessionState.Recording, + pendingAttachmentId: String, + finishJob: Job, + ): AudioRecordingEffect { + val recordedDurationMillis = SystemClock.elapsedRealtime() - + currentSessionState.startedAtMillis + + if (recordedDurationMillis.milliseconds < audioRecordMinimumDurationMillis) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + return AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = currentSessionState.mediaRecorder, + durationJob = currentSessionState.durationJob, + ) + } + + sessionState = AudioRecordingSessionState.Finalizing( + pendingAttachmentId = pendingAttachmentId, + mediaRecorder = currentSessionState.mediaRecorder, + stoppedRecordingUri = null, + durationMillis = currentSessionState.durationMillis, + finishJob = finishJob, + ) + publishUiStateLocked() + + return AudioRecordingEffect.StartFinalization( + finishJob = finishJob, + durationJob = currentSessionState.durationJob, + ) + } + + private fun cancelRecordingLocked(): AudioRecordingEffect { + return when (val currentSessionState = sessionState) { + AudioRecordingSessionState.Idle -> AudioRecordingEffect.None + + is AudioRecordingSessionState.Starting -> { + sessionState = currentSessionState.copy( + queuedIntent = QueuedStartIntent.Cancel, + ) + publishUiStateLocked() + + AudioRecordingEffect.None + } + + is AudioRecordingSessionState.Recording -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = currentSessionState.mediaRecorder, + durationJob = currentSessionState.durationJob, + ) + } + + is AudioRecordingSessionState.Finalizing -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + AudioRecordingEffect.RemovePendingAndDeleteRecording( + pendingAttachmentId = currentSessionState.pendingAttachmentId, + mediaRecorder = currentSessionState.mediaRecorder, + stoppedRecordingUri = currentSessionState.stoppedRecordingUri, + finishJob = currentSessionState.finishJob, + ) + } + } + } + + private fun runAudioRecordingEffect( + scope: CoroutineScope, + effect: AudioRecordingEffect, + ) { + when (effect) { + AudioRecordingEffect.None -> Unit + + is AudioRecordingEffect.StartFinalization -> { + effect.durationJob.cancel() + effect.finishJob.start() + } + + is AudioRecordingEffect.StopAndDeleteRecording -> { + effect.durationJob?.cancel() + + scope.launch(defaultDispatcher) { + val outputUri = stopRecording(mediaRecorder = effect.mediaRecorder) + deleteStoppedRecording(outputUri = outputUri) + } + } + + is AudioRecordingEffect.RemovePendingAndDeleteRecording -> { + effect.finishJob.cancel() + + scope.launch(defaultDispatcher) { + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = effect.pendingAttachmentId, + ) + val outputUri = effect.stoppedRecordingUri + ?: effect.mediaRecorder?.let { mediaRecorder -> + stopRecording(mediaRecorder = mediaRecorder) + } + deleteStoppedRecording(outputUri = outputUri) + } + } + } + } + + private suspend fun startRecordingInBackground( + scope: CoroutineScope, + selfParticipantId: String, + ) { + val resolvedMediaRecorder = LevelTrackingMediaRecorder() + val maxMessageSize = subscriptionsRepository + .resolveMaxMessageSize(selfParticipantId = selfParticipantId) + .first() + + val didStartRecording = resolvedMediaRecorder.startRecording( + null, + null, + maxMessageSize, + ) + + if (!didStartRecording) { + withSessionStateLock { + clearStartingSessionLocked() + } + + return + } + + val startedAtMillis = SystemClock.elapsedRealtime() + val durationJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { + bindDurationTicker(startedAtMillis = startedAtMillis) + } + + val effect = withSessionStateLock { + completeRecorderStartLocked( + mediaRecorder = resolvedMediaRecorder, + startedAtMillis = startedAtMillis, + durationJob = durationJob, + ) + } + + runAudioRecordingEffect( + scope = scope, + effect = effect, + ) + + if (effect == AudioRecordingEffect.None) { + durationJob.start() + } + } + + private fun clearStartingSessionLocked() { + if (sessionState is AudioRecordingSessionState.Starting) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + } + } + + private fun completeRecorderStartLocked( + mediaRecorder: LevelTrackingMediaRecorder, + startedAtMillis: Long, + durationJob: Job, + ): AudioRecordingEffect { + val currentSessionState = sessionState as? AudioRecordingSessionState.Starting + + return when { + currentSessionState == null -> { + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + } + + currentSessionState.queuedIntent == QueuedStartIntent.Cancel -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + } + + else -> { + sessionState = AudioRecordingSessionState.Recording( + mediaRecorder = mediaRecorder, + startedAtMillis = startedAtMillis, + durationMillis = 0L, + isLocked = currentSessionState.queuedIntent == QueuedStartIntent.Lock, + durationJob = durationJob, + ) + + publishUiStateLocked() + + AudioRecordingEffect.None + } + } + } + + private suspend fun finalizeRecording(pendingAttachmentId: String) { + addPendingAudioAttachment(pendingAttachmentId = pendingAttachmentId) + delay(audioRecordEndingBufferMillis) + + val mediaRecorder = withSessionStateLock { + claimFinalizingRecorderLocked(pendingAttachmentId = pendingAttachmentId) + } + + val outputUri = mediaRecorder?.let { finalizingMediaRecorder -> + stopRecording(mediaRecorder = finalizingMediaRecorder) + } + + val shouldResolvePendingAttachment = withSessionStateLock { + storeStoppedRecordingUriLocked( + pendingAttachmentId = pendingAttachmentId, + outputUri = outputUri, + ) + } + + if (!shouldResolvePendingAttachment || !currentCoroutineContext().isActive) { + deleteStoppedRecording(outputUri = outputUri) + return + } + + val didResolvePendingAttachment = resolvePendingAudioAttachment( + pendingAttachmentId = pendingAttachmentId, + outputUri = outputUri, + ) + + if (!didResolvePendingAttachment) { + deleteStoppedRecording(outputUri = outputUri) + } + + withSessionStateLock { + clearFinalizingSessionLocked(pendingAttachmentId = pendingAttachmentId) + } + } + + private fun claimFinalizingRecorderLocked( + pendingAttachmentId: String, + ): LevelTrackingMediaRecorder? { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + var claimedMediaRecorder: LevelTrackingMediaRecorder? = null + + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = currentSessionState.copy(mediaRecorder = null) + claimedMediaRecorder = currentSessionState.mediaRecorder + } + + return claimedMediaRecorder + } + + private fun storeStoppedRecordingUriLocked( + pendingAttachmentId: String, + outputUri: Uri?, + ): Boolean { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + var didStoreStoppedRecordingUri = false + + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = currentSessionState.copy(stoppedRecordingUri = outputUri) + didStoreStoppedRecordingUri = true + } + + return didStoreStoppedRecordingUri + } + + private fun clearFinalizingSessionLocked(pendingAttachmentId: String) { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + } + } + + private fun addPendingAudioAttachment(pendingAttachmentId: String) { + conversationDraftDelegate.addPendingAttachment( + pendingAttachment = ConversationDraftPendingAttachment( + pendingAttachmentId = pendingAttachmentId, + contentUri = createPendingAudioAttachmentUri( + pendingAttachmentId = pendingAttachmentId, + ), + contentType = ContentType.AUDIO_3GPP, + kind = ConversationDraftPendingAttachmentKind.AudioFinalizing, + ), + ) + } + + private fun resolvePendingAudioAttachment( + pendingAttachmentId: String, + outputUri: Uri?, + ): Boolean { + val recordedAttachment = outputUri?.let { resolvedOutputUri -> + ConversationDraftAttachment( + contentType = ContentType.AUDIO_3GPP, + contentUri = resolvedOutputUri.toString(), + ) + } + + return when (recordedAttachment) { + null -> { + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) + false + } + + else -> { + conversationDraftDelegate.resolvePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + attachment = recordedAttachment, + ) + } + } + } + + private suspend fun bindDurationTicker(startedAtMillis: Long) { + while (true) { + val shouldContinue = withSessionStateLock { + tickRecordingDurationLocked(startedAtMillis = startedAtMillis) + } + + if (!shouldContinue) { + return + } + + delay(durationTickIntervalMillis) + } + } + + private fun tickRecordingDurationLocked(startedAtMillis: Long): Boolean { + val currentSessionState = sessionState as? AudioRecordingSessionState.Recording + var shouldContinueTicking = false + + val isMatchingRecordingSession = currentSessionState?.startedAtMillis == startedAtMillis + + if (isMatchingRecordingSession) { + sessionState = currentSessionState.copy( + durationMillis = SystemClock.elapsedRealtime() - startedAtMillis, + ) + + publishUiStateLocked() + shouldContinueTicking = true + } + + return shouldContinueTicking + } + + @Suppress("TooGenericExceptionCaught") + private fun stopRecording(mediaRecorder: LevelTrackingMediaRecorder): Uri? { + return try { + mediaRecorder.stopRecording() + } catch (exception: Exception) { + LogUtil.w(TAG, "Failed to stop audio recording", exception) + null + } + } + + private suspend fun deleteStoppedRecording(outputUri: Uri?) { + outputUri ?: return + + conversationAttachmentsRepository + .deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + .collect() + } + + private fun publishUiStateLocked() { + _state.value = createUiState(sessionState = sessionState) + } + + private fun createUiState( + sessionState: AudioRecordingSessionState, + ): ConversationAudioRecordingUiState { + return when (sessionState) { + AudioRecordingSessionState.Idle -> ConversationAudioRecordingUiState() + + is AudioRecordingSessionState.Starting -> { + createStartingUiState( + queuedIntent = sessionState.queuedIntent, + ) + } + + is AudioRecordingSessionState.Recording -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + durationMillis = sessionState.durationMillis, + isLocked = sessionState.isLocked, + ) + } + + is AudioRecordingSessionState.Finalizing -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Finalizing, + durationMillis = sessionState.durationMillis, + ) + } + } + } + + private fun createStartingUiState( + queuedIntent: QueuedStartIntent, + ): ConversationAudioRecordingUiState { + return when { + queuedIntent == QueuedStartIntent.Cancel -> { + ConversationAudioRecordingUiState() + } + + else -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + isLocked = queuedIntent == QueuedStartIntent.Lock, + ) + } + } + } + + private fun createPendingAudioAttachmentId(): String { + return "pending-audio-${UUID.randomUUID()}" + } + + private fun createPendingAudioAttachmentUri(pendingAttachmentId: String): String { + return "${PENDING_AUDIO_URI_PREFIX}$pendingAttachmentId" + } + + private sealed interface AudioRecordingSessionState { + data object Idle : AudioRecordingSessionState + + data class Starting( + val queuedIntent: QueuedStartIntent = QueuedStartIntent.None, + ) : AudioRecordingSessionState + + data class Recording( + val mediaRecorder: LevelTrackingMediaRecorder, + val startedAtMillis: Long, + val durationMillis: Long, + val isLocked: Boolean, + val durationJob: Job, + ) : AudioRecordingSessionState + + data class Finalizing( + val pendingAttachmentId: String, + val mediaRecorder: LevelTrackingMediaRecorder?, + val stoppedRecordingUri: Uri?, + val durationMillis: Long, + val finishJob: Job, + ) : AudioRecordingSessionState + } + + private enum class QueuedStartIntent { + None, + Lock, + Cancel, + } + + private sealed interface AudioRecordingEffect { + data object None : AudioRecordingEffect + + data class StartFinalization( + val finishJob: Job, + val durationJob: Job, + ) : AudioRecordingEffect + + data class StopAndDeleteRecording( + val mediaRecorder: LevelTrackingMediaRecorder, + val durationJob: Job?, + ) : AudioRecordingEffect + + data class RemovePendingAndDeleteRecording( + val pendingAttachmentId: String, + val mediaRecorder: LevelTrackingMediaRecorder?, + val stoppedRecordingUri: Uri?, + val finishJob: Job, + ) : AudioRecordingEffect + } + + private companion object { + private const val TAG = "ConversationAudioRecording" + private const val PENDING_AUDIO_URI_PREFIX = "pending://audio/" + + private val audioRecordEndingBufferMillis = 500L.milliseconds + private val audioRecordMinimumDurationMillis = 300L.milliseconds + private val durationTickIntervalMillis = 200L.milliseconds + } +} diff --git a/src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt b/src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt new file mode 100644 index 00000000..e18a1f64 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.audio.model + +import androidx.compose.runtime.Immutable + +internal enum class ConversationAudioRecordingPhase { + Idle, + Recording, + Finalizing, +} + +@Immutable +internal data class ConversationAudioRecordingUiState( + val phase: ConversationAudioRecordingPhase = ConversationAudioRecordingPhase.Idle, + val durationMillis: Long = 0L, + val isLocked: Boolean = false, +) diff --git a/src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt b/src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt new file mode 100644 index 00000000..0346cbcf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt @@ -0,0 +1,13 @@ +package com.android.messaging.ui.conversation.common + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +internal interface ConversationScreenDelegate { + val state: StateFlow + + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) +} diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt new file mode 100644 index 00000000..ce255503 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt @@ -0,0 +1,194 @@ +package com.android.messaging.ui.conversation.composer.delegate + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +internal interface ConversationComposerAttachmentsDelegate { + val state: StateFlow> + + fun bind( + scope: CoroutineScope, + draftStateFlow: StateFlow, + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationComposerAttachmentsDelegateImpl @Inject constructor( + private val conversationComposerAttachmentUiModelMapper: + ConversationComposerAttachmentUiModelMapper, + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, + private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationComposerAttachmentsDelegate { + + private val _state = MutableStateFlow>( + value = persistentListOf(), + ) + + override val state = _state.asStateFlow() + + private var isBound = false + + override fun bind( + scope: CoroutineScope, + draftStateFlow: StateFlow, + ) { + if (isBound) { + return + } + + isBound = true + _state.value = mapAttachmentUiModels( + attachmentSource = createAttachmentSource( + draftState = draftStateFlow.value, + ), + ) + + scope.launch(defaultDispatcher) { + draftStateFlow + .map(::createAttachmentSource) + .distinctUntilChanged() + .flatMapLatest(::observeAttachmentUiModels) + .collect { attachmentUiModels -> + _state.value = attachmentUiModels + } + } + } + + private fun createAttachmentSource( + draftState: ConversationDraftState, + ): ComposerAttachmentSource { + return ComposerAttachmentSource( + attachments = draftState.draft.attachments, + pendingAttachments = draftState.pendingAttachments, + ) + } + + private fun observeAttachmentUiModels( + attachmentSource: ComposerAttachmentSource, + ): Flow> { + val attachmentUiModels = mapAttachmentUiModels( + attachmentSource = attachmentSource, + ) + val vCardContentUris = attachmentUiModels + .asSequence() + .filterIsInstance() + .map { it.contentUri } + .distinct() + .toList() + + if (vCardContentUris.isEmpty()) { + return flowOf(attachmentUiModels) + } + + val metadataFlows = vCardContentUris.map { contentUri -> + conversationVCardMetadataRepository + .observeAttachmentMetadata(contentUri = contentUri) + .map { metadata -> + contentUri to metadata + } + } + + return combine(flows = metadataFlows) { contentUriAndMetadata -> + val vCardAttachmentMetadata = contentUriAndMetadata.associate { pair -> + pair.first to pair.second + } + + updateAttachmentUiModelsWithVCardUiModel( + attachments = attachmentUiModels, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .onStart { + emit(attachmentUiModels) + } + .flowOn(defaultDispatcher) + } + + private fun mapAttachmentUiModels( + attachmentSource: ComposerAttachmentSource, + ): ImmutableList { + return conversationComposerAttachmentUiModelMapper.map( + attachments = attachmentSource.attachments, + pendingAttachments = attachmentSource.pendingAttachments, + ) + } + + private fun updateAttachmentUiModelsWithVCardUiModel( + attachments: ImmutableList, + vCardAttachmentMetadata: Map, + ): ImmutableList { + return attachments + .map { attachment -> + updateAttachmentUiModelWithVCardUiModel( + attachment = attachment, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .toImmutableList() + } + + private fun updateAttachmentUiModelWithVCardUiModel( + attachment: ComposerAttachmentUiModel, + vCardAttachmentMetadata: Map, + ): ComposerAttachmentUiModel { + return when (attachment) { + is ComposerAttachmentUiModel.Pending.AudioFinalizing, + is ComposerAttachmentUiModel.Pending.Generic, + -> { + attachment + } + + is ComposerAttachmentUiModel.Resolved.Audio, + is ComposerAttachmentUiModel.Resolved.File, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Image, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Video, + -> { + attachment + } + + is ComposerAttachmentUiModel.Resolved.VCard -> { + val resolvedVCardMetadata = vCardAttachmentMetadata[attachment.contentUri] + ?: ConversationVCardAttachmentMetadata.Loading + + attachment.copy( + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = resolvedVCardMetadata, + ), + ) + } + } + } + + private data class ComposerAttachmentSource( + val attachments: List, + val pendingAttachments: List, + ) +} diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt new file mode 100644 index 00000000..3d4cc66d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -0,0 +1,674 @@ +package com.android.messaging.ui.conversation.composer.delegate + +import android.app.Activity +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.di.core.ApplicationCoroutineScope +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.MessageLimitExceededException +import com.android.messaging.domain.conversation.usecase.draft.exception.SendConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.unitFlow +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal interface ConversationDraftDelegate : ConversationScreenDelegate { + val effects: Flow + val attachmentLimitWarning: StateFlow + val isSubjectDialogVisible: StateFlow + + fun onMessageTextChanged(messageText: String) + + fun onSubjectTextChanged(subjectText: String) + + fun showSubjectDialog() + + fun dismissSubjectDialog() + + fun confirmSubjectDialog(subjectText: String) + + fun onSelfParticipantIdChanged(selfParticipantId: String) + + fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) + + fun addAttachments( + attachments: Collection, + ): List + + fun tryStartAddingAttachment(): Boolean + + fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) + + fun removeAttachment(contentUri: String) + + fun removePendingAttachment(pendingAttachmentId: String) + + fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): Boolean + + fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) + + fun onSendClick() + + fun dismissAttachmentLimitWarning() + + fun sendAnywayAfterAttachmentLimitWarning() + + fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean + + fun persistDraft() + + fun flushDraft() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +internal class ConversationDraftDelegateImpl @Inject constructor( + @param:ApplicationCoroutineScope + private val applicationScope: CoroutineScope, + private val checkConversationActionRequirements: CheckConversationActionRequirements, + private val conversationDraftsRepository: ConversationDraftsRepository, + private val conversationDraftEditorDelegate: ConversationDraftEditorDelegate, + private val sendConversationDraft: SendConversationDraft, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationDraftDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _attachmentLimitWarning = MutableStateFlow( + value = null, + ) + private val _isSubjectDialogVisible = MutableStateFlow(value = false) + + override val effects = _effects.asSharedFlow() + override val attachmentLimitWarning = _attachmentLimitWarning.asStateFlow() + override val isSubjectDialogVisible = _isSubjectDialogVisible.asStateFlow() + override val state: StateFlow = conversationDraftEditorDelegate.state + + private val draftSaveMutex = Mutex() + + private var boundScope: CoroutineScope? = null + private var pendingDefaultSmsRoleSendRequest: DraftSendRequest? = null + private var pendingMessageLimitSendRequest: DraftSendRequest? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + bindConversationDraftObservation( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + bindDraftAutosave(scope = scope) + bindDraftSendProtocol(scope = scope) + } + + override fun onMessageTextChanged(messageText: String) { + conversationDraftEditorDelegate.onMessageTextChanged(messageText = messageText) + } + + override fun onSubjectTextChanged(subjectText: String) { + conversationDraftEditorDelegate.onSubjectTextChanged(subjectText = subjectText) + } + + override fun showSubjectDialog() { + _isSubjectDialogVisible.value = true + } + + override fun dismissSubjectDialog() { + _isSubjectDialogVisible.value = false + } + + override fun confirmSubjectDialog(subjectText: String) { + conversationDraftEditorDelegate.onSubjectTextChanged(subjectText = subjectText) + _isSubjectDialogVisible.value = false + } + + override fun onSelfParticipantIdChanged(selfParticipantId: String) { + conversationDraftEditorDelegate.onSelfParticipantIdChanged( + selfParticipantId = selfParticipantId, + ) + } + + override fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) { + conversationDraftEditorDelegate.seedDraft( + conversationId = conversationId, + draft = draft, + ) + } + + override fun addAttachments( + attachments: Collection, + ): List { + val attachmentLimitResult = conversationDraftEditorDelegate.addAttachments( + attachments = attachments, + ) + + if (attachmentLimitResult.didDropAttachments) { + showComposingAttachmentLimitWarning() + } + + return attachmentLimitResult.attachmentsToAdd + } + + override fun tryStartAddingAttachment(): Boolean { + val canStartAddingAttachment = conversationDraftEditorDelegate.tryStartAddingAttachment() + + if (!canStartAddingAttachment) { + showComposingAttachmentLimitWarning() + } + + return canStartAddingAttachment + } + + override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { + conversationDraftEditorDelegate.addPendingAttachment( + pendingAttachment = pendingAttachment, + ) + } + + override fun removeAttachment(contentUri: String) { + conversationDraftEditorDelegate.removeAttachment(contentUri = contentUri) + } + + override fun removePendingAttachment(pendingAttachmentId: String) { + conversationDraftEditorDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) + } + + override fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): Boolean { + val resolution = conversationDraftEditorDelegate.resolvePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + attachment = attachment, + ) + + if (resolution.didDropAttachments) { + showComposingAttachmentLimitWarning() + } + + return resolution.didResolveAttachment + } + + override fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + conversationDraftEditorDelegate.updateAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) + } + + override fun onSendClick() { + conversationDraftEditorDelegate.createSendRequestOrNull() + ?.let(::sendDraftWhenActionRequirementsSatisfied) + } + + override fun dismissAttachmentLimitWarning() { + _attachmentLimitWarning.value = null + } + + private fun showComposingAttachmentLimitWarning() { + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .ComposingAttachmentLimitReached + } + + override fun sendAnywayAfterAttachmentLimitWarning() { + val sendRequest = pendingMessageLimitSendRequest ?: return + pendingMessageLimitSendRequest = null + _attachmentLimitWarning.value = null + + sendDraftWhenActionRequirementsSatisfied( + sendRequest = sendRequest.copy(ignoreMessageSizeLimit = true), + ) + } + + override fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val sendRequest = pendingDefaultSmsRoleSendRequest ?: return false + + pendingDefaultSmsRoleSendRequest = null + + return when (resultCode) { + Activity.RESULT_OK -> { + sendDraftWhenActionRequirementsSatisfied(sendRequest = sendRequest) + true + } + + else -> false + } + } + + override fun persistDraft() { + val scope = boundScope ?: return + val saveRequest = conversationDraftEditorDelegate.currentSaveRequest ?: return + + launchDraftOperation(scope = scope) { + createSaveDraftOperationFlow( + operationName = "persist draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + shouldSkipIfRequestIsStale = true, + ) + } + } + + override fun flushDraft() { + val saveRequest = conversationDraftEditorDelegate.currentSaveRequest ?: return + + launchDraftOperation(scope = applicationScope) { + createSaveDraftOperationFlow( + operationName = "flush draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = false, + shouldSkipIfRequestIsStale = false, + shouldRunNonCancellable = true, + ) + } + } + + private suspend fun saveDraft( + saveRequest: DraftSaveRequest, + shouldMarkCurrentDraftAsPersisted: Boolean, + shouldSkipIfRequestIsStale: Boolean, + ) { + draftSaveMutex.withLock { + // Ignore debounced or queued saves that no longer reflect the current working draft + if (shouldSkipIfRequestIsStale && + !conversationDraftEditorDelegate.matchesSaveRequest( + saveRequest = saveRequest, + ) + ) { + return@withLock + } + + conversationDraftsRepository.saveDraft( + conversationId = saveRequest.conversationId, + draft = saveRequest.draft, + ) + + if (!shouldMarkCurrentDraftAsPersisted) { + return@withLock + } + + conversationDraftEditorDelegate.applyPersistedSaveResult(saveRequest = saveRequest) + } + } + + private fun bindConversationDraftObservation( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + scope.launch(defaultDispatcher) { + observeConversationDraftUpdates(conversationIdFlow = conversationIdFlow) + .collect { persistedDraftUpdate -> + conversationDraftEditorDelegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate, + ) + } + } + } + + private fun bindDraftAutosave(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + observeDraftAutosaveRequests().collect { saveRequest -> + createSaveDraftOperationFlow( + operationName = "autosave draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + shouldSkipIfRequestIsStale = true, + ).collect() + } + } + } + + private fun bindDraftSendProtocol(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + conversationDraftEditorDelegate.sendProtocolUpdates.collect { sendProtocol -> + conversationDraftEditorDelegate.applySendProtocol(sendProtocol = sendProtocol) + } + } + } + + private suspend fun resetDraftEditorState(conversationId: String?) { + pendingMessageLimitSendRequest = null + _attachmentLimitWarning.value = null + _isSubjectDialogVisible.value = false + + val previousSaveRequest = conversationDraftEditorDelegate.reset( + conversationId = conversationId, + ) + + previousSaveRequest + ?.let { saveRequest -> + createSaveDraftOperationFlow( + operationName = "flush previous draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = false, + shouldSkipIfRequestIsStale = false, + shouldRunNonCancellable = true, + ).collect() + } + } + + private fun launchDraftOperation( + scope: CoroutineScope, + createOperationFlow: () -> Flow, + ) { + scope.launch(defaultDispatcher) { + createOperationFlow().collect() + } + } + + private fun sendDraftWhenActionRequirementsSatisfied(sendRequest: DraftSendRequest) { + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + sendDraft(sendRequest = sendRequest) + } + + ConversationActionRequirementsResult.SmsNotCapable -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + ) + } + + ConversationActionRequirementsResult.NoPreferredSmsSim -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + ) + } + + ConversationActionRequirementsResult.MissingDefaultSmsRole -> { + pendingDefaultSmsRoleSendRequest = sendRequest + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = true, + ), + ) + } + } + } + + private fun sendDraft(sendRequest: DraftSendRequest) { + val scope = boundScope ?: return + + if (conversationDraftEditorDelegate.markSendingForSendRequest(sendRequest = sendRequest)) { + launchDraftOperation(scope = scope) { + createSendDraftFlow(sendRequest = sendRequest) + } + } + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) + } + } + + private fun createSendDraftFlow(sendRequest: DraftSendRequest): Flow { + var didClearDraftAfterSend = false + + return runDraftOperationBoundary( + operationName = "send draft", + conversationId = sendRequest.conversationId, + onFailure = { exception -> + handleSendDraftFailure( + exception = exception, + sendRequest = sendRequest, + ) + }, + ) { + sendConversationDraft( + conversationId = sendRequest.conversationId, + draft = sendRequest.draft, + ignoreMessageSizeLimit = sendRequest.ignoreMessageSizeLimit, + ).onEach { + conversationDraftEditorDelegate.clearConversationDraftAfterSend( + sendRequest = sendRequest, + ) + didClearDraftAfterSend = true + _effects.emit(ConversationScreenEffect.NotifyDraftSent) + }.onCompletion { throwable -> + if (throwable != null || !didClearDraftAfterSend) { + conversationDraftEditorDelegate.markConversationDraftAsIdle( + conversationId = sendRequest.conversationId, + ) + } + } + } + } + + private fun handleSendDraftFailure( + exception: Throwable, + sendRequest: DraftSendRequest, + ) { + // TODO: Add an extension that properly skip CancellationException manual handling + + val wasAttachmentLimitFailure = handleAttachmentLimitFailure( + exception = exception, + sendRequest = sendRequest, + ) + + if (!wasAttachmentLimitFailure && exception !is CancellationException) { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = resolveSendDraftFailureMessageResId(exception = exception), + ), + ) + } + } + + private fun handleAttachmentLimitFailure( + exception: Throwable, + sendRequest: DraftSendRequest, + ): Boolean { + return when (exception) { + is TooManyVideoAttachmentsException -> { + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .SendingVideoAttachmentLimitReached + true + } + + is MessageLimitExceededException -> { + pendingMessageLimitSendRequest = sendRequest + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .SendingMessageLimitReached + true + } + + else -> false + } + } + + private fun resolveSendDraftFailureMessageResId(exception: Throwable): Int { + return when (exception) { + is ConversationSimNotReadyException -> { + R.string.cant_send_message_without_active_subscription + } + + is UnknownConversationRecipientException -> R.string.unknown_sender + is SendConversationDraftException -> R.string.send_message_failure + else -> R.string.send_message_failure + } + } + + private fun createSaveDraftOperationFlow( + operationName: String, + saveRequest: DraftSaveRequest, + shouldMarkCurrentDraftAsPersisted: Boolean, + shouldSkipIfRequestIsStale: Boolean, + shouldRunNonCancellable: Boolean = false, + ): Flow { + return runDraftOperationBoundary( + operationName = operationName, + conversationId = saveRequest.conversationId, + ) { + unitFlow { + if (shouldRunNonCancellable) { + withContext(context = NonCancellable) { + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = shouldMarkCurrentDraftAsPersisted, + shouldSkipIfRequestIsStale = shouldSkipIfRequestIsStale, + ) + } + + return@unitFlow + } + + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = shouldMarkCurrentDraftAsPersisted, + shouldSkipIfRequestIsStale = shouldSkipIfRequestIsStale, + ) + } + } + } + + private fun observeConversationDraftUpdates( + conversationIdFlow: StateFlow, + ): Flow { + return runDraftOperationBoundary( + operationName = "observe drafts", + conversationId = null, + ) { + conversationIdFlow.transformLatest { conversationId -> + resetDraftEditorState(conversationId = conversationId) + + if (conversationId == null) { + return@transformLatest + } + + emitAll(createPersistedDraftUpdatesFlow(conversationId = conversationId)) + } + } + } + + private fun createPersistedDraftUpdatesFlow( + conversationId: String, + ): Flow { + return conversationDraftsRepository + .observeConversationDraft(conversationId = conversationId) + .map { persistedDraft -> + PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = persistedDraft, + ) + } + .catch { exception -> + LogUtil.e( + TAG, + "Failed to observe draft for conversation $conversationId", + exception, + ) + + emit( + PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = ConversationDraft(), + ), + ) + } + } + + private fun observeDraftAutosaveRequests(): Flow { + return runDraftOperationBoundary( + operationName = "bind draft autosave", + conversationId = null, + ) { + conversationDraftEditorDelegate + .saveRequests + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_AUTOSAVE_DELAY_MILLIS) + .filterNotNull() + } + } + + private fun runDraftOperationBoundary( + operationName: String, + conversationId: String?, + onFailure: ((Throwable) -> Unit)? = null, + createFlow: () -> Flow, + ): Flow { + return flow { + emitAll(createFlow()) + }.catch { exception -> + LogUtil.e( + TAG, + "Failed to $operationName for conversation $conversationId", + exception, + ) + onFailure?.invoke(exception) + } + } + + private companion object { + private const val TAG = "ConversationDraftDelegate" + + private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt new file mode 100644 index 00000000..9b19f6d1 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt @@ -0,0 +1,457 @@ +package com.android.messaging.ui.conversation.composer.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update + +internal interface ConversationDraftEditorDelegate { + val state: StateFlow + val saveRequests: Flow + val sendProtocolUpdates: Flow + val currentSaveRequest: DraftSaveRequest? + + fun onMessageTextChanged(messageText: String) + + fun onSubjectTextChanged(subjectText: String) + + fun onSelfParticipantIdChanged(selfParticipantId: String) + + fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) + + fun addAttachments( + attachments: Collection, + ): DraftAttachmentLimitResult + + fun tryStartAddingAttachment(): Boolean + + fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) + + fun removeAttachment(contentUri: String) + + fun removePendingAttachment(pendingAttachmentId: String) + + fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): DraftPendingAttachmentResolution + + fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) + + fun reset(conversationId: String?): DraftSaveRequest? + + fun applyPersistedDraftUpdate(persistedDraftUpdate: PersistedDraftUpdate) + + fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean + + fun applyPersistedSaveResult(saveRequest: DraftSaveRequest) + + fun applySendProtocol(sendProtocol: ConversationDraftSendProtocol) + + fun createSendRequestOrNull(): DraftSendRequest? + + fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean + + fun markConversationDraftAsIdle(conversationId: String) + + fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +internal class ConversationDraftEditorDelegateImpl @Inject constructor( + private val subscriptionsRepository: SubscriptionsRepository, + private val resolveConversationDraftSendProtocol: ResolveConversationDraftSendProtocol, + private val resolveDraftAttachmentsWithinLimit: ResolveDraftAttachmentsWithinLimit, +) : ConversationDraftEditorDelegate { + + private val _state = MutableStateFlow(ConversationDraftState()) + private val draftEditorState = MutableStateFlow(DraftEditorState()) + + override val state: StateFlow = _state.asStateFlow() + override val saveRequests: Flow = draftEditorState + .map { currentDraftEditorState -> + currentDraftEditorState.toSaveRequestOrNull() + } + override val sendProtocolUpdates: Flow = draftEditorState + .map { currentDraftEditorState -> + DraftSendProtocolRequest( + conversationId = currentDraftEditorState.conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) + } + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS) + .mapLatest { request -> + resolveConversationDraftSendProtocol( + conversationId = request.conversationId, + draft = request.draft, + ) + } + .distinctUntilChanged() + override val currentSaveRequest: DraftSaveRequest? + get() { + return draftEditorState.value.toSaveRequestOrNull() + } + + private var pendingDraftSeed: PendingDraftSeed? = null + + override fun onMessageTextChanged(messageText: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withMessageText(messageText) + } + } + + override fun onSubjectTextChanged(subjectText: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withSubjectText(subjectText) + } + } + + override fun onSelfParticipantIdChanged(selfParticipantId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withSelfParticipantId(selfParticipantId = selfParticipantId) + } + } + + override fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) { + pendingDraftSeed = PendingDraftSeed( + conversationId = conversationId, + draft = draft, + ) + applyPendingDraftSeedIfPossible() + } + + override fun addAttachments( + attachments: Collection, + ): DraftAttachmentLimitResult { + if (attachments.isEmpty()) { + return DraftAttachmentLimitResult( + attachmentsToAdd = emptyList(), + didDropAttachments = false, + ) + } + + val attachmentLimitResult = resolveDraftAttachmentsWithinLimit( + currentAttachments = draftEditorState.value.effectiveDraft.attachments, + attachmentsToAdd = attachments, + ) + val attachmentsToAdd = attachmentLimitResult.attachmentsToAdd + + if (attachmentsToAdd.isNotEmpty()) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentsAdded(attachments = attachmentsToAdd) + } + } + + return attachmentLimitResult + } + + override fun tryStartAddingAttachment(): Boolean { + val currentDraftEditorState = draftEditorState.value + val currentAttachmentCount = currentDraftEditorState.effectiveDraft.attachments.size + + currentDraftEditorState.pendingAttachments.size + + return currentAttachmentCount < subscriptionsRepository.resolveAttachmentLimit() + } + + override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentAdded(pendingAttachment) + } + } + + override fun removeAttachment(contentUri: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentRemoved(contentUri = contentUri) + } + } + + override fun removePendingAttachment(pendingAttachmentId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentRemoved( + pendingAttachmentId = pendingAttachmentId, + ) + } + } + + override fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): DraftPendingAttachmentResolution { + var resolution = DraftPendingAttachmentResolution( + didResolveAttachment = false, + didDropAttachments = false, + ) + + updateDraftEditorState { currentDraftEditorState -> + val resolutionState = resolvePendingAttachmentState( + currentDraftEditorState = currentDraftEditorState, + pendingAttachmentId = pendingAttachmentId, + attachment = attachment, + ) + + resolution = DraftPendingAttachmentResolution( + didResolveAttachment = resolutionState.didResolveAttachment, + didDropAttachments = resolutionState.didDropAttachments, + ) + + resolutionState.draftEditorState + } + + return resolution + } + + override fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) + } + } + + override fun reset(conversationId: String?): DraftSaveRequest? { + val saveRequest = draftEditorState.value.toSaveRequestOrNull() + + updateDraftEditorState { + DraftEditorState(conversationId = conversationId) + } + applyPendingDraftSeedIfPossible() + + return saveRequest + } + + override fun applyPersistedDraftUpdate(persistedDraftUpdate: PersistedDraftUpdate) { + updateDraftEditorState { currentDraftEditorState -> + when { + currentDraftEditorState.conversationId != persistedDraftUpdate.conversationId -> { + currentDraftEditorState + } + + else -> { + currentDraftEditorState.withPersistedDraft( + persistedDraft = persistedDraftUpdate.persistedDraft, + ) + } + } + } + applyPendingDraftSeedIfPossible() + } + + override fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { + return draftEditorState.value.matchesSaveRequest(saveRequest = saveRequest) + } + + override fun applyPersistedSaveResult(saveRequest: DraftSaveRequest) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPersistedSaveResult(saveRequest = saveRequest) + } + } + + override fun applySendProtocol(sendProtocol: ConversationDraftSendProtocol) { + _state.update { currentState -> + currentState.copy( + sendProtocol = when { + currentState.draft.hasContent -> sendProtocol + else -> ConversationDraftSendProtocol.SMS + }, + ) + } + } + + override fun createSendRequestOrNull(): DraftSendRequest? { + val currentDraftEditorState = draftEditorState.value + val conversationId = currentDraftEditorState.conversationId + + return when { + conversationId == null -> null + !currentDraftEditorState.canSendDraft() -> null + + else -> { + DraftSendRequest( + conversationId = conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) + } + } + } + + override fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean { + var didMarkSending = false + + updateDraftEditorState { currentDraftEditorState -> + val isSameConversation = currentDraftEditorState.conversationId == + sendRequest.conversationId + val canMarkSending = isSameConversation && !currentDraftEditorState.isSending + + if (!canMarkSending) { + return@updateDraftEditorState currentDraftEditorState + } + + didMarkSending = true + currentDraftEditorState.markSending() + } + + return didMarkSending + } + + override fun markConversationDraftAsIdle(conversationId: String) { + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + currentDraftEditorState.markIdle() + } + } + + override fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) { + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != sendRequest.conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + currentDraftEditorState.clearDraftAfterSend(sentDraft = sendRequest.draft) + } + } + + private fun resolvePendingAttachmentState( + currentDraftEditorState: DraftEditorState, + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): PendingAttachmentResolutionState { + val hasPendingAttachment = currentDraftEditorState + .pendingAttachments + .any { pendingAttachment -> + pendingAttachment.pendingAttachmentId == pendingAttachmentId + } + + if (!hasPendingAttachment) { + return PendingAttachmentResolutionState( + draftEditorState = currentDraftEditorState, + didResolveAttachment = false, + didDropAttachments = false, + ) + } + + val draftEditorStateWithoutPendingAttachment = currentDraftEditorState + .withPendingAttachmentRemoved(pendingAttachmentId = pendingAttachmentId) + + val attachmentLimitResult = resolveDraftAttachmentsWithinLimit( + currentAttachments = draftEditorStateWithoutPendingAttachment + .effectiveDraft + .attachments, + attachmentsToAdd = listOf(attachment), + ) + val attachmentsToAdd = attachmentLimitResult.attachmentsToAdd + val updatedDraftEditorState = draftEditorStateWithoutPendingAttachment + .withAttachmentsAdded(attachments = attachmentsToAdd) + + return PendingAttachmentResolutionState( + draftEditorState = updatedDraftEditorState, + didResolveAttachment = attachmentsToAdd.any { acceptedAttachment -> + acceptedAttachment.contentUri == attachment.contentUri + }, + didDropAttachments = attachmentLimitResult.didDropAttachments, + ) + } + + private fun updateDraftEditorState(transform: (DraftEditorState) -> DraftEditorState) { + draftEditorState.update { currentDraftEditorState -> + val updatedDraftEditorState = transform(currentDraftEditorState) + val visibleState = updatedDraftEditorState.visibleState + val visibleSendProtocol = resolveVisibleSendProtocol( + previousState = _state.value, + visibleState = visibleState, + ) + + _state.value = visibleState.copy( + sendProtocol = visibleSendProtocol, + ) + + updatedDraftEditorState + } + } + + private fun resolveVisibleSendProtocol( + previousState: ConversationDraftState, + visibleState: ConversationDraftState, + ): ConversationDraftSendProtocol { + val visibleDraft = visibleState.draft + val previousDraft = previousState.draft + + return when { + !visibleDraft.hasContent -> ConversationDraftSendProtocol.SMS + visibleDraft.isMms -> ConversationDraftSendProtocol.MMS + previousDraft.isMms -> ConversationDraftSendProtocol.SMS + else -> previousState.sendProtocol + } + } + + private fun applyPendingDraftSeedIfPossible() { + val pendingDraftSeed = pendingDraftSeed ?: return + + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != pendingDraftSeed.conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + this.pendingDraftSeed = null + currentDraftEditorState.withSeededDraft(draft = pendingDraftSeed.draft) + } + } + + private companion object { + private const val DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS = 250L + } +} + +private data class DraftSendProtocolRequest( + val conversationId: String?, + val draft: ConversationDraft, +) + +internal data class DraftPendingAttachmentResolution( + val didResolveAttachment: Boolean, + val didDropAttachments: Boolean, +) + +private data class PendingDraftSeed( + val conversationId: String, + val draft: ConversationDraft, +) + +private data class PendingAttachmentResolutionState( + val draftEditorState: DraftEditorState, + val didResolveAttachment: Boolean, + val didDropAttachments: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt new file mode 100644 index 00000000..665c6a59 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt @@ -0,0 +1,538 @@ +package com.android.messaging.ui.conversation.composer.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal data class DraftEditorState( + val conversationId: String? = null, + val persistedDraft: ConversationDraft = ConversationDraft(), + private val localEdits: ConversationDraftEdits = ConversationDraftEdits(), + val isLoaded: Boolean = false, + val isSending: Boolean = false, + val pendingAttachments: List = emptyList(), + val pendingSentDraft: ConversationDraft? = null, +) { + val effectiveDraft: ConversationDraft + get() = localEdits.applyTo(baseDraft = persistedDraft) + + val visibleState: ConversationDraftState + get() { + return when { + conversationId == null -> ConversationDraftState() + + else -> { + ConversationDraftState( + draft = effectiveDraft.copy( + isCheckingDraft = !isLoaded, + isSending = isSending, + ), + pendingAttachments = pendingAttachments, + ) + } + } + } + + fun withPersistedDraft(persistedDraft: ConversationDraft): DraftEditorState { + return when { + pendingSentDraft != null -> { + withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft = persistedDraft, + sentDraftAwaitingClear = pendingSentDraft, + ) + } + + else -> { + copy( + persistedDraft = persistedDraft, + localEdits = localEdits.normalizedAgainst( + baseDraft = persistedDraft, + ), + isLoaded = true, + ) + } + } + } + + fun withMessageText(messageText: String): DraftEditorState { + return when { + conversationId == null -> this + effectiveDraft.messageText == messageText -> this + + else -> copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(messageText = messageText), + ) + } + } + + fun withSubjectText(subjectText: String): DraftEditorState { + return when { + conversationId == null -> this + effectiveDraft.subjectText == subjectText -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(subjectText = subjectText), + ) + } + } + } + + fun withSelfParticipantId(selfParticipantId: String): DraftEditorState { + return when { + conversationId == null -> this + selfParticipantId.isBlank() -> this + effectiveDraft.selfParticipantId == selfParticipantId -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(selfParticipantId = selfParticipantId), + ) + } + } + } + + fun withSeededDraft(draft: ConversationDraft): DraftEditorState { + if (conversationId == null) { + return this + } + + val normalizedDraft = draft.copy( + selfParticipantId = when { + draft.selfParticipantId.isBlank() -> persistedDraft.selfParticipantId + else -> draft.selfParticipantId + }, + ) + + return copyWithNormalizedLocalEdits( + updatedLocalEdits = ConversationDraftEdits( + messageText = normalizedDraft.messageText, + subjectText = normalizedDraft.subjectText, + selfParticipantId = normalizedDraft.selfParticipantId, + attachments = normalizedDraft.attachments, + ), + ) + } + + fun toSaveRequestOrNull(): DraftSaveRequest? { + return when { + conversationId == null -> null + !isLoaded || isSending || !localEdits.hasChanges -> null + + else -> { + DraftSaveRequest( + conversationId = conversationId, + draft = effectiveDraft, + ) + } + } + } + + fun withAttachmentsAdded( + attachments: Collection, + ): DraftEditorState { + if (conversationId == null || attachments.isEmpty()) { + return this + } + + val currentAttachments = effectiveDraft.attachments + val mergedAttachments = mergeDraftAttachments( + baseAttachments = currentAttachments, + attachmentsToAdd = attachments, + ) + + return when { + mergedAttachments == currentAttachments -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy( + attachments = mergedAttachments, + ), + ) + } + } + } + + fun withAttachmentRemoved(contentUri: String): DraftEditorState { + return when { + conversationId == null -> this + + else -> { + val currentAttachments = effectiveDraft.attachments + val updatedAttachments = currentAttachments.withoutAttachment( + contentUri = contentUri, + ) + + copyWithUpdatedAttachments( + currentAttachments = currentAttachments, + updatedAttachments = updatedAttachments, + ) + } + } + } + + fun withAttachmentCaption( + contentUri: String, + captionText: String, + ): DraftEditorState { + return when { + conversationId == null -> this + + else -> { + val currentAttachments = effectiveDraft.attachments + val updatedAttachments = currentAttachments.withUpdatedAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) + + copyWithUpdatedAttachments( + currentAttachments = currentAttachments, + updatedAttachments = updatedAttachments, + ) + } + } + } + + fun withPendingAttachmentAdded( + pendingAttachment: ConversationDraftPendingAttachment, + ): DraftEditorState { + return when { + conversationId == null -> this + + else -> { + copy( + pendingAttachments = pendingAttachments + pendingAttachment, + ) + } + } + } + + fun withPendingAttachmentRemoved(pendingAttachmentId: String): DraftEditorState { + val pendingAttachmentIndex = pendingAttachments.indexOfFirst { pendingAttachment -> + pendingAttachment.pendingAttachmentId == pendingAttachmentId + } + + if (pendingAttachmentIndex == -1) { + return this + } + + val updatedPendingAttachments = pendingAttachments.toMutableList().apply { + removeAt(pendingAttachmentIndex) + } + + return copy(pendingAttachments = updatedPendingAttachments) + } + + fun canSendDraft(): Boolean { + return conversationId != null && + isLoaded && + !isSending && + pendingAttachments.isEmpty() && + effectiveDraft.hasContent + } + + fun withPersistedSaveResult(saveRequest: DraftSaveRequest): DraftEditorState { + return when { + conversationId != saveRequest.conversationId -> this + + effectiveDraft == saveRequest.draft -> { + copy( + persistedDraft = saveRequest.draft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + + else -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = saveRequest.draft, + shouldKeepPendingSentDraft = false, + ) + } + } + } + + fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { + return when { + conversationId != saveRequest.conversationId -> false + !isLoaded || isSending || !localEdits.hasChanges -> false + else -> effectiveDraft == saveRequest.draft + } + } + + fun markSending(): DraftEditorState { + return when { + conversationId == null -> this + isSending -> this + else -> copy(isSending = true) + } + } + + fun markIdle(): DraftEditorState { + if (!isSending) { + return this + } + + return copy(isSending = false) + } + + fun clearDraftAfterSend(sentDraft: ConversationDraft): DraftEditorState { + val latestEffectiveDraft = effectiveDraft + + val clearedDraft = createClearedDraftForSentDraft(sentDraft) + + val visibleDraftAfterSend = when { + latestEffectiveDraft == sentDraft -> clearedDraft + + else -> { + latestEffectiveDraft.copy( + selfParticipantId = sentDraft.selfParticipantId, + ) + } + } + + return copy( + persistedDraft = clearedDraft, + localEdits = createConversationDraftEdits( + baseDraft = clearedDraft, + targetDraft = visibleDraftAfterSend, + ), + isLoaded = true, + isSending = false, + pendingSentDraft = sentDraft, + ) + } + + private fun withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft: ConversationDraft, + sentDraftAwaitingClear: ConversationDraft, + ): DraftEditorState { + return when { + persistedDraft == sentDraftAwaitingClear -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = true, + ) + } + + else -> { + withPersistedDraftAfterSentDraftChanged( + persistedDraft = persistedDraft, + sentDraftAwaitingClear = sentDraftAwaitingClear, + ) + } + } + } + + private fun withPersistedDraftAfterSentDraftChanged( + persistedDraft: ConversationDraft, + sentDraftAwaitingClear: ConversationDraft, + ): DraftEditorState { + val isVisibleDraftAlreadyCleared = effectiveDraft == createClearedDraftForSentDraft( + sentDraft = sentDraftAwaitingClear, + ) + + return when { + isVisibleDraftAlreadyCleared -> { + copy( + persistedDraft = persistedDraft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + + else -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = false, + ) + } + } + } + + private fun copyWithUpdatedAttachments( + currentAttachments: ImmutableList, + updatedAttachments: ImmutableList, + ): DraftEditorState { + return when { + updatedAttachments == currentAttachments -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), + ) + } + } + } + + private fun rebaseVisibleDraftOnPersistedDraft( + persistedDraft: ConversationDraft, + shouldKeepPendingSentDraft: Boolean, + ): DraftEditorState { + return copy( + persistedDraft = persistedDraft, + localEdits = createConversationDraftEdits( + baseDraft = persistedDraft, + targetDraft = effectiveDraft, + ), + isLoaded = true, + pendingSentDraft = pendingSentDraft.takeIf { shouldKeepPendingSentDraft }, + ) + } + + private fun copyWithNormalizedLocalEdits( + updatedLocalEdits: ConversationDraftEdits, + ): DraftEditorState { + return copy( + localEdits = updatedLocalEdits.normalizedAgainst( + baseDraft = persistedDraft, + ), + ) + } +} + +internal data class DraftSaveRequest( + val conversationId: String, + val draft: ConversationDraft, +) + +internal data class DraftSendRequest( + val conversationId: String, + val draft: ConversationDraft, + val ignoreMessageSizeLimit: Boolean = false, +) + +internal data class PersistedDraftUpdate( + val conversationId: String, + val persistedDraft: ConversationDraft, +) + +internal data class ConversationDraftEdits( + val messageText: String? = null, + val subjectText: String? = null, + val selfParticipantId: String? = null, + val attachments: ImmutableList? = null, +) { + val hasChanges: Boolean + get() { + return messageText != null || + subjectText != null || + selfParticipantId != null || + attachments != null + } + + fun applyTo(baseDraft: ConversationDraft): ConversationDraft { + return baseDraft.copy( + messageText = messageText ?: baseDraft.messageText, + subjectText = subjectText ?: baseDraft.subjectText, + selfParticipantId = selfParticipantId ?: baseDraft.selfParticipantId, + attachments = attachments ?: baseDraft.attachments, + ) + } + + fun normalizedAgainst(baseDraft: ConversationDraft): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = messageText?.takeIf { it != baseDraft.messageText }, + subjectText = subjectText?.takeIf { it != baseDraft.subjectText }, + selfParticipantId = selfParticipantId?.takeIf { it != baseDraft.selfParticipantId }, + attachments = attachments?.takeIf { it != baseDraft.attachments }, + ) + } +} + +private fun mergeDraftAttachments( + baseAttachments: ImmutableList, + attachmentsToAdd: Collection, +): ImmutableList { + if (attachmentsToAdd.isEmpty()) { + return baseAttachments + } + + val seenContentUris = baseAttachments + .asSequence() + .map { attachment -> attachment.contentUri } + .toHashSet() + + val attachmentsToAppend = attachmentsToAdd.filter { attachment -> + seenContentUris.add(attachment.contentUri) + } + + return when { + attachmentsToAppend.isEmpty() -> baseAttachments + + else -> { + (baseAttachments + attachmentsToAppend).toImmutableList() + } + } +} + +private fun ImmutableList.withoutAttachment( + contentUri: String, +): ImmutableList { + val attachmentIndex = indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + + return when { + attachmentIndex == -1 -> this + + else -> { + toMutableList() + .apply { + removeAt(attachmentIndex) + } + .toImmutableList() + } + } +} + +private fun ImmutableList.withUpdatedAttachmentCaption( + contentUri: String, + captionText: String, +): ImmutableList { + val attachmentIndex = indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + val currentAttachment = getOrNull(attachmentIndex) + + return when { + currentAttachment == null -> this + currentAttachment.captionText == captionText -> this + + else -> { + toMutableList() + .apply { + this[attachmentIndex] = currentAttachment.copy(captionText = captionText) + } + .toImmutableList() + } + } +} + +private fun createClearedDraftForSentDraft( + sentDraft: ConversationDraft, +): ConversationDraft { + return ConversationDraft( + selfParticipantId = sentDraft.selfParticipantId, + ) +} + +private fun createConversationDraftEdits( + baseDraft: ConversationDraft, + targetDraft: ConversationDraft, +): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = targetDraft.messageText.takeIf { it != baseDraft.messageText }, + subjectText = targetDraft.subjectText.takeIf { it != baseDraft.subjectText }, + selfParticipantId = targetDraft.selfParticipantId.takeIf { + it != baseDraft.selfParticipantId + }, + attachments = targetDraft.attachments.takeIf { it != baseDraft.attachments }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt new file mode 100644 index 00000000..c2dcf0db --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -0,0 +1,122 @@ +package com.android.messaging.ui.conversation.composer.mapper + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.util.ContentType +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal interface ConversationComposerAttachmentUiModelMapper { + fun map( + attachments: List, + pendingAttachments: List, + ): ImmutableList +} + +internal class ConversationComposerAttachmentUiModelMapperImpl @Inject constructor( + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, +) : ConversationComposerAttachmentUiModelMapper { + + override fun map( + attachments: List, + pendingAttachments: List, + ): ImmutableList { + val resolvedAttachments = attachments.map { attachment -> + createResolvedAttachmentUiModel( + attachment = attachment, + ) + } + val pendingAttachmentUiModels = pendingAttachments.map { pendingAttachment -> + createPendingAttachmentUiModel( + pendingAttachment = pendingAttachment, + ) + } + + return (resolvedAttachments + pendingAttachmentUiModels).toImmutableList() + } + + private fun createPendingAttachmentUiModel( + pendingAttachment: ConversationDraftPendingAttachment, + ): ComposerAttachmentUiModel.Pending { + return when (pendingAttachment.kind) { + ConversationDraftPendingAttachmentKind.Generic -> { + ComposerAttachmentUiModel.Pending.Generic( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + + ConversationDraftPendingAttachmentKind.AudioFinalizing -> { + ComposerAttachmentUiModel.Pending.AudioFinalizing( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + } + } + + private fun createResolvedAttachmentUiModel( + attachment: ConversationDraftAttachment, + ): ComposerAttachmentUiModel.Resolved { + return when { + ContentType.isAudioType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.Audio( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + durationMillis = attachment.durationMillis ?: 0L, + ) + } + + ContentType.isImageType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VisualMedia.Image( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + captionText = attachment.captionText, + width = attachment.width, + height = attachment.height, + ) + } + + ContentType.isVCardType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VCard( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = ConversationVCardAttachmentMetadata.Loading, + ), + ) + } + + ContentType.isVideoType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VisualMedia.Video( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + captionText = attachment.captionText, + width = attachment.width, + height = attachment.height, + ) + } + + else -> { + ComposerAttachmentUiModel.Resolved.File( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt new file mode 100644 index 00000000..a3e73b63 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt @@ -0,0 +1,144 @@ +package com.android.messaging.ui.conversation.composer.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.MessageTextStats +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.composer.model.ConversationSegmentCounterUiState +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList + +internal interface ConversationComposerUiStateMapper { + fun map( + audioRecording: ConversationAudioRecordingUiState, + draftState: ConversationDraftState, + attachments: ImmutableList, + composerAvailability: ConversationComposerAvailability, + subscriptions: ImmutableList, + ): ConversationComposerUiState +} + +internal class ConversationComposerUiStateMapperImpl @Inject constructor() : + ConversationComposerUiStateMapper { + + override fun map( + audioRecording: ConversationAudioRecordingUiState, + draftState: ConversationDraftState, + attachments: ImmutableList, + composerAvailability: ConversationComposerAvailability, + subscriptions: ImmutableList, + ): ConversationComposerUiState { + val draft = draftState.draft + val hasWorkingDraft = draft.hasContent + val visibleSendProtocol = when { + hasWorkingDraft -> draftState.sendProtocol + else -> ConversationDraftSendProtocol.SMS + } + + val isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled && + !draft.isCheckingDraft && + !draft.isSending + + val isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled + val shouldShowRecordAction = !hasWorkingDraft && + audioRecording.phase == ConversationAudioRecordingPhase.Idle + + val isRecordActionEnabled = composerAvailability.isSendAvailable && + !draft.isCheckingDraft && + !draft.isSending && + draftState.pendingAttachments.isEmpty() + + val isSendEnabled = composerAvailability.isSendAvailable && + hasWorkingDraft && + !draft.isCheckingDraft && + !draft.isSending && + draftState.pendingAttachments.isEmpty() + + return ConversationComposerUiState( + audioRecording = audioRecording, + attachments = attachments, + messageText = draft.messageText, + subjectText = draft.subjectText, + selfParticipantId = draft.selfParticipantId, + simSelector = buildSimSelectorUiState( + subscriptions = subscriptions, + selfParticipantId = draft.selfParticipantId, + ), + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + isSendEnabled = isSendEnabled, + shouldShowRecordAction = shouldShowRecordAction, + hasWorkingDraft = hasWorkingDraft, + sendProtocol = visibleSendProtocol, + attachmentCount = draft.attachments.size, + pendingAttachmentCount = draftState.pendingAttachments.size, + segmentCounter = buildSegmentCounterUiState( + draft = draft, + sendProtocol = visibleSendProtocol, + ), + isCheckingDraft = draft.isCheckingDraft, + isSending = draft.isSending, + disabledReason = composerAvailability.disabledReason, + ) + } + + private fun buildSegmentCounterUiState( + draft: ConversationDraft, + sendProtocol: ConversationDraftSendProtocol, + ): ConversationSegmentCounterUiState? { + val isSms = sendProtocol == ConversationDraftSendProtocol.SMS + val messageText = draft.messageText + + if (!isSms || messageText.isBlank()) { + return null + } + + val stats = MessageTextStats().apply { + updateMessageTextStats(ParticipantData.DEFAULT_SELF_SUB_ID, messageText) + } + + val messageCount = stats.numMessagesToBeSent + val codePointsRemaining = stats.codePointsRemainingInCurrentMessage + + val isVisible = messageCount > 1 || + codePointsRemaining <= SEGMENT_COUNTER_VISIBILITY_THRESHOLD + + return when { + isVisible -> { + ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = codePointsRemaining, + messageCount = messageCount, + ) + } + + else -> null + } + } + + private fun buildSimSelectorUiState( + subscriptions: ImmutableList, + selfParticipantId: String, + ): ConversationSimSelectorUiState { + val selected = subscriptions + .firstOrNull { it.selfParticipantId == selfParticipantId } + ?: subscriptions.firstOrNull() + + return ConversationSimSelectorUiState( + subscriptions = subscriptions, + selectedSubscription = selected, + ) + } + + private companion object { + private const val SEGMENT_COUNTER_VISIBILITY_THRESHOLD = 10 + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt new file mode 100644 index 00000000..712ab4de --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt @@ -0,0 +1,86 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel + +@Immutable +internal sealed interface ComposerAttachmentUiModel { + val key: String + val contentType: String + val contentUri: String + + @Immutable + sealed interface Pending : ComposerAttachmentUiModel { + val displayName: String + + @Immutable + data class Generic( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val displayName: String, + ) : Pending + + @Immutable + data class AudioFinalizing( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val displayName: String, + ) : Pending + } + + @Immutable + sealed interface Resolved : ComposerAttachmentUiModel { + + @Immutable + sealed interface VisualMedia : Resolved { + val captionText: String + val width: Int? + val height: Int? + + @Immutable + data class Image( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val captionText: String, + override val width: Int?, + override val height: Int?, + ) : VisualMedia + + @Immutable + data class Video( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val captionText: String, + override val width: Int?, + override val height: Int?, + ) : VisualMedia + } + + @Immutable + data class Audio( + override val key: String, + override val contentType: String, + override val contentUri: String, + val durationMillis: Long, + ) : Resolved + + @Immutable + data class File( + override val key: String, + override val contentType: String, + override val contentUri: String, + ) : Resolved + + @Immutable + data class VCard( + override val key: String, + override val contentType: String, + override val contentUri: String, + val vCardUiModel: ConversationVCardAttachmentUiModel, + ) : Resolved + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt new file mode 100644 index 00000000..e2981d8b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt @@ -0,0 +1,31 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationComposerUiState( + val audioRecording: ConversationAudioRecordingUiState = ConversationAudioRecordingUiState(), + val attachments: ImmutableList = persistentListOf(), + val messageText: String = "", + val subjectText: String = "", + val selfParticipantId: String = "", + val simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), + val isMessageFieldEnabled: Boolean = false, + val isAttachmentActionEnabled: Boolean = false, + val isRecordActionEnabled: Boolean = false, + val isSendEnabled: Boolean = false, + val shouldShowRecordAction: Boolean = false, + val hasWorkingDraft: Boolean = false, + val sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, + val attachmentCount: Int = 0, + val pendingAttachmentCount: Int = 0, + val segmentCounter: ConversationSegmentCounterUiState? = null, + val isCheckingDraft: Boolean = false, + val isSending: Boolean = false, + val disabledReason: ConversationComposerDisabledReason? = null, +) diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt new file mode 100644 index 00000000..1c57ba64 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt @@ -0,0 +1,11 @@ +package com.android.messaging.ui.conversation.composer.model + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol + +internal data class ConversationDraftState( + val draft: ConversationDraft = ConversationDraft(), + val pendingAttachments: List = emptyList(), + val sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, +) diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt new file mode 100644 index 00000000..615645e8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationSegmentCounterUiState( + val codePointsRemainingInCurrentMessage: Int, + val messageCount: Int, +) diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt new file mode 100644 index 00000000..125cd5fd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationSendActionButtonGestureState( + val cancelDragDistancePx: Float = 0f, + val lockDragDistancePx: Float = 0f, +) diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt new file mode 100644 index 00000000..28dfa8e2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt @@ -0,0 +1,10 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal enum class ConversationSendActionButtonMode { + Send, + Record, + Stop, +} diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt new file mode 100644 index 00000000..33e2cb10 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.subscription.model.Subscription +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationSimSelectorUiState( + val subscriptions: ImmutableList = persistentListOf(), + val selectedSubscription: Subscription? = null, +) { + val isAvailable: Boolean + get() = subscriptions.size > 1 +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt new file mode 100644 index 00000000..6d54b803 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt @@ -0,0 +1,468 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.attachment.ui.ConversationVCardAttachmentCardContent +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.conversationAttachmentPreviewItemTestTag +import com.android.messaging.ui.conversation.conversationAttachmentPreviewRemoveButtonTestTag +import kotlinx.collections.immutable.ImmutableList + +private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp +private val ATTACHMENT_PREVIEW_CARD_HEIGHT = 88.dp +private val ATTACHMENT_PREVIEW_CARD_WIDTH = 220.dp +private val ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_MARGIN = 4.dp +private val ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE = 24.dp +private const val ATTACHMENT_PREVIEW_SIZE_PX = 256 + +@Composable +internal fun ConversationAttachmentPreview( + modifier: Modifier = Modifier, + attachments: ImmutableList, + onPendingAttachmentRemove: (String) -> Unit, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, + onResolvedAttachmentRemove: (String) -> Unit, +) { + if (attachments.isEmpty()) { + return + } + + LazyRow( + modifier = modifier.testTag(CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG), + contentPadding = PaddingValues( + start = 12.dp, + top = 4.dp, + end = 12.dp, + bottom = 4.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = attachments, + key = { attachment -> attachment.key }, + ) { attachment -> + when (attachment) { + is ComposerAttachmentUiModel.Pending.AudioFinalizing -> { + PendingAudioAttachmentPreviewItem( + attachmentKey = attachment.key, + ) + } + + is ComposerAttachmentUiModel.Pending.Generic -> { + PendingAttachmentPreviewItem( + attachmentKey = attachment.key, + onRemoveClick = { + onPendingAttachmentRemove(attachment.key) + }, + ) + } + + is ComposerAttachmentUiModel.Resolved -> { + ResolvedAttachmentPreviewItem( + attachment = attachment, + attachmentKey = attachment.key, + onAttachmentClick = { + onResolvedAttachmentClick(attachment) + }, + onRemoveClick = { + onResolvedAttachmentRemove(attachment.contentUri) + }, + ) + } + } + } + } +} + +@Composable +private fun PendingAttachmentPreviewItem( + attachmentKey: String, + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + modifier = Modifier.size(88.dp), + attachmentKey = attachmentKey, + onClick = {}, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.dp, + ) + } + + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } +} + +@Composable +private fun PendingAudioAttachmentPreviewItem( + attachmentKey: String, +) { + AttachmentPreviewItemContainer( + modifier = Modifier + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .wrapContentWidth(), + attachmentKey = attachmentKey, + onClick = {}, + ) { + Row( + modifier = Modifier + .wrapContentWidth() + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(shape = RoundedCornerShape(size = 20.dp)) + .background(color = MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + modifier = Modifier.wrapContentWidth(), + text = stringResource(id = R.string.audio_recording_finalizing_attachment_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } +} + +@Composable +private fun ResolvedAttachmentPreviewItem( + attachment: ComposerAttachmentUiModel.Resolved, + attachmentKey: String, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + when (attachment) { + is ComposerAttachmentUiModel.Resolved.VCard -> { + ConversationVCardAttachmentPreviewItem( + attachmentKey = attachmentKey, + uiModel = attachment.vCardUiModel, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + + is ComposerAttachmentUiModel.Resolved.Audio -> { + ConversationAudioAttachmentPreviewItem( + attachmentKey = attachmentKey, + durationMillis = attachment.durationMillis, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + + is ComposerAttachmentUiModel.Resolved.File, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Image, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Video, + -> { + ConversationResolvedAttachmentThumbnailPreviewItem( + attachment = attachment, + attachmentKey = attachmentKey, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + } +} + +@Composable +private fun ConversationResolvedAttachmentThumbnailPreviewItem( + attachment: ComposerAttachmentUiModel.Resolved, + attachmentKey: String, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + val thumbnailSize = IntSize( + width = ATTACHMENT_PREVIEW_SIZE_PX, + height = ATTACHMENT_PREVIEW_SIZE_PX, + ) + + AttachmentPreviewItemContainer( + modifier = Modifier.size(90.dp), + attachmentKey = attachmentKey, + onClick = onAttachmentClick, + ) { + ConversationMediaThumbnail( + modifier = Modifier.fillMaxSize(), + contentUri = attachment.contentUri, + contentType = attachment.contentType, + size = thumbnailSize, + ) + + if (attachment is ComposerAttachmentUiModel.Resolved.VisualMedia.Video) { + VideoAttachmentOverlay() + } + + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } +} + +@Composable +private fun VideoAttachmentOverlay() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +private fun ConversationAudioAttachmentPreviewItem( + attachmentKey: String, + durationMillis: Long, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + modifier = Modifier + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .wrapContentWidth(), + attachmentKey = attachmentKey, + onClick = onAttachmentClick, + ) { + Row( + modifier = Modifier + .wrapContentWidth() + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(shape = RoundedCornerShape(size = 20.dp)) + .background(color = MaterialTheme.colorScheme.primary) + .padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + + Text( + modifier = Modifier.wrapContentWidth(), + text = formatConversationAudioDuration(durationMillis = durationMillis), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + + Box( + modifier = Modifier + .width( + width = ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE + + ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_MARGIN, + ) + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + InlineAudioRemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } + } + } +} + +@Composable +private fun ConversationVCardAttachmentPreviewItem( + attachmentKey: String, + uiModel: ConversationVCardAttachmentUiModel, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + modifier = Modifier.size( + width = ATTACHMENT_PREVIEW_CARD_WIDTH, + height = ATTACHMENT_PREVIEW_CARD_HEIGHT, + ), + attachmentKey = attachmentKey, + onClick = onAttachmentClick, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + ConversationVCardAttachmentCardContent( + modifier = Modifier + .fillMaxWidth() + .align(alignment = Alignment.CenterStart) + .padding(horizontal = 16.dp, vertical = 12.dp), + type = uiModel.type, + avatarUri = uiModel.avatarUri, + titleText = uiModel.titleText, + titleTextResId = uiModel.titleTextResId, + subtitleText = uiModel.subtitleText, + subtitleTextResId = uiModel.subtitleTextResId, + ) + + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } + } +} + +@Composable +private fun AttachmentPreviewItemContainer( + modifier: Modifier = Modifier, + attachmentKey: String, + onClick: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS)) + .clickable(onClick = onClick) + .testTag( + conversationAttachmentPreviewItemTestTag( + attachmentKey = attachmentKey, + ), + ), + shape = RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Box(content = content) + } +} + +@Composable +private fun BoxScope.RemoveAttachmentButton( + attachmentKey: String, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(28.dp) + .testTag( + conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey = attachmentKey, + ), + ), + onClick = onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = pluralStringResource( + id = R.plurals.attachment_preview_close_content_description, + count = 1, + ), + ) + } +} + +@Composable +private fun InlineAudioRemoveAttachmentButton( + attachmentKey: String, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = Modifier + .size(size = ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE) + .testTag( + conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey = attachmentKey, + ), + ), + onClick = onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = pluralStringResource( + id = R.plurals.attachment_preview_close_content_description, + count = 1, + ), + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt new file mode 100644 index 00000000..44677e71 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt @@ -0,0 +1,408 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration + +private const val AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD = 0.7f + +@Composable +internal fun ConversationAudioRecordingBar( + modifier: Modifier = Modifier, + durationMillis: Long, + cancelProgress: Float, + isCancellationArmed: Boolean, +) { + val visualState = animateAudioRecordingBarVisualState( + cancelProgress = cancelProgress, + isCancellationArmed = isCancellationArmed, + ) + + Box( + modifier = modifier + .fillMaxWidth() + .height(height = 56.dp) + .testTag(CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(height = 56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(size = 28.dp), + ) + .padding( + horizontal = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AudioRecordingDeleteIcon( + isVisible = isCancellationArmed, + tint = visualState.deleteIconTint, + ) + + Spacer(modifier = Modifier.width(width = 4.dp)) + + AudioRecordingDurationLabel( + durationMillis = durationMillis, + contentColor = visualState.contentColor, + ) + + AudioRecordingCancelHint( + modifier = Modifier + .weight(weight = 1f) + .padding(end = 8.dp), + contentColor = visualState.contentColor, + hintAlpha = visualState.hintAlpha, + ) + } + } +} + +@Composable +private fun animateAudioRecordingBarVisualState( + cancelProgress: Float, + isCancellationArmed: Boolean, +): AudioRecordingBarVisualState { + val visualProgress = when { + isCancellationArmed -> 1f + else -> { + (cancelProgress / AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + } + } + + val contentColor = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = when { + isCancellationArmed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + fraction = visualProgress, + ) + + val deleteIconTint = animateColorAsRecordingState( + isCancellationArmed = isCancellationArmed, + visualProgress = visualProgress, + ) + + val hintAlpha = animateFloatAsState( + targetValue = 1f - (visualProgress * 0.45f), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_hint_alpha", + ).value + + return AudioRecordingBarVisualState( + contentColor = contentColor, + deleteIconTint = deleteIconTint, + hintAlpha = hintAlpha, + ) +} + +@Composable +private fun AudioRecordingDeleteIcon( + isVisible: Boolean, + tint: Color, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 220)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 140)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 180), + shrinkTowards = Alignment.Start, + ), + ) { + Icon( + modifier = Modifier.testTag(CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG), + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = tint, + ) + } +} + +@Composable +private fun AudioRecordingDurationLabel( + durationMillis: Long, + contentColor: Color, +) { + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(durationMillis = 140)) + + slideInHorizontally( + animationSpec = tween(durationMillis = 200), + initialOffsetX = { fullWidth -> + -(fullWidth / 6) + }, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RecordingIndicatorDot() + + Text( + modifier = Modifier.padding( + start = 8.dp, + end = 12.dp, + ), + text = formatConversationAudioDuration(durationMillis = durationMillis), + style = MaterialTheme.typography.titleMedium, + color = contentColor, + ) + } + } +} + +@Composable +private fun AudioRecordingCancelHint( + modifier: Modifier = Modifier, + contentColor: Color, + hintAlpha: Float, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + tint = contentColor.copy(alpha = hintAlpha), + ) + + Text( + modifier = Modifier.padding(start = 4.dp), + text = stringResource(id = R.string.conversation_audio_recording_slide_to_cancel), + style = MaterialTheme.typography.titleMedium, + color = contentColor.copy(alpha = hintAlpha), + ) + } +} + +@Composable +private fun animateColorAsRecordingState( + isCancellationArmed: Boolean, + visualProgress: Float, +): Color { + return animateColorAsState( + targetValue = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = when { + isCancellationArmed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + fraction = visualProgress, + ), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_delete_icon_color", + ).value +} + +@Composable +private fun RecordingIndicatorDot() { + val pulseTransition = rememberInfiniteTransition( + label = "conversation_audio_recording_dot", + ) + + val dotScale = pulseTransition.animateFloat( + initialValue = 0.9f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 900, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "conversation_audio_recording_dot_scale", + ).value + val dotAlpha = pulseTransition.animateFloat( + initialValue = 0.6f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 900, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "conversation_audio_recording_dot_alpha", + ).value + + Box( + modifier = Modifier + .size(size = 10.dp) + .graphicsLayer { + scaleX = dotScale + scaleY = dotScale + alpha = dotAlpha + } + .background( + color = MaterialTheme.colorScheme.error, + shape = RoundedCornerShape(size = 100.dp), + ), + ) +} + +@Composable +internal fun ConversationAudioRecordingLockAffordance( + modifier: Modifier = Modifier, + lockProgress: Float, +) { + val visualState = animateConversationAudioRecordingLockAffordanceVisualState( + lockProgress = lockProgress, + ) + + Column( + modifier = modifier + .graphicsLayer { + scaleX = visualState.scale + scaleY = visualState.scale + translationY = visualState.verticalTranslation + } + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(size = 24.dp), + ) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(size = 24.dp), + ) + .padding( + paddingValues = PaddingValues( + horizontal = 10.dp, + vertical = 8.dp, + ), + ) + .testTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(size = 18.dp), + imageVector = Icons.Rounded.Lock, + contentDescription = null, + tint = visualState.contentColor, + ) + + ConversationAudioRecordingLockAffordanceDivider( + color = visualState.contentColor, + ) + + Icon( + modifier = Modifier.size(size = 18.dp), + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null, + tint = visualState.contentColor, + ) + } +} + +@Composable +private fun animateConversationAudioRecordingLockAffordanceVisualState( + lockProgress: Float, +): ConversationAudioRecordingLockAffordanceVisualState { + val resolvedLockProgress = lockProgress.coerceIn(minimumValue = 0f, maximumValue = 1f) + + val contentColor = animateColorAsState( + targetValue = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = MaterialTheme.colorScheme.onSurface, + fraction = resolvedLockProgress, + ), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_content_color", + ).value + + val scale = animateFloatAsState( + targetValue = 0.96f + (resolvedLockProgress * 0.06f), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_scale", + ).value + + return ConversationAudioRecordingLockAffordanceVisualState( + contentColor = contentColor, + scale = scale, + verticalTranslation = -8f * resolvedLockProgress, + ) +} + +@Composable +private fun ConversationAudioRecordingLockAffordanceDivider(color: Color) { + Spacer( + modifier = Modifier + .padding(vertical = 4.dp) + .size( + width = 18.dp, + height = 1.dp, + ) + .background( + color = color.copy(alpha = 0.2f), + shape = CircleShape, + ), + ) +} + +private data class AudioRecordingBarVisualState( + val contentColor: Color, + val deleteIconTint: Color, + val hintAlpha: Float, +) + +private data class ConversationAudioRecordingLockAffordanceVisualState( + val contentColor: Color, + val scale: Float, + val verticalTranslation: Float, +) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt new file mode 100644 index 00000000..e89c3f1a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -0,0 +1,627 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEGMENT_COUNTER_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ConversationSegmentCounterUiState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.conversationShape + +internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp +internal val AUDIO_RECORD_LOCK_THRESHOLD = 72.dp + +private const val CONTENT_SWAP_ENTER_FADE_DURATION_MILLIS = 160 +private const val CONTENT_SWAP_ENTER_SLIDE_DURATION_MILLIS = 220 +private const val CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR = 10 +private const val CONTENT_SWAP_EXIT_FADE_DURATION_MILLIS = 120 +private const val CONTENT_SWAP_EXIT_SLIDE_DURATION_MILLIS = 180 +private const val CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR = 12 + +@Composable +internal fun ConversationComposeBar( + modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, + messageText: String, + subjectText: String, + sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, + isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, + messageFieldFocusRequester: FocusRequester? = null, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onMessageTextChange: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingCancel: () -> Unit, + onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, +) { + val presentation = rememberConversationComposeBarPresentation() + val recordingGestureController = rememberConversationAudioRecordingGestureController( + audioRecording = audioRecording, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingCancel = onAudioRecordingCancel, + ) + + Box( + modifier = modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding() + .testTag(CONVERSATION_COMPOSE_BAR_TEST_TAG), + ) { + ConversationComposeInputContent( + audioRecording = audioRecording, + messageText = messageText, + subjectText = subjectText, + sendProtocol = sendProtocol, + segmentCounter = segmentCounter, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + isSendActionEnabled = isSendActionEnabled, + shouldShowRecordAction = shouldShowRecordAction, + recordingGestureState = recordingGestureController.recordingGestureState, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = recordingGestureController.onAudioRecordingStartRequest, + onAudioRecordingDrag = recordingGestureController.onAudioRecordingDrag, + onAudioRecordingLock = recordingGestureController.onAudioRecordingLock, + onAudioRecordingFinish = recordingGestureController.onAudioRecordingFinish, + onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, + ) + } +} + +@Composable +private fun rememberConversationAudioRecordingGestureController( + audioRecording: ConversationAudioRecordingUiState, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingCancel: () -> Unit, +): ConversationAudioRecordingGestureController { + val hapticFeedback = LocalHapticFeedback.current + + var recordingGestureState by remember { + mutableStateOf(ConversationSendActionButtonGestureState()) + } + + LaunchedEffect(audioRecording.phase) { + if (audioRecording.phase != ConversationAudioRecordingPhase.Recording) { + recordingGestureState = ConversationSendActionButtonGestureState() + } + } + + return ConversationAudioRecordingGestureController( + recordingGestureState = recordingGestureState, + onAudioRecordingStartRequest = { + recordingGestureState = ConversationSendActionButtonGestureState() + onAudioRecordingStartRequest() + }, + onAudioRecordingDrag = { gestureState -> + recordingGestureState = gestureState + }, + onAudioRecordingLock = { + when { + audioRecording.isLocked -> false + + else -> { + recordingGestureState = ConversationSendActionButtonGestureState() + val didLockRecording = onAudioRecordingLock() + if (didLockRecording) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) + } + didLockRecording + } + } + }, + onAudioRecordingFinish = { shouldCancelRecording -> + recordingGestureState = ConversationSendActionButtonGestureState() + when { + shouldCancelRecording -> onAudioRecordingCancel() + else -> onAudioRecordingFinish() + } + }, + ) +} + +@Composable +internal fun ConversationComposeInputContent( + audioRecording: ConversationAudioRecordingUiState, + messageText: String, + subjectText: String, + sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, + isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, + recordingGestureState: ConversationSendActionButtonGestureState, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onMessageTextChange: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingFinish: (Boolean) -> Unit, + onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, +) { + val inputState = conversationComposeInputState( + audioRecording = audioRecording, + recordingGestureState = recordingGestureState, + shouldShowRecordAction = shouldShowRecordAction, + isRecordActionEnabled = isRecordActionEnabled, + isSendActionEnabled = isSendActionEnabled, + ) + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(inputState.isActiveRecording) { + if (inputState.isActiveRecording) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy( + space = 8.dp, + ), + verticalAlignment = Alignment.Bottom, + ) { + ConversationComposeMessageRecordingContent( + modifier = Modifier.weight(weight = 1f), + messageText = messageText, + subjectText = subjectText, + sendProtocol = sendProtocol, + durationMillis = audioRecording.durationMillis, + inputState = inputState, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, + ) + + ConversationComposeInputSendAction( + audioRecording = audioRecording, + inputState = inputState, + segmentCounter = segmentCounter, + onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingDrag = onAudioRecordingDrag, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingFinish = onAudioRecordingFinish, + ) + } +} + +@Composable +private fun ConversationComposeInputSendAction( + modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, + inputState: ConversationComposeInputState, + segmentCounter: ConversationSegmentCounterUiState?, + onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingFinish: (Boolean) -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (segmentCounter != null && !inputState.isActiveRecording) { + SegmentCounterIndicator(state = segmentCounter) + } + + ConversationComposeSendAction( + modifier = Modifier + .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .semantics { + conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE + }, + enabled = inputState.isRecordingControlEnabled, + mode = conversationComposeSendActionMode( + isRecordMode = inputState.isRecordMode, + isRecordingLocked = audioRecording.isLocked, + ), + isRecordingActive = inputState.isActiveRecording, + isRecordingLocked = audioRecording.isLocked, + shouldShowLockAffordance = inputState.isActiveRecording && !audioRecording.isLocked, + lockProgress = inputState.lockProgress, + onClick = onSendClick, + onLockedStopClick = { + onAudioRecordingFinish(false) + }, + onRecordGestureStart = onAudioRecordingStartRequest, + onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureLock = onAudioRecordingLock, + onRecordGestureFinish = onAudioRecordingFinish, + onSendActionLongClick = onSendActionLongClick, + ) + } +} + +@Composable +private fun conversationComposeInputState( + audioRecording: ConversationAudioRecordingUiState, + recordingGestureState: ConversationSendActionButtonGestureState, + shouldShowRecordAction: Boolean, + isRecordActionEnabled: Boolean, + isSendActionEnabled: Boolean, +): ConversationComposeInputState { + val cancelThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_CANCEL_THRESHOLD.toPx() + } + val lockThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_LOCK_THRESHOLD.toPx() + } + val cancelProgress = (recordingGestureState.cancelDragDistancePx / cancelThresholdPx) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + + val lockProgress = when { + audioRecording.isLocked -> 1f + + else -> { + (recordingGestureState.lockDragDistancePx / lockThresholdPx) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + } + } + val isActiveRecording = audioRecording.phase == ConversationAudioRecordingPhase.Recording + val isRecordMode = shouldShowRecordAction || isActiveRecording + + val isRecordingControlEnabled = when { + isActiveRecording -> true + isRecordMode -> isRecordActionEnabled + else -> isSendActionEnabled + } + + return ConversationComposeInputState( + cancelProgress = cancelProgress, + lockProgress = lockProgress, + isCancellationArmed = cancelProgress >= 1f, + isActiveRecording = isActiveRecording, + isRecordMode = isRecordMode, + isRecordingControlEnabled = isRecordingControlEnabled, + ) +} + +@Composable +private fun ConversationComposeMessageRecordingContent( + modifier: Modifier = Modifier, + messageText: String, + subjectText: String, + sendProtocol: ConversationDraftSendProtocol, + durationMillis: Long, + inputState: ConversationComposeInputState, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onMessageTextChange: (String) -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, +) { + val isTextEntryEnabled = isMessageFieldEnabled && !inputState.isActiveRecording + val isInputActionEnabled = !inputState.isActiveRecording + + Surface( + modifier = modifier, + shape = presentation.fieldShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column { + if (!subjectText.isBlank()) { + ConversationSubjectChip( + subjectText = subjectText, + onClick = onSubjectChipClick, + onClear = onSubjectChipClear, + ) + } + + Box { + ConversationComposeMessageField( + modifier = Modifier.fillMaxWidth(), + value = messageText, + onValueChange = { updatedMessageText -> + if (!inputState.isActiveRecording) { + onMessageTextChange(updatedMessageText) + } + }, + enabled = isTextEntryEnabled, + sendProtocol = sendProtocol, + isVisuallyHidden = inputState.isActiveRecording, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + isAttachmentActionEnabled = isAttachmentActionEnabled && isInputActionEnabled, + isAudioRecordActionEnabled = isRecordActionEnabled && isInputActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onLockedAudioRecordingStartRequest, + ) + + ConversationAudioRecordingContentOverlay( + modifier = Modifier.matchParentSize(), + isActiveRecording = inputState.isActiveRecording, + durationMillis = durationMillis, + cancelProgress = inputState.cancelProgress, + isCancellationArmed = inputState.isCancellationArmed, + ) + } + } + } +} + +@Composable +private fun ConversationAudioRecordingContentOverlay( + modifier: Modifier = Modifier, + isActiveRecording: Boolean, + durationMillis: Long, + cancelProgress: Float, + isCancellationArmed: Boolean, +) { + AnimatedContent( + modifier = modifier, + targetState = isActiveRecording, + transitionSpec = { + contentSwapTransition() + }, + label = "conversation_compose_content", + ) { isRecording -> + when { + isRecording -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart, + ) { + ConversationAudioRecordingBar( + durationMillis = durationMillis, + cancelProgress = cancelProgress, + isCancellationArmed = isCancellationArmed, + ) + } + } + + else -> { + Box(modifier = Modifier.fillMaxSize()) + } + } + } +} + +private fun conversationComposeSendActionMode( + isRecordMode: Boolean, + isRecordingLocked: Boolean, +): ConversationSendActionButtonMode { + return when { + isRecordMode && isRecordingLocked -> ConversationSendActionButtonMode.Stop + isRecordMode -> ConversationSendActionButtonMode.Record + else -> ConversationSendActionButtonMode.Send + } +} + +private fun contentSwapTransition(): ContentTransform { + val enterTransition = fadeIn( + animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_FADE_DURATION_MILLIS), + ) + slideInHorizontally( + animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_SLIDE_DURATION_MILLIS), + initialOffsetX = { fullWidth -> + fullWidth / CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR + }, + ) + + val exitTransition = fadeOut( + animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_FADE_DURATION_MILLIS), + ) + slideOutHorizontally( + animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_SLIDE_DURATION_MILLIS), + targetOffsetX = { fullWidth -> + -(fullWidth / CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR) + }, + ) + + return enterTransition.togetherWith(exitTransition) +} + +@Composable +private fun ConversationComposeSendAction( + modifier: Modifier = Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, + isRecordingActive: Boolean, + isRecordingLocked: Boolean, + shouldShowLockAffordance: Boolean, + lockProgress: Float, + onClick: () -> Unit, + onLockedStopClick: () -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, + onSendActionLongClick: () -> Unit, +) { + Box( + modifier = Modifier.heightIn( + min = 56.dp, + max = 56.dp, + ), + ) { + ConversationSendActionButton( + modifier = modifier, + enabled = enabled, + mode = mode, + isRecordingActive = isRecordingActive, + isRecordingLocked = isRecordingLocked, + onClick = onClick, + onLockedStopClick = onLockedStopClick, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + onRecordGestureFinish = onRecordGestureFinish, + onSendActionLongClick = onSendActionLongClick, + ) + + if (shouldShowLockAffordance) { + ConversationAudioRecordingLockAffordance( + modifier = Modifier + .align(alignment = Alignment.TopCenter) + .padding(top = 2.dp) + .offset(y = (-74).dp), + lockProgress = lockProgress, + ) + } + } +} + +@Composable +private fun SegmentCounterIndicator( + state: ConversationSegmentCounterUiState, +) { + val displayText = when { + state.messageCount > 1 -> stringResource( + id = R.string.conversation_segment_counter_multi, + state.codePointsRemainingInCurrentMessage, + state.messageCount, + ) + + else -> state.codePointsRemainingInCurrentMessage.toString() + } + + val accessibilityDescription = when { + state.messageCount > 1 -> pluralStringResource( + id = R.plurals.conversation_segment_counter_content_description, + count = state.codePointsRemainingInCurrentMessage, + state.codePointsRemainingInCurrentMessage, + state.messageCount, + ) + + else -> pluralStringResource( + id = R.plurals.conversation_segment_counter_single_content_description, + count = state.codePointsRemainingInCurrentMessage, + state.codePointsRemainingInCurrentMessage, + ) + } + + Text( + modifier = Modifier + .padding(bottom = 4.dp) + .clearAndSetSemantics { + testTag = CONVERSATION_SEGMENT_COUNTER_TEST_TAG + contentDescription = accessibilityDescription + }, + text = displayText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private data class ConversationComposeInputState( + val cancelProgress: Float, + val lockProgress: Float, + val isCancellationArmed: Boolean, + val isActiveRecording: Boolean, + val isRecordMode: Boolean, + val isRecordingControlEnabled: Boolean, +) + +private data class ConversationAudioRecordingGestureController( + val recordingGestureState: ConversationSendActionButtonGestureState, + val onAudioRecordingStartRequest: () -> Unit, + val onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + val onAudioRecordingLock: () -> Boolean, + val onAudioRecordingFinish: (Boolean) -> Unit, +) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt new file mode 100644 index 00000000..01df3236 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt @@ -0,0 +1,321 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddCircleOutline +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_MMS_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TEXT_FIELD_TEST_TAG + +@Composable +internal fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { + val fieldColors = conversationComposeBarTextFieldColors() + + return remember(fieldColors) { + ConversationComposeBarPresentation( + fieldShape = RoundedCornerShape(size = 28.dp), + fieldColors = fieldColors, + ) + } +} + +@Composable +private fun conversationComposeBarTextFieldColors(): TextFieldColors { + return TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +internal fun ConversationComposeMessageField( + modifier: Modifier = Modifier, + value: String, + enabled: Boolean, + sendProtocol: ConversationDraftSendProtocol, + isVisuallyHidden: Boolean, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + isAttachmentActionEnabled: Boolean, + isAudioRecordActionEnabled: Boolean, + onValueChange: (String) -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, +) { + val focusRequesterModifier = messageFieldFocusRequester + ?.let(Modifier::focusRequester) + ?: Modifier + + val recordingVisibilityModifier = when { + isVisuallyHidden -> { + Modifier + .alpha(alpha = 0f) + .clearAndSetSemantics {} + } + + else -> Modifier + } + + val mmsText = stringResource(id = R.string.mms_text) + val sendProtocolSemanticsModifier = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + Modifier.semantics { + stateDescription = mmsText + } + } + + ConversationDraftSendProtocol.SMS -> Modifier + } + + TextField( + modifier = modifier + .then(focusRequesterModifier) + .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .heightIn(min = 56.dp) + .then(sendProtocolSemanticsModifier) + .then(recordingVisibilityModifier), + value = value, + onValueChange = onValueChange, + enabled = enabled, + shape = presentation.fieldShape, + colors = presentation.fieldColors, + placeholder = ::ConversationComposePlaceholder, + leadingIcon = { + ConversationComposeAttachmentMenu( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), + enabled = isAttachmentActionEnabled, + isAudioRecordActionEnabled = isAudioRecordActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onAudioAttachClick, + ) + }, + trailingIcon = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + { + MmsIndicator() + } + } + + ConversationDraftSendProtocol.SMS -> null + }, + minLines = 1, + maxLines = 4, + ) +} + +@Composable +private fun MmsIndicator() { + Text( + modifier = Modifier + .padding(end = 12.dp) + .clearAndSetSemantics { + testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG + }, + text = stringResource(id = R.string.mms_text), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) +} + +@Composable +private fun ConversationComposePlaceholder() { + Text( + text = stringResource(id = R.string.compose_message_view_hint_text), + style = MaterialTheme.typography.bodyLarge, + ) +} + +@Composable +private fun ConversationComposeAttachmentMenu( + modifier: Modifier = Modifier, + enabled: Boolean, + isAudioRecordActionEnabled: Boolean, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + var isExpanded by rememberSaveable { + mutableStateOf(value = false) + } + + fun closeMenuAndRun(action: () -> Unit) { + isExpanded = false + action() + } + + Box( + modifier = modifier, + ) { + IconButton( + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + isExpanded = true + }, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.AddCircleOutline, + contentDescription = stringResource( + id = R.string.attachMediaButtonContentDescription, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { + isExpanded = false + }, + shape = RoundedCornerShape(size = 24.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + offset = DpOffset( + x = 0.dp, + y = (-8).dp, + ), + properties = PopupProperties( + focusable = false, + clippingEnabled = false, + ), + ) { + ConversationComposeAttachmentMenuContent( + isAudioRecordActionEnabled = isAudioRecordActionEnabled, + onMediaPickerClick = { + closeMenuAndRun(action = onMediaPickerClick) + }, + onAudioAttachClick = { + closeMenuAndRun(action = onAudioAttachClick) + }, + onContactAttachClick = { + closeMenuAndRun(action = onContactAttachClick) + }, + ) + } + } +} + +@Composable +private fun ConversationComposeAttachmentMenuContent( + isAudioRecordActionEnabled: Boolean, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, + onContactAttachClick: () -> Unit, +) { + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Image, + textResId = R.string.mediapicker_gallery_title, + onClick = onMediaPickerClick, + ) + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Mic, + textResId = R.string.mediapicker_audio_title, + enabled = isAudioRecordActionEnabled, + onClick = onAudioAttachClick, + ) + + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Person, + textResId = R.string.mediapicker_contact_title, + onClick = onContactAttachClick, + ) +} + +@Composable +private fun ConversationComposeAttachmentMenuItem( + modifier: Modifier = Modifier, + imageVector: ImageVector, + @StringRes textResId: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = modifier, + text = { + Text(text = stringResource(id = textResId)) + }, + leadingIcon = { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(size = 24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = enabled, + onClick = onClick, + ) +} + +internal data class ConversationComposeBarPresentation( + val fieldShape: RoundedCornerShape, + val fieldColors: TextFieldColors, +) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt new file mode 100644 index 00000000..9a29c161 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt @@ -0,0 +1,80 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationSegmentCounterUiState +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun ConversationComposerSection( + modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, + attachments: ImmutableList, + messageText: String, + subjectText: String, + sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, + isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, + messageFieldFocusRequester: FocusRequester, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onMessageTextChange: (String) -> Unit, + onPendingAttachmentRemove: (String) -> Unit, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, + onResolvedAttachmentRemove: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingCancel: () -> Unit, + onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, +) { + Column( + modifier = modifier, + ) { + ConversationAttachmentPreview( + attachments = attachments, + onPendingAttachmentRemove = onPendingAttachmentRemove, + onResolvedAttachmentClick = onResolvedAttachmentClick, + onResolvedAttachmentRemove = onResolvedAttachmentRemove, + ) + + ConversationComposeBar( + audioRecording = audioRecording, + messageText = messageText, + subjectText = subjectText, + sendProtocol = sendProtocol, + segmentCounter = segmentCounter, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + isSendActionEnabled = isSendActionEnabled, + shouldShowRecordAction = shouldShowRecordAction, + messageFieldFocusRequester = messageFieldFocusRequester, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingCancel = onAudioRecordingCancel, + onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt new file mode 100644 index 00000000..98dc45fd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt @@ -0,0 +1,55 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal val CaptureStopIcon: ImageVector + get() { + val cachedIcon = cachedCaptureStopIcon + if (cachedIcon != null) { + return cachedIcon + } + + val icon = ImageVector.Builder( + name = "capture_stop", + defaultWidth = 61.5.dp, + defaultHeight = 61.5.dp, + viewportWidth = 246f, + viewportHeight = 246f, + ).apply { + path( + stroke = SolidColor(Color.Black), + strokeLineWidth = 12f, + ) { + moveTo(123f, 17.5f) + curveTo(181.266f, 17.5f, 228.5f, 64.734f, 228.5f, 123f) + curveTo(228.5f, 181.266f, 181.266f, 228.5f, 123f, 228.5f) + curveTo(64.734f, 228.5f, 17.5f, 181.266f, 17.5f, 123f) + curveTo(17.5f, 64.734f, 64.734f, 17.5f, 123f, 17.5f) + close() + } + + path( + fill = SolidColor(Color.Black), + ) { + moveTo(94f, 90f) + horizontalLineTo(152f) + curveTo(154.209f, 90f, 156f, 91.791f, 156f, 94f) + verticalLineTo(152f) + curveTo(156f, 154.209f, 154.209f, 156f, 152f, 156f) + horizontalLineTo(94f) + curveTo(91.791f, 156f, 90f, 154.209f, 90f, 152f) + verticalLineTo(94f) + curveTo(90f, 91.791f, 91.791f, 90f, 94f, 90f) + close() + } + }.build() + + cachedCaptureStopIcon = icon + return icon + } + +private var cachedCaptureStopIcon: ImageVector? = null diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt new file mode 100644 index 00000000..1f578798 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt @@ -0,0 +1,470 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode + +@Immutable +private data class ConversationSendActionButtonVisualState( + val buttonScale: Float, + val containerColor: Color, + val contentColor: Color, +) + +private val SEND_ACTION_BUTTON_PULSE_SCALE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, +) + +private val SEND_ACTION_BUTTON_BASE_SCALE_ANIMATION_SPEC = tween(durationMillis = 180) +private val SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC = tween(durationMillis = 220) +private val SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC = tween(durationMillis = 150) +private val SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC = tween(durationMillis = 120) + +private val SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, +) + +private val SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset( + offsetMillis = 1050, + offsetType = StartOffsetType.FastForward, + ), +) + +@Composable +internal fun ConversationSendActionButton( + modifier: Modifier = Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, + isRecordingActive: Boolean, + isRecordingLocked: Boolean, + onClick: () -> Unit, + onLockedStopClick: () -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, + onSendActionLongClick: () -> Unit, +) { + var isRecordGestureActive by remember(mode, enabled) { + mutableStateOf(value = false) + } + + val visualState = animateConversationSendActionButtonVisualState( + isRecordingActive = isRecordingActive, + isRecordGestureActive = isRecordGestureActive, + ) + + val cancelThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_CANCEL_THRESHOLD.toPx() + } + val lockThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_LOCK_THRESHOLD.toPx() + } + + val gestureModifier = Modifier.conversationSendActionButtonGesture( + mode = mode, + enabled = enabled, + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + isRecordingActive = isRecordingActive, + isRecordingLocked = isRecordingLocked, + onGestureActiveChange = { isActive -> + isRecordGestureActive = isActive + }, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + onRecordGestureFinish = onRecordGestureFinish, + onLockedStopClick = onLockedStopClick, + ) + + ConversationSendActionButtonLayout( + modifier = modifier, + isRecordingActive = isRecordingActive, + buttonModifier = gestureModifier, + enabled = enabled, + mode = mode, + onClick = onClick, + onLockedStopClick = onLockedStopClick, + onSendActionLongClick = onSendActionLongClick, + visualState = visualState, + ) +} + +@Composable +private fun animateConversationSendActionButtonVisualState( + isRecordingActive: Boolean, + isRecordGestureActive: Boolean, +): ConversationSendActionButtonVisualState { + val pulseAnimation = rememberInfiniteTransition( + label = "conversation_send_action_pulse", + ) + + val pulseScale by pulseAnimation.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = SEND_ACTION_BUTTON_PULSE_SCALE_ANIMATION_SPEC, + label = "conversation_send_action_pulse_scale", + ) + + val baseButtonScale by animateFloatAsState( + targetValue = when { + isRecordingActive -> 1.1f + isRecordGestureActive -> 0.95f + else -> 1f + }, + animationSpec = SEND_ACTION_BUTTON_BASE_SCALE_ANIMATION_SPEC, + label = "conversation_send_action_base_scale", + ) + + val buttonScale = when { + isRecordingActive -> baseButtonScale * pulseScale + else -> baseButtonScale + } + + val containerColor by animateColorAsState( + targetValue = when { + isRecordingActive -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + }, + animationSpec = SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC, + label = "conversation_send_action_container_color", + ) + + val contentColor by animateColorAsState( + targetValue = when { + isRecordingActive -> MaterialTheme.colorScheme.onError + else -> MaterialTheme.colorScheme.onPrimary + }, + animationSpec = SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC, + label = "conversation_send_action_content_color", + ) + + return ConversationSendActionButtonVisualState( + buttonScale = buttonScale, + containerColor = containerColor, + contentColor = contentColor, + ) +} + +@Composable +private fun ConversationSendActionButtonLayout( + modifier: Modifier, + isRecordingActive: Boolean, + buttonModifier: Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, + onClick: () -> Unit, + onLockedStopClick: () -> Unit, + onSendActionLongClick: () -> Unit, + visualState: ConversationSendActionButtonVisualState, +) { + Box( + modifier = modifier.size(size = 56.dp), + ) { + ConversationSendActionButtonPulseBackdrop( + isVisible = isRecordingActive, + ) + + ConversationSendActionButtonContent( + modifier = buttonModifier, + enabled = enabled, + mode = mode, + onClick = onClick, + onLockedStopClick = onLockedStopClick, + onSendActionLongClick = onSendActionLongClick, + visualState = visualState, + ) + } +} + +@Composable +private fun ConversationSendActionButtonContent( + modifier: Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, + onClick: () -> Unit, + onLockedStopClick: () -> Unit, + onSendActionLongClick: () -> Unit, + visualState: ConversationSendActionButtonVisualState, +) { + val containerColor = when { + enabled -> visualState.containerColor + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + + val contentColor = when { + enabled -> visualState.contentColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + modifier = Modifier + .fillMaxSize() + .scale(scale = visualState.buttonScale) + .stopSemanticsModifier( + mode = mode, + onLockedStopClick = onLockedStopClick, + ) + .then(modifier), + shape = CircleShape, + color = containerColor, + contentColor = contentColor, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .sendClickModifier( + mode = mode, + enabled = enabled, + onClick = onClick, + onSendActionLongClick = onSendActionLongClick, + ), + contentAlignment = Alignment.Center, + ) { + ConversationSendActionButtonIcon( + mode = mode, + ) + } + } +} + +@Composable +private fun Modifier.stopSemanticsModifier( + mode: ConversationSendActionButtonMode, + onLockedStopClick: () -> Unit, +): Modifier { + val stopContentDescription = stringResource( + id = R.string.audio_record_stop_content_description, + ) + + return when (mode) { + ConversationSendActionButtonMode.Stop -> { + semantics { + onClick(label = stopContentDescription) { + onLockedStopClick() + true + } + } + } + + else -> this + } +} + +@Composable +private fun Modifier.sendClickModifier( + mode: ConversationSendActionButtonMode, + enabled: Boolean, + onClick: () -> Unit, + onSendActionLongClick: () -> Unit, +): Modifier { + return when (mode) { + ConversationSendActionButtonMode.Send -> { + combinedClickable( + enabled = enabled, + role = Role.Button, + onClickLabel = stringResource(R.string.sendButtonContentDescription), + onLongClickLabel = stringResource( + id = R.string.sim_selector_button_content_description, + ), + onClick = onClick, + onLongClick = onSendActionLongClick, + ) + } + + else -> this + } +} + +@Composable +private fun ConversationSendActionButtonIcon( + mode: ConversationSendActionButtonMode, +) { + AnimatedContent( + targetState = mode, + transitionSpec = { + conversationSendActionButtonIconContentTransform() + }, + label = "conversation_send_action_icon", + ) { currentMode -> + when (currentMode) { + ConversationSendActionButtonMode.Send -> { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = stringResource( + id = R.string.sendButtonContentDescription, + ), + ) + } + + ConversationSendActionButtonMode.Record -> { + Icon( + imageVector = Icons.Rounded.Mic, + contentDescription = stringResource( + id = R.string.audio_record_view_content_description, + ), + ) + } + + ConversationSendActionButtonMode.Stop -> { + Icon( + imageVector = CaptureStopIcon, + contentDescription = stringResource( + id = R.string.audio_record_stop_content_description, + ), + ) + } + } + } +} + +private fun conversationSendActionButtonIconContentTransform(): ContentTransform { + val fadeInTransition = fadeIn( + animationSpec = SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC, + ) + val scaleInTransition = scaleIn( + animationSpec = SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC, + initialScale = 0.9f, + ) + val enterTransition = fadeInTransition + scaleInTransition + + val fadeOutTransition = fadeOut( + animationSpec = SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC, + ) + val scaleOutTransition = scaleOut( + animationSpec = SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC, + targetScale = 1.1f, + ) + val exitTransition = fadeOutTransition + scaleOutTransition + + return enterTransition.togetherWith(exitTransition) +} + +@Composable +private fun ConversationSendActionButtonPulseBackdrop( + isVisible: Boolean, +) { + if (!isVisible) { + return + } + + val pulseTransition = rememberInfiniteTransition( + label = "conversation_send_action_backdrop", + ) + + val outerPulseScale by pulseTransition.animateFloat( + initialValue = 1f, + targetValue = 2.9f, + animationSpec = SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC, + label = "conversation_send_action_outer_pulse_scale", + ) + + val outerPulseAlpha by pulseTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0f, + animationSpec = SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC, + label = "conversation_send_action_outer_pulse_alpha", + ) + + val innerPulseScale by pulseTransition.animateFloat( + initialValue = 1f, + targetValue = 2.5f, + animationSpec = SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC, + label = "conversation_send_action_inner_pulse_scale", + ) + + val innerPulseAlpha by pulseTransition.animateFloat( + initialValue = 0.15f, + targetValue = 0f, + animationSpec = SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC, + label = "conversation_send_action_inner_pulse_alpha", + ) + + ConversationSendActionPulseCircle( + scale = outerPulseScale, + alpha = outerPulseAlpha, + ) + + ConversationSendActionPulseCircle( + scale = innerPulseScale, + alpha = innerPulseAlpha, + ) +} + +@Composable +private fun ConversationSendActionPulseCircle( + scale: Float, + alpha: Float, +) { + Box( + modifier = Modifier + .fillMaxSize() + .scale(scale = scale) + .alpha(alpha = alpha) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt new file mode 100644 index 00000000..aa5ea6fb --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt @@ -0,0 +1,282 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode + +@Composable +internal fun Modifier.conversationSendActionButtonGesture( + mode: ConversationSendActionButtonMode, + enabled: Boolean, + cancelThresholdPx: Float, + lockThresholdPx: Float, + isRecordingActive: Boolean, + isRecordingLocked: Boolean, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +): Modifier { + val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) + val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) + val currentOnGestureActiveChange by rememberUpdatedState(newValue = onGestureActiveChange) + val currentOnRecordGestureStart by rememberUpdatedState(newValue = onRecordGestureStart) + val currentOnRecordGestureMove by rememberUpdatedState(newValue = onRecordGestureMove) + val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) + val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) + val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) + + if (mode == ConversationSendActionButtonMode.Send || !enabled) { + return this + } + + return pointerInput( + mode, + cancelThresholdPx, + lockThresholdPx, + ) { + awaitEachGesture { + val isLockedRecording = currentIsRecordingActive && currentIsRecordingLocked + + when { + isLockedRecording -> { + handleLockedRecordGesture( + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureFinish = currentOnRecordGestureFinish, + onLockedStopClick = currentOnLockedStopClick, + ) + } + + else -> { + handleRecordGesture( + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureStart = currentOnRecordGestureStart, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureLock = currentOnRecordGestureLock, + onRecordGestureFinish = currentOnRecordGestureFinish, + ) + } + } + } + } +} + +private suspend fun AwaitPointerEventScope.handleRecordGesture( + cancelThresholdPx: Float, + lockThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) + ?: return + + onGestureActiveChange(true) + onRecordGestureStart() + + trackRecordGestureDrag( + initialDown = initialDown, + longPressChange = longPressChange, + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + onRecordGestureFinish = onRecordGestureFinish, + ) +} + +private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( + initialDown: PointerInputChange, + longPressChange: PointerInputChange, + cancelThresholdPx: Float, + lockThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, +) { + var isRecordingLocked = false + + longPressChange.consume() + + val releaseGestureState = awaitRecordGestureRelease(initialDown = initialDown) { gestureState -> + isRecordingLocked = updateRecordGestureLockState( + gestureState = gestureState, + isRecordingLocked = isRecordingLocked, + lockThresholdPx = lockThresholdPx, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + ) + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + + if (releaseGestureState != null && !isRecordingLocked) { + onRecordGestureFinish(releaseGestureState.cancelDragDistancePx >= cancelThresholdPx) + } +} + +private fun updateRecordGestureLockState( + gestureState: ConversationSendActionButtonGestureState, + isRecordingLocked: Boolean, + lockThresholdPx: Float, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, +): Boolean { + var updatedIsRecordingLocked = isRecordingLocked + + if (!updatedIsRecordingLocked) { + onRecordGestureMove(gestureState) + + if (gestureState.lockDragDistancePx >= lockThresholdPx) { + updatedIsRecordingLocked = onRecordGestureLock() + + if (updatedIsRecordingLocked) { + onRecordGestureMove(ConversationSendActionButtonGestureState()) + } + } + } + + return updatedIsRecordingLocked +} + +private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + onGestureActiveChange(true) + initialDown.consume() + + val releaseGestureState = awaitRecordGestureRelease(initialDown = initialDown) { gestureState -> + onRecordGestureMove( + ConversationSendActionButtonGestureState( + cancelDragDistancePx = gestureState.cancelDragDistancePx, + ), + ) + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + + if (releaseGestureState != null) { + handleLockedRecordGestureRelease( + gestureState = releaseGestureState, + cancelThresholdPx = cancelThresholdPx, + onRecordGestureFinish = onRecordGestureFinish, + onLockedStopClick = onLockedStopClick, + ) + } +} + +private fun handleLockedRecordGestureRelease( + gestureState: ConversationSendActionButtonGestureState, + cancelThresholdPx: Float, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +) { + when { + gestureState.cancelDragDistancePx >= cancelThresholdPx -> { + onRecordGestureFinish(true) + } + + else -> { + onLockedStopClick() + } + } +} + +private fun resetRecordGestureDragUi( + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, +) { + onGestureActiveChange(false) + onRecordGestureMove(ConversationSendActionButtonGestureState()) +} + +private suspend fun AwaitPointerEventScope.awaitRecordGestureRelease( + initialDown: PointerInputChange, + onGestureChange: (ConversationSendActionButtonGestureState) -> Unit, +): ConversationSendActionButtonGestureState? { + var releaseGestureState: ConversationSendActionButtonGestureState? = null + var isTrackingGesture = true + + while (isTrackingGesture) { + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) + + if (pointerChange == null) { + isTrackingGesture = false + } else { + val gestureState = calculateRecordGestureState( + initialDown = initialDown, + pointerChange = pointerChange, + ) + + onGestureChange(gestureState) + pointerChange.consume() + + if (!pointerChange.pressed) { + releaseGestureState = gestureState + isTrackingGesture = false + } + } + } + + return releaseGestureState +} + +private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( + pointerId: PointerId, +): PointerInputChange? { + return awaitPointerEvent() + .changes + .firstOrNull { change -> + change.id == pointerId + } +} + +private fun calculateRecordGestureState( + initialDown: PointerInputChange, + pointerChange: PointerInputChange, +): ConversationSendActionButtonGestureState { + val cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) + .coerceAtLeast(minimumValue = 0f) + + val lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) + .coerceAtLeast(minimumValue = 0f) + + return ConversationSendActionButtonGestureState( + cancelDragDistancePx = cancelDragDistancePx, + lockDragDistancePx = lockDragDistancePx, + ) +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt new file mode 100644 index 00000000..11615579 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt @@ -0,0 +1,52 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.data.subscription.model.Subscription + +internal val ConversationSimAvatarDefaultSize: Dp = 40.dp + +@Composable +internal fun ConversationSimAvatar( + subscription: Subscription, + modifier: Modifier = Modifier, + size: Dp = ConversationSimAvatarDefaultSize, +) { + Box( + modifier = modifier + .size(size = size) + .clip(shape = CircleShape) + .background( + color = subscription.resolveAccentColor(), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = subscription.displaySlotId.toString(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } +} + +@Composable +private fun Subscription.resolveAccentColor(): Color { + return when (color) { + 0 -> MaterialTheme.colorScheme.primary + else -> Color(color = color) + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt new file mode 100644 index 00000000..481d768a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt @@ -0,0 +1,149 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.resolveDisplayName + +private val SHEET_VERTICAL_PADDING = 8.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationSimSelectorSheet( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + + ModalBottomSheet( + modifier = Modifier.testTag(tag = CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + ) { + ConversationSimSelectorSheetContent( + uiState = uiState, + onSimSelected = onSimSelected, + ) + } +} + +@Composable +private fun ConversationSimSelectorSheetContent( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = SHEET_VERTICAL_PADDING), + ) { + Text( + modifier = Modifier.padding( + start = 24.dp, + end = 24.dp, + top = 8.dp, + bottom = 12.dp, + ), + text = stringResource(id = R.string.sim_selector_sheet_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + uiState.subscriptions.forEach { subscription -> + val isSelected = subscription.selfParticipantId == + uiState.selectedSubscription?.selfParticipantId + + ConversationSimSelectorRow( + subscription = subscription, + isSelected = isSelected, + onClick = { onSimSelected(subscription.selfParticipantId) }, + ) + } + + Spacer(modifier = Modifier.height(height = SHEET_VERTICAL_PADDING)) + } +} + +@Composable +private fun ConversationSimSelectorRow( + subscription: Subscription, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = onClick, + ) + .testTag( + tag = conversationSimSelectorItemTestTag( + selfParticipantId = subscription.selfParticipantId, + ), + ) + .padding( + horizontal = 24.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + ConversationSimAvatar(subscription = subscription) + + Column(modifier = Modifier.weight(weight = 1f)) { + Text( + text = subscription.label.resolveDisplayName(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + subscription.displayDestination?.let { destination -> + Text( + text = destination, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(id = R.string.sim_selector_item_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt new file mode 100644 index 00000000..b4a1f19d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt @@ -0,0 +1,69 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_CHIP_TEST_TAG + +private val SUBJECT_CHIP_TEXT_VERTICAL_PADDING = 12.dp + +@Composable +internal fun ConversationSubjectChip( + subjectText: String, + onClick: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(tag = CONVERSATION_SUBJECT_CHIP_TEST_TAG), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(weight = 1f) + .padding( + start = 16.dp, + top = SUBJECT_CHIP_TEXT_VERTICAL_PADDING, + bottom = SUBJECT_CHIP_TEXT_VERTICAL_PADDING, + ), + text = subjectText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + IconButton( + modifier = Modifier + .padding(end = 4.dp) + .testTag(tag = CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG), + onClick = onClear, + ) { + Icon( + modifier = Modifier.size(size = 20.dp), + imageVector = Icons.Rounded.Cancel, + contentDescription = stringResource(R.string.delete_subject_content_description), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt new file mode 100644 index 00000000..9a0089b5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt @@ -0,0 +1,644 @@ +package com.android.messaging.ui.conversation.entry + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.R +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.entry.model.ConversationEntryUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal interface ConversationEntryScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onCreateGroupRequested() + fun onCreateGroupCanceled() + fun onCreateGroupRecipientClicked(destination: String) + fun onCreateGroupConfirmed() + + fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) + + fun onNewChatRecipientLongPressed(destination: String) + fun onNewChatRecipientSelected(destination: String) + + fun onSimSelected(selfParticipantId: String) + + fun onDraftPayloadConsumed(conversationId: String) + + fun onScrollPositionConsumed(conversationId: String) + + fun onPendingSelfParticipantIdConsumed(conversationId: String) + + fun onStartupAttachmentConsumed(conversationId: String) + + fun navigateBack() + fun navigateToConversation(conversationId: String) + + fun showMessage(messageResId: Int) +} + +internal const val RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS = 200L + +@HiltViewModel +internal class ConversationEntryViewModel @Inject constructor( + private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val subscriptionsRepository: SubscriptionsRepository, + private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, + private val resolveConversationId: ResolveConversationId, + private val savedStateHandle: SavedStateHandle, + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ViewModel(), + ConversationEntryScreenModel { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _uiState = MutableStateFlow( + value = restoreUiState(), + ) + private var resolveConversationJob: Job? = null + + override val effects = _effects.asSharedFlow() + override val uiState = _uiState.asStateFlow() + + init { + observeActiveSubscriptions() + } + + override fun onCreateGroupRequested() { + // Re-entering group creation should also abandon any in-flight resolution. + cancelConversationResolution() + val currentUiState = _uiState.value + + if (currentUiState.isCreatingGroup) { + return + } + + updateUiState( + currentUiState.copy( + isCreatingGroup = true, + selectedGroupRecipientDestinations = persistentListOf(), + ), + ) + } + + override fun onCreateGroupCanceled() { + cancelConversationResolution() + val currentUiState = _uiState.value + + val hasGroupStateToClear = currentUiState.isCreatingGroup || + currentUiState.selectedGroupRecipientDestinations.isNotEmpty() + + if (!hasGroupStateToClear) { + return + } + + updateUiState( + currentUiState.copy( + isCreatingGroup = false, + selectedGroupRecipientDestinations = persistentListOf(), + ), + ) + } + + override fun onCreateGroupRecipientClicked(destination: String) { + val editableGroupState = editableGroupStateOrNull() + + editableGroupState + ?.let { editableGroupState -> + updatedGroupRecipientDestinationsOrNull( + currentDestinations = editableGroupState.selectedGroupRecipientDestinations, + destination = destination, + ) + } + ?.let { updatedDestinations -> + updateUiState( + editableGroupState.copy( + selectedGroupRecipientDestinations = updatedDestinations.toImmutableList(), + ), + ) + } + } + + override fun onCreateGroupConfirmed() { + val state = editableGroupStateOrNull() ?: return + val destinations = state.selectedGroupRecipientDestinations + + val isSelectionValid = destinations.isNotEmpty() && + canAcceptRecipientCount(count = destinations.size) + + if (isSelectionValid) { + resolveConversation( + destinations = destinations, + resolvingRecipientDestination = null, + selfParticipantId = selectedSelfParticipantId(), + ) + } + } + + override fun onNewChatRecipientLongPressed(destination: String) { + val state = _uiState.value + + if (state.isResolvingConversation) { + return + } + + if (state.isCreatingGroup) { + onCreateGroupRecipientClicked(destination = destination) + return + } + + val trimmed = destination.trim() + val canSeedGroup = trimmed.isNotEmpty() && canAcceptRecipientCount(count = 1) + + if (canSeedGroup) { + updateUiState( + state.copy( + isCreatingGroup = true, + selectedGroupRecipientDestinations = persistentListOf(trimmed), + ), + ) + } + } + + override fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) { + // Each new launch should supersede any in-flight resolution from the previous one. + cancelConversationResolution() + + val processedLaunchGeneration = savedStateHandle.get( + PROCESSED_LAUNCH_GENERATION_KEY, + ) + + if (processedLaunchGeneration == launchRequest.launchGeneration) { + return + } + + updateUiState( + ConversationEntryUiState( + launchGeneration = launchRequest.launchGeneration, + conversationId = launchRequest.conversationId, + pendingDraft = launchRequest.draftData?.let { messageData -> + conversationMessageDataDraftMapper.map(messageData = messageData) + }, + pendingScrollPosition = launchRequest.messagePosition, + pendingStartupAttachment = buildStartupAttachmentOrNull( + contentUri = launchRequest.startupAttachmentUri, + contentType = launchRequest.startupAttachmentType, + ), + simSelectorState = _uiState.value.simSelectorState, + ), + ) + savedStateHandle[PENDING_DRAFT_DATA_KEY] = launchRequest.draftData + savedStateHandle[PENDING_SCROLL_POSITION_KEY] = launchRequest.messagePosition + savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration + } + + override fun onNewChatRecipientSelected(destination: String) { + val currentUiState = _uiState.value + + if (currentUiState.isResolvingConversation || currentUiState.isCreatingGroup) { + return + } + + resolveConversation( + destinations = listOf(destination), + resolvingRecipientDestination = destination, + selfParticipantId = selectedSelfParticipantId(), + ) + } + + override fun onSimSelected(selfParticipantId: String) { + val currentSimState = _uiState.value.simSelectorState + val selectedSubscription = currentSimState.subscriptions + .firstOrNull { it.selfParticipantId == selfParticipantId } + ?: return + + savedStateHandle[SIM_SELECTED_SELF_PARTICIPANT_ID_KEY] = selectedSubscription + .selfParticipantId + + updateUiState( + _uiState.value.copy( + simSelectorState = currentSimState.copy( + selectedSubscription = selectedSubscription, + ), + ), + ) + } + + override fun onDraftPayloadConsumed(conversationId: String) { + val currentUiState = _uiState.value + + if (currentUiState.conversationId == conversationId && + currentUiState.pendingDraft != null + ) { + updateUiState( + currentUiState.copy( + pendingDraft = null, + ), + ) + savedStateHandle[PENDING_DRAFT_DATA_KEY] = null + } + } + + override fun onScrollPositionConsumed(conversationId: String) { + val currentUiState = _uiState.value + + val hasPendingScrollPosition = currentUiState.pendingScrollPosition != null + + if (currentUiState.conversationId == conversationId && hasPendingScrollPosition) { + updateUiState( + currentUiState.copy( + pendingScrollPosition = null, + ), + ) + savedStateHandle[PENDING_SCROLL_POSITION_KEY] = null + } + } + + override fun onStartupAttachmentConsumed(conversationId: String) { + val currentUiState = _uiState.value + + val hasPendingStartupAttachment = currentUiState.pendingStartupAttachment != null + + if (currentUiState.conversationId == conversationId && hasPendingStartupAttachment) { + updateUiState( + currentUiState.copy( + pendingStartupAttachment = null, + ), + ) + } + } + + override fun onPendingSelfParticipantIdConsumed(conversationId: String) { + val currentUiState = _uiState.value + + val hasPendingSelfParticipantId = currentUiState.pendingSelfParticipantId != null + + if (currentUiState.conversationId == conversationId && hasPendingSelfParticipantId) { + updateUiState( + currentUiState.copy( + pendingSelfParticipantId = null, + ), + ) + } + } + + override fun navigateBack() { + cancelConversationResolution() + _effects.tryEmit(ConversationEntryEffect.NavigateBack) + } + + override fun navigateToConversation(conversationId: String) { + navigateToConversation( + conversationId = conversationId, + selfParticipantId = null, + ) + } + + private fun navigateToConversation( + conversationId: String, + selfParticipantId: String?, + ) { + val pendingSelfParticipantId = selfParticipantId + ?.takeUnless { it.isBlank() } + + updateUiState( + _uiState.value.copy( + conversationId = conversationId, + isCreatingGroup = false, + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, + pendingSelfParticipantId = pendingSelfParticipantId, + resolvingRecipientDestination = null, + selectedGroupRecipientDestinations = persistentListOf(), + ), + ) + + _effects.tryEmit( + ConversationEntryEffect.NavigateToConversation( + conversationId = conversationId, + ), + ) + } + + override fun showMessage(messageResId: Int) { + _effects.tryEmit( + ConversationEntryEffect.ShowMessage( + messageResId = messageResId, + ), + ) + } + + private fun restoreUiState(): ConversationEntryUiState { + val pendingDraftData = savedStateHandle.get( + PENDING_DRAFT_DATA_KEY, + ) + val startupAttachmentUri = savedStateHandle.get( + PENDING_STARTUP_ATTACHMENT_URI_KEY, + ) + val startupAttachmentType = savedStateHandle.get( + PENDING_STARTUP_ATTACHMENT_TYPE_KEY, + ) + + return ConversationEntryUiState( + launchGeneration = savedStateHandle[LAUNCH_GENERATION_KEY], + conversationId = savedStateHandle[CONVERSATION_ID_KEY], + isCreatingGroup = savedStateHandle[IS_CREATING_GROUP_KEY] ?: false, + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, + pendingDraft = pendingDraftData?.let { messageData -> + conversationMessageDataDraftMapper.map(messageData = messageData) + }, + pendingScrollPosition = savedStateHandle[PENDING_SCROLL_POSITION_KEY], + pendingStartupAttachment = buildStartupAttachmentOrNull( + contentUri = startupAttachmentUri, + contentType = startupAttachmentType, + ), + resolvingRecipientDestination = null, + selectedGroupRecipientDestinations = savedStateHandle + .get>(SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY) + ?.toImmutableList() + ?: persistentListOf(), + ) + } + + private fun observeActiveSubscriptions() { + viewModelScope.launch { + subscriptionsRepository + .observeActiveSubscriptions() + .collect(::reconcileSimSelection) + } + } + + private fun reconcileSimSelection(subscriptions: ImmutableList) { + val persistedSelfParticipantId = savedStateHandle + .get(SIM_SELECTED_SELF_PARTICIPANT_ID_KEY) + + val resolvedSelection = resolveSimSelection( + subscriptions = subscriptions, + persistedSelfParticipantId = persistedSelfParticipantId, + ) + + savedStateHandle[SIM_SELECTED_SELF_PARTICIPANT_ID_KEY] = resolvedSelection + ?.selfParticipantId + + updateUiState( + _uiState.value.copy( + simSelectorState = ConversationSimSelectorUiState( + subscriptions = subscriptions, + selectedSubscription = resolvedSelection, + ), + ), + ) + } + + private fun resolveSimSelection( + subscriptions: ImmutableList, + persistedSelfParticipantId: String?, + ): Subscription? { + val persistedMatch = subscriptions.firstOrNull { subscription -> + subscription.selfParticipantId == persistedSelfParticipantId + } + + return when { + persistedMatch != null -> persistedMatch + else -> resolveDefaultSubscription(subscriptions) + } + } + + private fun resolveDefaultSubscription( + subscriptions: ImmutableList, + ): Subscription? { + val defaultSubId = subscriptionsRepository.getDefaultSmsSubscriptionId() + + val matchingBySubId = when { + defaultSubId == ParticipantData.DEFAULT_SELF_SUB_ID -> null + + else -> { + subscriptions.firstOrNull { subscription -> + subscription.subId == defaultSubId + } + } + } + + return matchingBySubId ?: subscriptions.firstOrNull() + } + + private fun selectedSelfParticipantId(): String? { + return _uiState.value.simSelectorState.selectedSubscription?.selfParticipantId + } + + private fun clearConversationResolutionState() { + updateUiState( + _uiState.value.copy( + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, + resolvingRecipientDestination = null, + ), + ) + } + + private fun editableGroupStateOrNull(): ConversationEntryUiState? { + return _uiState.value.takeIf { state -> + state.isCreatingGroup && !state.isResolvingConversation + } + } + + private fun updatedGroupRecipientDestinationsOrNull( + currentDestinations: List, + destination: String, + ): List? { + val trimmedDestination = destination.trim() + + return when { + trimmedDestination.isEmpty() -> null + trimmedDestination in currentDestinations -> currentDestinations - trimmedDestination + + canAcceptRecipientCount(count = currentDestinations.size + 1) -> { + currentDestinations + trimmedDestination + } + + else -> null + } + } + + private fun canAcceptRecipientCount(count: Int): Boolean { + if (isConversationRecipientLimitExceeded(count)) { + showMessage(messageResId = R.string.too_many_participants) + return false + } + + return true + } + + private fun cancelConversationResolution() { + val currentResolveConversationJob = resolveConversationJob + val currentUiState = _uiState.value + + resolveConversationJob = null + currentResolveConversationJob?.cancel() + + val shouldClearConversationResolutionState = currentUiState.isResolvingConversation || + currentUiState.isResolvingConversationIndicatorVisible || + currentUiState.resolvingRecipientDestination != null + + if (shouldClearConversationResolutionState) { + clearConversationResolutionState() + } + } + + private fun showConversationResolutionIndicator() { + val currentUiState = _uiState.value + + val shouldShowIndicator = currentUiState.isResolvingConversation && + !currentUiState.isResolvingConversationIndicatorVisible + + if (shouldShowIndicator) { + updateUiState( + currentUiState.copy( + isResolvingConversationIndicatorVisible = true, + ), + ) + } + } + + private fun startConversationResolution(resolvingRecipientDestination: String?) { + updateUiState( + _uiState.value.copy( + isResolvingConversation = true, + isResolvingConversationIndicatorVisible = false, + resolvingRecipientDestination = resolvingRecipientDestination, + ), + ) + } + + private fun resolveConversation( + destinations: List, + resolvingRecipientDestination: String?, + selfParticipantId: String?, + ) { + resolveConversationJob = viewModelScope.launch(mainDispatcher) { + startConversationResolution(resolvingRecipientDestination) + + val showIndicatorJob = launchDelayedResolutionIndicator() + + try { + handleResolveConversationIdResult( + result = resolveConversationId(destinations), + selfParticipantId = selfParticipantId, + ) + } finally { + showIndicatorJob.cancel() + resolveConversationJob = null + } + } + } + + private fun CoroutineScope.launchDelayedResolutionIndicator(): Job { + return launch(mainDispatcher) { + delay(timeMillis = RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS) + showConversationResolutionIndicator() + } + } + + private fun handleResolveConversationIdResult( + result: ResolveConversationIdResult, + selfParticipantId: String?, + ) { + when (result) { + is ResolveConversationIdResult.Resolved -> { + navigateToConversation( + conversationId = result.conversationId, + selfParticipantId = selfParticipantId, + ) + } + + ResolveConversationIdResult.EmptyDestinations, + ResolveConversationIdResult.NotResolved, + -> { + clearConversationResolutionState() + showMessage(messageResId = R.string.conversation_creation_failure) + } + } + } + + private fun updateUiState(uiState: ConversationEntryUiState) { + _uiState.value = uiState + + savedStateHandle[LAUNCH_GENERATION_KEY] = uiState.launchGeneration + savedStateHandle[CONVERSATION_ID_KEY] = uiState.conversationId + savedStateHandle[IS_CREATING_GROUP_KEY] = uiState.isCreatingGroup + savedStateHandle[PENDING_STARTUP_ATTACHMENT_TYPE_KEY] = uiState + .pendingStartupAttachment + ?.contentType + + savedStateHandle[PENDING_STARTUP_ATTACHMENT_URI_KEY] = uiState + .pendingStartupAttachment + ?.contentUri + savedStateHandle[SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY] = ArrayList( + uiState.selectedGroupRecipientDestinations, + ) + } + + private fun buildStartupAttachmentOrNull( + contentUri: String?, + contentType: String?, + ): ConversationEntryStartupAttachment? { + return when { + contentUri == null || contentType == null -> null + + else -> { + ConversationEntryStartupAttachment( + contentType = contentType, + contentUri = contentUri, + ) + } + } + } + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val IS_CREATING_GROUP_KEY = "is_creating_group" + private const val LAUNCH_GENERATION_KEY = "launch_generation" + private const val PENDING_DRAFT_DATA_KEY = "pending_draft_data" + private const val PENDING_SCROLL_POSITION_KEY = "pending_scroll_position" + private const val PENDING_STARTUP_ATTACHMENT_TYPE_KEY = "pending_startup_attachment_type" + private const val PENDING_STARTUP_ATTACHMENT_URI_KEY = "pending_startup_attachment_uri" + + // Tracks the last launch request handled by this ViewModel even when the + // same launch generation remains in uiState for downstream side effects + private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" + private const val SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY = + "selected_group_recipient_destinations" + private const val SIM_SELECTED_SELF_PARTICIPANT_ID_KEY = "sim_selected_self_participant_id" + } +} diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt new file mode 100644 index 00000000..a97e0556 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -0,0 +1,370 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, +) + +package com.android.messaging.ui.conversation.entry + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.fillMaxSize +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.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.R +import com.android.messaging.ui.conversation.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.newChatContactDestinationRowTestTag +import com.android.messaging.ui.conversation.newChatContactRowTestTag +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerModel +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerViewModel +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionStrings +import com.android.messaging.ui.conversation.recipientpicker.component.simselector.NewChatSimSelectorRow +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet + +@Composable +internal fun NewChatScreen( + modifier: Modifier = Modifier, + isCreatingGroup: Boolean = false, + isResolvingConversation: Boolean = false, + isResolvingConversationIndicatorVisible: Boolean = false, + onContactClick: (String) -> Unit = {}, + onContactLongClick: (String) -> Unit = {}, + onCreateGroupClick: () -> Unit = {}, + onCreateGroupConfirmed: () -> Unit = {}, + onCreateGroupRecipientClick: (String) -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onSimSelected: (String) -> Unit = {}, + pickerModel: RecipientPickerModel = hiltViewModel(), + resolvingRecipientDestination: String? = null, + selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), + simSelectorUiState: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), +) { + val uiState by pickerModel.uiState.collectAsStateWithLifecycle() + val screenContainerColor = MaterialTheme.colorScheme.surfaceVariant + + Scaffold( + modifier = modifier, + containerColor = screenContainerColor, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = screenContainerColor, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + title = { + Text(text = newChatTitle(isCreatingGroup = isCreatingGroup)) + }, + ) + }, + ) { contentPadding -> + NewChatRecipientSelectionContent( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + pickerUiState = uiState, + simSelectorUiState = simSelectorUiState, + isCreatingGroup = isCreatingGroup, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + resolvingRecipientDestination = resolvingRecipientDestination, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, + onLoadMore = pickerModel::onLoadMore, + onQueryChanged = pickerModel::onQueryChanged, + onContactClick = onContactClick, + onContactLongClick = onContactLongClick, + onCreateGroupClick = onCreateGroupClick, + onCreateGroupConfirmed = onCreateGroupConfirmed, + onCreateGroupRecipientClick = onCreateGroupRecipientClick, + onSimSelected = onSimSelected, + ) + } +} + +@Composable +private fun NewChatRecipientSelectionContent( + pickerUiState: RecipientPickerUiState, + simSelectorUiState: ConversationSimSelectorUiState, + isCreatingGroup: Boolean, + isResolvingConversation: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + resolvingRecipientDestination: String?, + selectedGroupRecipientDestinations: ImmutableList, + onLoadMore: () -> Unit, + onQueryChanged: (String) -> Unit, + onContactClick: (String) -> Unit, + onContactLongClick: (String) -> Unit, + onCreateGroupClick: () -> Unit, + onCreateGroupConfirmed: () -> Unit, + onCreateGroupRecipientClick: (String) -> Unit, + onSimSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + RecipientSelectionContent( + uiState = newChatRecipientSelectionContentUiState( + pickerUiState = pickerUiState, + isCreatingGroup = isCreatingGroup, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, + ), + strings = RecipientSelectionStrings( + queryPrefixText = stringResource(id = R.string.new_chat_recipient_prefix), + queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), + ), + rowDecorators = newChatRecipientSelectionRowDecorators( + isCreatingGroup = isCreatingGroup, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + resolvingRecipientDestination = resolvingRecipientDestination, + ), + onRecipientDestinationClick = { _, destination -> + when { + isCreatingGroup -> onCreateGroupRecipientClick(destination) + else -> onContactClick(destination) + } + }, + modifier = modifier, + autoFocusQuery = true, + onLoadMore = onLoadMore, + onPrimaryActionClick = onCreateGroupConfirmed, + onQueryChanged = onQueryChanged, + onRecipientDestinationLongClick = { _, destination -> + when { + isCreatingGroup -> onCreateGroupRecipientClick(destination) + else -> onContactLongClick(destination) + } + }, + simSelectorSlot = { + NewChatSimSelectorRow( + uiState = simSelectorUiState, + onSimSelected = onSimSelected, + ) + }, + topListContent = { + NewChatRecipientSelectionTopListContent( + isCreatingGroup = isCreatingGroup, + onCreateGroupClick = onCreateGroupClick, + ) + }, + ) +} + +private fun newChatRecipientSelectionRowDecorators( + isCreatingGroup: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + resolvingRecipientDestination: String?, +): RecipientSelectionRowDecorators { + return RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + newChatContactRowTestTag(contactId = item.id) + }, + destinationRowTestTag = { item, destination -> + newChatContactDestinationRowTestTag( + contactId = item.id, + destination = destination, + ) + }, + showRecipientTrailingIndicator = { _, destination -> + !isCreatingGroup && + isResolvingConversationIndicatorVisible && + resolvingRecipientDestination == destination + }, + trailingIndicatorTestTag = NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG, + ) +} + +@Composable +private fun newChatRecipientSelectionContentUiState( + pickerUiState: RecipientPickerUiState, + isCreatingGroup: Boolean, + isResolvingConversation: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + selectedGroupRecipientDestinations: ImmutableList, +): RecipientSelectionContentUiState { + val primaryAction = when { + isCreatingGroup && selectedGroupRecipientDestinations.isNotEmpty() -> { + RecipientSelectionPrimaryActionUiState( + text = stringResource(id = R.string.next), + isEnabled = !pickerUiState.isLoading && !isResolvingConversation, + isLoading = isResolvingConversationIndicatorVisible, + testTag = NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG, + ) + } + + else -> null + } + + return RecipientSelectionContentUiState( + picker = pickerUiState, + primaryAction = primaryAction, + selectedRecipientDestinations = when { + isCreatingGroup -> selectedGroupRecipientDestinations.toImmutableSet() + else -> persistentSetOf() + }, + isQueryEnabled = !isResolvingConversation, + ) +} + +@Composable +private fun NewChatRecipientSelectionTopListContent( + isCreatingGroup: Boolean, + onCreateGroupClick: () -> Unit, +) { + AnimatedVisibility( + visible = !isCreatingGroup, + enter = newGroupButtonEnterTransition(), + exit = newGroupButtonExitTransition(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + NewGroupButton( + modifier = Modifier.fillMaxWidth(), + onClick = onCreateGroupClick, + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + } + } +} + +@Composable +private fun newChatTitle( + isCreatingGroup: Boolean, +): String { + return when { + isCreatingGroup -> stringResource(id = R.string.conversation_new_group) + else -> stringResource(id = R.string.start_new_conversation) + } +} + +@Composable +private fun NewGroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + FilledTonalButton( + modifier = modifier, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(size = 18.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy( + alpha = 0.5f, + ), + disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.5f, + ), + ), + ) { + Icon( + imageVector = Icons.Rounded.Group, + contentDescription = null, + ) + Spacer(modifier = Modifier.size(size = 8.dp)) + Text(text = stringResource(id = R.string.conversation_new_group)) + } +} + +private fun newGroupButtonEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = newChatDefaultEffectsAnimationSpec(), + ) + slideInVertically( + animationSpec = newChatSpatialAnimationSpec(), + initialOffsetY = { fullHeight -> + -fullHeight / 4 + }, + ) +} + +private fun newGroupButtonExitTransition(): ExitTransition { + return fadeOut( + animationSpec = newChatFastEffectsAnimationSpec(), + ) + shrinkVertically( + animationSpec = newChatSpatialAnimationSpec(), + shrinkTowards = androidx.compose.ui.Alignment.Top, + ) +} + +private fun newChatDefaultEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 200, + easing = LinearOutSlowInEasing, + ) +} + +private fun newChatFastEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ) +} + +private fun newChatSpatialAnimationSpec(): FiniteAnimationSpec { + return spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) +} diff --git a/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt new file mode 100644 index 00000000..5d9e64b2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt @@ -0,0 +1,20 @@ +package com.android.messaging.ui.conversation.entry.model + +import com.android.messaging.ui.conversation.navigation.RecipientPickerMode + +internal sealed interface ConversationEntryEffect { + + data class NavigateToConversation( + val conversationId: String, + ) : ConversationEntryEffect + + data class NavigateToRecipientPicker( + val mode: RecipientPickerMode, + ) : ConversationEntryEffect + + data object NavigateBack : ConversationEntryEffect + + data class ShowMessage( + val messageResId: Int, + ) : ConversationEntryEffect +} diff --git a/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt new file mode 100644 index 00000000..bf7039a7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.entry.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.datamodel.data.MessageData + +@Immutable +internal data class ConversationEntryLaunchRequest( + val launchGeneration: Int, + val conversationId: String?, + val draftData: MessageData? = null, + val startupAttachmentUri: String? = null, + val startupAttachmentType: String? = null, + val messagePosition: Int? = null, + val isLaunchedFromBubble: Boolean = false, +) diff --git a/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt new file mode 100644 index 00000000..25989b76 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt @@ -0,0 +1,29 @@ +package com.android.messaging.ui.conversation.entry.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationEntryUiState( + val launchGeneration: Int? = null, + val conversationId: String? = null, + val isCreatingGroup: Boolean = false, + val isResolvingConversation: Boolean = false, + val isResolvingConversationIndicatorVisible: Boolean = false, + val pendingDraft: ConversationDraft? = null, + val pendingScrollPosition: Int? = null, + val pendingSelfParticipantId: String? = null, + val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, + val resolvingRecipientDestination: String? = null, + val selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), + val simSelectorState: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), +) + +@Immutable +internal data class ConversationEntryStartupAttachment( + val contentType: String, + val contentUri: String, +) diff --git a/src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt b/src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt new file mode 100644 index 00000000..501e29b2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt @@ -0,0 +1,107 @@ +package com.android.messaging.ui.conversation.focus.delegate + +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.di.core.DefaultDispatcher +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +internal interface ConversationFocusDelegate { + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) + + fun setScreenFocused( + focused: Boolean, + cancelNotification: Boolean = true, + ) +} + +internal class ConversationFocusDelegateImpl @Inject constructor( + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationFocusDelegate { + + private val focusStateFlow = MutableStateFlow(value = FocusRequest.Unfocused) + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + combine( + focusStateFlow, + conversationIdFlow, + ) { focusRequest, conversationId -> + when (focusRequest) { + is FocusRequest.Focused -> { + conversationId + ?.takeIf { it.isNotBlank() } + ?.let { id -> + FocusedConversation( + conversationId = id, + cancelNotification = focusRequest.cancelNotification, + ) + } + } + + FocusRequest.Unfocused -> null + } + } + .distinctUntilChanged() + .collect { focused -> + when { + focused == null -> { + DataModel.get().setFocusedConversation(null) + } + + else -> { + DataModel.get().setFocusedConversation(focused.conversationId) + + BugleNotifications.markMessagesAsRead( + focused.conversationId, + focused.cancelNotification, + ) + } + } + } + } + } + + override fun setScreenFocused( + focused: Boolean, + cancelNotification: Boolean, + ) { + focusStateFlow.value = when { + focused -> FocusRequest.Focused(cancelNotification = cancelNotification) + else -> FocusRequest.Unfocused + } + } + + private sealed interface FocusRequest { + data object Unfocused : FocusRequest + data class Focused( + val cancelNotification: Boolean, + ) : FocusRequest + } + + private data class FocusedConversation( + val conversationId: String, + val cancelNotification: Boolean, + ) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt new file mode 100644 index 00000000..7e008d90 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt @@ -0,0 +1,385 @@ +package com.android.messaging.ui.conversation.mediapicker + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.photopicker.EmbeddedPhotoPickerFeatureInfo +import androidx.annotation.RequiresExtension +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.core.net.toUri +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.photopicker.compose.EmbeddedPhotoPicker +import androidx.photopicker.compose.EmbeddedPhotoPickerState +import androidx.photopicker.compose.ExperimentalPhotoPickerComposeApi +import androidx.photopicker.compose.rememberEmbeddedPhotoPickerState +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.camera.BindConversationCameraLifecycleEffect +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.camera.rememberConversationCameraController +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +private const val TAG = "ConversationMediaPicker" + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +internal fun ConversationMediaPicker( + modifier: Modifier = Modifier, + attachments: ImmutableList, + conversationTitle: String?, + isSendActionEnabled: Boolean, + state: ConversationMediaPickerState, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val cameraController = rememberConversationCameraController() + val visualAttachments = rememberVisualMediaAttachments(attachments = attachments) + val isReviewVisible = state.isReviewRequested && visualAttachments.isNotEmpty() + val lifecycleOwner = LocalLifecycleOwner.current + + BindConversationCameraLifecycleEffect( + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + isCameraPreviewVisible = !isReviewVisible, + lifecycleOwner = lifecycleOwner, + ) + + ConversationMediaPickerContent( + modifier = modifier, + cameraController = cameraController, + visualAttachments = visualAttachments, + isReviewVisible = isReviewVisible, + state = state, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onAttachmentStartRequest = onAttachmentStartRequest, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) +} + +@Composable +private fun rememberVisualMediaAttachments( + attachments: ImmutableList, +): ImmutableList { + return remember(attachments) { + attachments + .asSequence() + .filterIsInstance() + .toImmutableList() + } +} + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@Composable +private fun rememberConversationEmbeddedPhotoPickerFeatureInfo(): EmbeddedPhotoPickerFeatureInfo { + val themeNightMode = when { + isSystemInDarkTheme() -> Configuration.UI_MODE_NIGHT_YES + else -> Configuration.UI_MODE_NIGHT_NO + } + + return remember(themeNightMode) { + EmbeddedPhotoPickerFeatureInfo.Builder() + .setMaxSelectionLimit(MediaStore.getPickImagesMaxLimit()) + .setMimeTypes( + listOf( + ContentType.IMAGE_UNSPECIFIED, + ContentType.VIDEO_UNSPECIFIED, + ), + ) + .setOrderedSelection(true) + .setThemeNightMode(themeNightMode) + .build() + } +} + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun rememberConversationEmbeddedPhotoPickerState( + sheetState: SheetState, + state: ConversationMediaPickerState, + coroutineScope: CoroutineScope, + onAttachmentStartRequest: () -> Boolean, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, +): EmbeddedPhotoPickerState { + return rememberEmbeddedPhotoPickerState( + initialExpandedValue = false, + onSessionError = { + LogUtil.w(TAG, "Embedded photo picker session failed", it) + }, + onUriPermissionGranted = { uris -> + val contentUris = uris.map(Uri::toString) + + if (contentUris.isNotEmpty() && onAttachmentStartRequest()) { + onPhotoPickerMediaSelected(contentUris) + contentUris.lastOrNull()?.let(state::showReview) + } + }, + onUriPermissionRevoked = { uris -> + onPhotoPickerMediaDeselected(uris.map(Uri::toString)) + }, + onSelectionComplete = { + coroutineScope.launch(Dispatchers.Main.immediate) { + sheetState.partialExpand() + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun SyncEmbeddedPhotoPickerExpansionEffect( + sheetState: SheetState, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, +) { + LaunchedEffect(sheetState, embeddedPhotoPickerState) { + snapshotFlow { + sheetState.currentValue == SheetValue.Expanded || + sheetState.targetValue == SheetValue.Expanded + } + .distinctUntilChanged() + .collect { isExpanded -> + embeddedPhotoPickerState.setCurrentExpanded(expanded = isExpanded) + } + } +} + +@OptIn(ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun rememberPickerBackedAttachmentRemoveCallback( + coroutineScope: CoroutineScope, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onAttachmentRemove: (String) -> Unit, +): (String) -> Unit { + return remember( + coroutineScope, + embeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentRemove, + ) { + { contentUri -> + val sourceContentUri = photoPickerSourceContentUriByAttachmentContentUri[contentUri] + ?: contentUri + coroutineScope.launch(Dispatchers.Main.immediate) { + try { + embeddedPhotoPickerState.deselectUri(uri = sourceContentUri.toUri()) + } catch (e: IllegalStateException) { + LogUtil.w(TAG, "Unable to deselect photo picker URI $sourceContentUri", e) + } + } + onAttachmentRemove(contentUri) + } + } +} + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationMediaPickerContent( + modifier: Modifier, + cameraController: ConversationCameraController, + visualAttachments: ImmutableList, + isReviewVisible: Boolean, + state: ConversationMediaPickerState, + conversationTitle: String?, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true, + ) + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = sheetState, + ) + val embeddedPhotoPickerState = rememberConversationEmbeddedPhotoPickerState( + sheetState = sheetState, + state = state, + coroutineScope = coroutineScope, + onAttachmentStartRequest = onAttachmentStartRequest, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + ) + SyncEmbeddedPhotoPickerExpansionEffect( + sheetState = sheetState, + embeddedPhotoPickerState = embeddedPhotoPickerState, + ) + + ConversationMediaPickerScaffoldContent( + modifier = modifier, + cameraController = cameraController, + scaffoldState = scaffoldState, + embeddedPhotoPickerState = embeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo = rememberConversationEmbeddedPhotoPickerFeatureInfo(), + visualAttachments = visualAttachments, + isReviewVisible = isReviewVisible, + state = state, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = rememberPickerBackedAttachmentRemoveCallback( + coroutineScope = coroutineScope, + embeddedPhotoPickerState = embeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentRemove = onAttachmentRemove, + ), + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) +} + +@Suppress("ParamsComparedByRef") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationMediaPickerScaffoldContent( + modifier: Modifier, + cameraController: ConversationCameraController, + scaffoldState: BottomSheetScaffoldState, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo: EmbeddedPhotoPickerFeatureInfo, + visualAttachments: ImmutableList, + isReviewVisible: Boolean, + state: ConversationMediaPickerState, + conversationTitle: String?, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + ConversationMediaPickerScaffold( + modifier = modifier, + cameraController = cameraController, + scaffoldState = scaffoldState, + photoPickerSheetContent = { + ConversationEmbeddedPhotoPickerContent( + state = embeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo = embeddedPhotoPickerFeatureInfo, + ) + }, + visualAttachments = visualAttachments, + conversationTitle = conversationTitle, + captureMode = state.captureMode, + reviewContentUri = state.reviewContentUri, + reviewRequestSequence = state.reviewRequestSequence, + isReviewVisible = isReviewVisible, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + onShowReview = state::showReview, + onClearReview = state::clearReview, + onCaptureModeChange = state::updateCaptureMode, + ) +} + +@SuppressLint("NewApi") +@OptIn(ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationEmbeddedPhotoPickerContent( + state: EmbeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo: EmbeddedPhotoPickerFeatureInfo, +) { + EmbeddedPhotoPicker( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize(), + state = state, + embeddedPhotoPickerFeatureInfo = embeddedPhotoPickerFeatureInfo, + ) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt new file mode 100644 index 00000000..87dbf254 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -0,0 +1,80 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.camera.handlePhotoCaptureRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleSwitchCameraRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleToggleFlashRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleVideoCaptureRequest +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureContent + +@Composable +internal fun ConversationMediaCaptureRoute( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + audioPermissionGranted: Boolean, + captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, + onClose: () -> Unit, + onRequestAudioPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, + onShowReview: (String) -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + val hasFlashUnit = cameraController.hasFlashUnit.collectAsStateWithLifecycle() + val isPhotoCaptureInProgress = cameraController.isPhotoCaptureInProgress + .collectAsStateWithLifecycle() + + val isRecording = cameraController.isRecording.collectAsStateWithLifecycle() + val photoFlashMode = cameraController.photoFlashMode.collectAsStateWithLifecycle() + val recordingDurationMillis = cameraController.recordingDurationMillis + .collectAsStateWithLifecycle() + + ConversationMediaCaptureContent( + modifier = modifier, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + hasFlashUnit = hasFlashUnit.value, + isPhotoCaptureInProgress = isPhotoCaptureInProgress.value, + isRecording = isRecording.value, + photoFlashMode = photoFlashMode.value, + onCloseClick = { + if (isRecording.value) { + cameraController.cancelVideoRecording() + } + onClose() + }, + onRequestAudioPermission = { + if (onAttachmentStartRequest()) { + onRequestAudioPermission() + } + }, + onPhotoCaptureClick = { + handlePhotoCaptureRequest( + cameraController = cameraController, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + ) + }, + onPhotoModeClick = { onCaptureModeChange(ConversationCaptureMode.Photo) }, + onSwitchCameraClick = { handleSwitchCameraRequest(cameraController) }, + onToggleFlashClick = { handleToggleFlashRequest(cameraController) }, + onVideoCaptureClick = { + handleVideoCaptureRequest( + cameraController = cameraController, + isRecording = isRecording.value, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + ) + }, + onVideoModeClick = { onCaptureModeChange(ConversationCaptureMode.Video) }, + recordingDurationMillis = recordingDurationMillis.value, + ) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt new file mode 100644 index 00000000..d0e59bd2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -0,0 +1,78 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCameraPreviewSurface + +@Composable +internal fun ConversationMediaPickerCaptureScene( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + contentPadding: PaddingValues, + captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize(), + ) { + ConversationMediaCameraPreviewRoute( + modifier = Modifier + .fillMaxSize(), + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + contentPadding = contentPadding, + onRequestCameraPermission = onRequestCameraPermission, + ) + + ConversationMediaCaptureRoute( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + cameraController = cameraController, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onShowReview = onShowReview, + onCapturedMediaReady = onCapturedMediaReady, + onCaptureModeChange = onCaptureModeChange, + ) + } +} + +@Composable +private fun ConversationMediaCameraPreviewRoute( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + cameraPermissionGranted: Boolean, + contentPadding: PaddingValues, + onRequestCameraPermission: () -> Unit, +) { + val surfaceRequest = cameraController.surfaceRequest.collectAsStateWithLifecycle() + + ConversationMediaCameraPreviewSurface( + modifier = modifier, + cameraPermissionGranted = cameraPermissionGranted, + contentPadding = contentPadding, + surfaceRequest = surfaceRequest.value, + onRequestCameraPermission = onRequestCameraPermission, + ) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt new file mode 100644 index 00000000..f9e100c7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt @@ -0,0 +1,109 @@ +package com.android.messaging.ui.conversation.mediapicker + +import android.Manifest +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresExtension +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ConversationMediaPickerOverlay( + modifier: Modifier = Modifier, + state: ConversationMediaPickerState, + attachments: ImmutableList, + conversationTitle: String?, + isSendActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val focusManager = LocalFocusManager.current + val isImeVisible = WindowInsets.isImeVisible + val keyboardController = LocalSoftwareKeyboardController.current + + val permissionState = rememberConversationMediaPickerPermissionState() + + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.audioPermissionGranted = isGranted + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.cameraPermissionGranted = isGranted + } + + HandleConversationMediaPickerVisibilityEffect( + state = state, + isImeVisible = isImeVisible, + focusManager = focusManager, + keyboardController = keyboardController, + messageFieldFocusRequester = messageFieldFocusRequester, + ) + + RefreshConversationMediaPickerPermissionsEffect( + permissionState = permissionState, + ) + + BackHandler(enabled = state.isOpen) { + state.close() + } + + if (state.isOpen) { + ConversationMediaPicker( + modifier = modifier + .fillMaxSize() + .testTag(CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG), + attachments = attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + state = state, + cameraPermissionGranted = permissionState.cameraPermissionGranted, + audioPermissionGranted = permissionState.audioPermissionGranted, + onClose = state::close, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onAttachmentStartRequest = onAttachmentStartRequest, + onRequestAudioPermission = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }, + onRequestCameraPermission = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt new file mode 100644 index 00000000..a8e5b47c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt @@ -0,0 +1,61 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.android.messaging.ui.conversation.mediapicker.model.ConversationMediaPickerPermissionState + +@Composable +internal fun rememberConversationMediaPickerPermissionState(): + ConversationMediaPickerPermissionState { + val context = LocalContext.current + + return remember(context) { + ConversationMediaPickerPermissionState( + context = context, + ) + } +} + +@Composable +internal fun RefreshConversationMediaPickerPermissionsEffect( + permissionState: ConversationMediaPickerPermissionState, +) { + val context = LocalContext.current + + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + permissionState.refresh(context = context) + } +} + +@Composable +internal fun HandleConversationMediaPickerVisibilityEffect( + state: ConversationMediaPickerState, + isImeVisible: Boolean, + focusManager: FocusManager, + keyboardController: SoftwareKeyboardController?, + messageFieldFocusRequester: FocusRequester, +) { + LaunchedEffect(state.isOpen) { + if (state.isOpen) { + state.shouldRestoreKeyboard = isImeVisible + focusManager.clearFocus(force = true) + keyboardController?.hide() + return@LaunchedEffect + } + + if (!state.shouldRestoreKeyboard) { + return@LaunchedEffect + } + + messageFieldFocusRequester.requestFocus() + keyboardController?.show() + state.shouldRestoreKeyboard = false + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt new file mode 100644 index 00000000..f5310eea --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt @@ -0,0 +1,209 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.component.review.ConversationMediaReviewScene +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +private enum class ConversationMediaPickerOverlayMode { + Capture, + Review, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationMediaPickerScaffold( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + scaffoldState: BottomSheetScaffoldState, + photoPickerSheetContent: @Composable () -> Unit, + visualAttachments: ImmutableList, + conversationTitle: String?, + captureMode: ConversationCaptureMode, + reviewContentUri: String?, + reviewRequestSequence: Int, + isReviewVisible: Boolean, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, + onShowReview: (String) -> Unit, + onClearReview: () -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + ConversationMediaPickerSheetScaffold( + modifier = modifier, + scaffoldState = scaffoldState, + photoPickerSheetContent = photoPickerSheetContent, + ) { innerPadding -> + ConversationMediaPickerOverlayHost( + modifier = Modifier.fillMaxSize(), + cameraController = cameraController, + contentPadding = innerPadding, + visualAttachments = visualAttachments, + conversationTitle = conversationTitle, + captureMode = captureMode, + reviewContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + isReviewVisible = isReviewVisible, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + onShowReview = onShowReview, + onClearReview = onClearReview, + onCaptureModeChange = onCaptureModeChange, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationMediaPickerOverlayHost( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + contentPadding: PaddingValues, + visualAttachments: ImmutableList, + conversationTitle: String?, + captureMode: ConversationCaptureMode, + reviewContentUri: String?, + reviewRequestSequence: Int, + isReviewVisible: Boolean, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, + onShowReview: (String) -> Unit, + onClearReview: () -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + AnimatedContent( + modifier = modifier + .fillMaxSize(), + targetState = resolveOverlayMode(isReviewVisible = isReviewVisible), + transitionSpec = { + pickerOverlayTransition() + }, + label = "pickerOverlayMode", + ) { currentOverlayMode -> + when (currentOverlayMode) { + ConversationMediaPickerOverlayMode.Capture -> { + ConversationMediaPickerCaptureScene( + cameraController = cameraController, + contentPadding = contentPadding, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + onCaptureModeChange = onCaptureModeChange, + ) + } + + ConversationMediaPickerOverlayMode.Review -> { + ConversationMediaReviewScene( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + attachments = visualAttachments, + conversationTitle = conversationTitle, + initiallyReviewedContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + isSendActionEnabled = isSendActionEnabled, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onClearReview, + onClearReview = onClearReview, + onCloseClick = onClose, + onSendClick = { + onSendClick() + onClose() + }, + ) + } + } + } +} + +private fun resolveOverlayMode(isReviewVisible: Boolean): ConversationMediaPickerOverlayMode { + return when { + isReviewVisible -> ConversationMediaPickerOverlayMode.Review + else -> ConversationMediaPickerOverlayMode.Capture + } +} + +private fun pickerOverlayTransition(): ContentTransform { + val enterTransition = fadeIn( + animationSpec = tween( + durationMillis = 180, + delayMillis = 40, + ), + ) + scaleIn( + initialScale = 0.95f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + + val exitTransition = fadeOut( + animationSpec = tween(durationMillis = 100), + ) + scaleOut( + targetScale = 0.985f, + animationSpec = tween(durationMillis = 100), + ) + + return enterTransition.togetherWith(exitTransition) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt new file mode 100644 index 00000000..c1beedb8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt @@ -0,0 +1,92 @@ +package com.android.messaging.ui.conversation.mediapicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val CAMERA_PREVIEW_HEIGHT_FRACTION = 2f / 3f +private val PICKER_GALLERY_SHEET_HEIGHT_REDUCTION = 16.dp +private val PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS = 28.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationMediaPickerSheetScaffold( + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState, + photoPickerSheetContent: @Composable () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize(), + ) { + BottomSheetScaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + sheetContainerColor = Color.Transparent, + sheetContentColor = MaterialTheme.colorScheme.onSurface, + sheetShape = RectangleShape, + containerColor = Color.Transparent, + sheetDragHandle = { + ConversationPhotoPickerSheetHeader() + }, + sheetPeekHeight = calculatePhotoPickerSheetPeekHeight(maxHeight = maxHeight), + sheetContent = { + photoPickerSheetContent() + }, + ) { innerPadding -> + content(innerPadding) + } + } +} + +private fun calculatePhotoPickerSheetPeekHeight(maxHeight: Dp): Dp { + val previewHeight = maxHeight * CAMERA_PREVIEW_HEIGHT_FRACTION + val defaultSheetPeekHeight = maxHeight - previewHeight + + return when { + defaultSheetPeekHeight > PICKER_GALLERY_SHEET_HEIGHT_REDUCTION -> { + defaultSheetPeekHeight - PICKER_GALLERY_SHEET_HEIGHT_REDUCTION + } + + else -> defaultSheetPeekHeight + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationPhotoPickerSheetHeader( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape( + topStart = PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS, + topEnd = PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS, + ), + ), + contentAlignment = Alignment.Center, + ) { + BottomSheetDefaults.DragHandle() + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt new file mode 100644 index 00000000..b558fe1d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.conversation.mediapicker + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.parcelize.Parcelize + +internal enum class ConversationCaptureMode { + Photo, + Video, +} + +@Parcelize +internal data class ConversationMediaPickerSavedState( + val captureModeName: String, + val isOpen: Boolean, + val isReviewRequested: Boolean, + val reviewContentUri: String?, + val reviewRequestSequence: Int, + val selectedMediaIds: List, + val shouldRestoreKeyboard: Boolean, +) : Parcelable + +@Stable +internal class ConversationMediaPickerState( + isOpen: Boolean, + captureMode: ConversationCaptureMode, + isReviewRequested: Boolean, + reviewContentUri: String?, + reviewRequestSequence: Int, + selectedMediaIds: Set, + shouldRestoreKeyboard: Boolean, +) { + var captureMode by mutableStateOf(captureMode) + var isOpen by mutableStateOf(isOpen) + var isReviewRequested by mutableStateOf(isReviewRequested) + var reviewContentUri by mutableStateOf(reviewContentUri) + var reviewRequestSequence by mutableIntStateOf(reviewRequestSequence) + var shouldRestoreKeyboard by mutableStateOf(shouldRestoreKeyboard) + + private var selectedMediaIds by mutableStateOf(selectedMediaIds) + + fun clearSelection() { + selectedMediaIds = emptySet() + } + + fun isSelected(mediaId: String): Boolean { + return selectedMediaIds.contains(mediaId) + } + + fun open() { + isReviewRequested = true + isOpen = true + } + + fun showReview(contentUri: String) { + isReviewRequested = true + reviewContentUri = contentUri + reviewRequestSequence += 1 + } + + fun clearReview() { + isReviewRequested = false + reviewContentUri = null + } + + fun updateCaptureMode(captureMode: ConversationCaptureMode) { + this.captureMode = captureMode + } + + fun close() { + clearSelection() + clearReview() + isOpen = false + } + + private fun toSavedState(): ConversationMediaPickerSavedState { + return ConversationMediaPickerSavedState( + captureModeName = captureMode.name, + isOpen = isOpen, + isReviewRequested = isReviewRequested, + reviewContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + selectedMediaIds = selectedMediaIds.toList(), + shouldRestoreKeyboard = shouldRestoreKeyboard, + ) + } + + companion object { + val Saver: Saver = Saver( + save = { it.toSavedState() }, + restore = { restoredState -> + ConversationMediaPickerState( + isOpen = restoredState.isOpen, + captureMode = restoredState.captureModeName.toConversationCaptureMode(), + isReviewRequested = restoredState.isReviewRequested, + reviewContentUri = restoredState.reviewContentUri, + reviewRequestSequence = restoredState.reviewRequestSequence, + selectedMediaIds = restoredState.selectedMediaIds.toSet(), + shouldRestoreKeyboard = restoredState.shouldRestoreKeyboard, + ) + }, + ) + } +} + +private fun String.toConversationCaptureMode(): ConversationCaptureMode { + return ConversationCaptureMode + .entries + .firstOrNull { it.name == this } + ?: ConversationCaptureMode.Photo +} + +@Composable +internal fun rememberConversationMediaPickerState(): ConversationMediaPickerState { + return rememberSaveable(saver = ConversationMediaPickerState.Saver) { + ConversationMediaPickerState( + isOpen = false, + captureMode = ConversationCaptureMode.Photo, + isReviewRequested = false, + reviewContentUri = null, + reviewRequestSequence = 0, + selectedMediaIds = emptySet(), + shouldRestoreKeyboard = false, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt new file mode 100644 index 00000000..b333e8ee --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt @@ -0,0 +1,781 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import android.Manifest +import android.content.Context +import android.net.Uri +import androidx.annotation.RequiresPermission +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.PendingRecording +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.util.ContentType +import com.google.common.util.concurrent.ListenableFuture +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal interface ConversationCameraController { + val hasFlashUnit: StateFlow + val isPhotoCaptureInProgress: StateFlow + val isRecording: StateFlow + val photoFlashMode: StateFlow + val recordingDurationMillis: StateFlow + val surfaceRequest: StateFlow + + fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + onError: (Throwable) -> Unit, + ) + + fun capturePhoto( + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) + + fun startVideoRecording( + withAudio: Boolean, + onCaptured: (ConversationCapturedMedia) -> Unit, + onDiscarded: () -> Unit, + onError: (Throwable) -> Unit, + ) + + fun stopVideoRecording() + fun cancelVideoRecording() + + fun switchCamera(onError: (Throwable) -> Unit) + + fun cyclePhotoFlashMode(onError: (Throwable) -> Unit) + + fun unbind() +} + +private class ConversationCameraControllerImpl( + context: Context, +) : ConversationCameraController { + private val applicationContext = context.applicationContext + private val mainExecutor = ContextCompat.getMainExecutor(applicationContext) + + private val _hasFlashUnit = MutableStateFlow(false) + private val _isPhotoCaptureInProgress = MutableStateFlow(false) + private val _isRecording = MutableStateFlow(false) + private val _photoFlashMode = MutableStateFlow(ConversationPhotoFlashMode.Off) + private val _recordingDurationMillis = MutableStateFlow(0L) + private val _surfaceRequest = MutableStateFlow(null) + + override val hasFlashUnit = _hasFlashUnit.asStateFlow() + override val isPhotoCaptureInProgress = _isPhotoCaptureInProgress.asStateFlow() + override val isRecording = _isRecording.asStateFlow() + override val photoFlashMode = _photoFlashMode.asStateFlow() + override val recordingDurationMillis = _recordingDurationMillis.asStateFlow() + override val surfaceRequest = _surfaceRequest.asStateFlow() + + private var activeRecordingSession: ActiveRecordingSession? = null + private var bindGeneration = 0L + private var boundCameraSession: BoundCameraSession? = null + private var bindRequestLifecycleOwner: LifecycleOwner? = null + private var preferredLensFacing = CameraSelector.LENS_FACING_BACK + private var preferredPhotoFlashMode = ConversationPhotoFlashMode.Off + + override fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + onError: (Throwable) -> Unit, + ) { + bindRequestLifecycleOwner = lifecycleOwner + val requestedBindGeneration = ++bindGeneration + + requestCameraProvider( + lifecycleOwner = lifecycleOwner, + requestedBindGeneration = requestedBindGeneration, + onError = onError, + ) + } + + override fun capturePhoto( + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) { + val currentImageCapture = getReadyImageCaptureOrReportError(onError = onError) ?: return + val photoOutput = createPhotoOutputOrReportError(onError = onError) ?: return + + capturePhotoWithOutput( + imageCapture = currentImageCapture, + photoOutput = photoOutput, + onCaptured = onCaptured, + onError = onError, + ) + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun startVideoRecording( + withAudio: Boolean, + onCaptured: (ConversationCapturedMedia) -> Unit, + onDiscarded: () -> Unit, + onError: (Throwable) -> Unit, + ) { + val currentVideoCapture = getReadyVideoCaptureOrReportError(onError = onError) ?: return + val videoOutput = createVideoOutputOrReportError(onError = onError) ?: return + val preparedRecording = prepareVideoRecording( + videoCapture = currentVideoCapture, + videoOutput = videoOutput, + withAudio = withAudio, + ) + val callbacks = VideoRecordingCallbacks( + onCaptured = onCaptured, + onDiscarded = onDiscarded, + onError = onError, + ) + + startPreparedRecording( + preparedRecording = preparedRecording, + videoOutput = videoOutput, + callbacks = callbacks, + ) + } + + override fun stopVideoRecording() { + updateRecordingDiscardOnFinalize(discardOnFinalize = false)?.stop() + } + + override fun cancelVideoRecording() { + updateRecordingDiscardOnFinalize(discardOnFinalize = true)?.stop() + } + + override fun switchCamera(onError: (Throwable) -> Unit) { + val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) + ?: return + + val targetLensFacing = resolveSwitchTargetLensFacing( + currentLensFacing = currentBoundCameraSession.lensFacing, + ) + + runCatching { + requireAvailableLensFacing( + processCameraProvider = currentBoundCameraSession.cameraProvider, + lensFacing = targetLensFacing, + ) + rebindForLensFacing( + boundCameraSession = currentBoundCameraSession, + lensFacing = targetLensFacing, + ) + }.onFailure(onError) + } + + override fun cyclePhotoFlashMode(onError: (Throwable) -> Unit) { + val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) + ?: return + + if (!_hasFlashUnit.value) { + onError(FlashUnavailableException()) + return + } + + val nextPhotoFlashMode = _photoFlashMode.value.next() + + runCatching { + updatePhotoFlashMode( + imageCapture = currentBoundCameraSession.imageCapture, + photoFlashMode = nextPhotoFlashMode, + ) + }.onFailure(onError) + } + + private fun updatePhotoFlashMode( + imageCapture: ImageCapture, + photoFlashMode: ConversationPhotoFlashMode, + ) { + imageCapture.flashMode = photoFlashMode.imageCaptureFlashMode + preferredPhotoFlashMode = photoFlashMode + _photoFlashMode.value = photoFlashMode + } + + private fun resetPhotoFlashAvailabilityState() { + _hasFlashUnit.value = false + } + + private fun syncBoundImageCaptureFlashMode(imageCapture: ImageCapture) { + updatePhotoFlashMode( + imageCapture = imageCapture, + photoFlashMode = preferredPhotoFlashMode, + ) + } + + override fun unbind() { + invalidateCurrentBinding() + stopRecordingForUnbind() + clearBoundCameraReferences() + resetUiState() + } + + private fun requestCameraProvider( + lifecycleOwner: LifecycleOwner, + requestedBindGeneration: Long, + onError: (Throwable) -> Unit, + ) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(applicationContext) + + cameraProviderFuture.addListener( + { + handleCameraProviderReady( + cameraProviderFuture = cameraProviderFuture, + lifecycleOwner = lifecycleOwner, + requestedBindGeneration = requestedBindGeneration, + onError = onError, + ) + }, + mainExecutor, + ) + } + + @Suppress("TooGenericExceptionCaught") + private fun handleCameraProviderReady( + cameraProviderFuture: ListenableFuture, + lifecycleOwner: LifecycleOwner, + requestedBindGeneration: Long, + onError: (Throwable) -> Unit, + ) { + try { + if (!isCurrentBindGeneration(bindGeneration = requestedBindGeneration)) { + return + } + + val processCameraProvider = cameraProviderFuture.get() + if (!isCurrentBindGeneration(bindGeneration = requestedBindGeneration)) { + return + } + + rebindUseCases( + lifecycleOwner = lifecycleOwner, + processCameraProvider = processCameraProvider, + ) + } catch (exception: Exception) { + onError(exception) + } + } + + private fun rebindUseCases( + lifecycleOwner: LifecycleOwner, + processCameraProvider: ProcessCameraProvider, + ) { + processCameraProvider.unbindAll() + _surfaceRequest.value = null + + val selectedLensFacing = resolveBindLensFacing( + processCameraProvider = processCameraProvider, + ) + + val selectedCameraSelector = buildCameraSelector(lensFacing = selectedLensFacing) + val boundUseCases = createBoundUseCases() + val camera = processCameraProvider.bindToLifecycle( + lifecycleOwner, + selectedCameraSelector, + boundUseCases.preview, + boundUseCases.imageCapture, + boundUseCases.videoCapture, + ) + + preferredLensFacing = selectedLensFacing + val newBoundCameraSession = BoundCameraSession( + boundCamera = camera, + cameraProvider = processCameraProvider, + imageCapture = boundUseCases.imageCapture, + lifecycleOwner = lifecycleOwner, + lensFacing = selectedLensFacing, + videoCapture = boundUseCases.videoCapture, + ) + boundCameraSession = newBoundCameraSession + publishBoundCameraState(boundCameraSession = newBoundCameraSession) + } + + private fun createBoundUseCases(): BoundUseCases { + return BoundUseCases( + imageCapture = createImageCaptureUseCase(), + preview = createPreviewUseCase(), + videoCapture = createVideoCaptureUseCase(), + ) + } + + private fun createPreviewUseCase(): Preview { + return Preview.Builder() + .build() + .also { previewUseCase -> + previewUseCase.setSurfaceProvider { surfaceRequest -> + _surfaceRequest.value = surfaceRequest + } + } + } + + private fun createImageCaptureUseCase(): ImageCapture { + return ImageCapture.Builder() + .setFlashMode(preferredPhotoFlashMode.imageCaptureFlashMode) + .build() + } + + private fun createVideoCaptureUseCase(): VideoCapture { + val recorder = Recorder.Builder().build() + + return VideoCapture.withOutput(recorder) + } + + private fun publishBoundCameraState(boundCameraSession: BoundCameraSession) { + _hasFlashUnit.value = boundCameraSession.boundCamera.cameraInfo.hasFlashUnit() + syncBoundImageCaptureFlashMode( + imageCapture = boundCameraSession.imageCapture, + ) + } + + private fun getReadyImageCaptureOrReportError( + onError: (Throwable) -> Unit, + ): ImageCapture? { + if (_isPhotoCaptureInProgress.value) { + onError(PhotoCaptureAlreadyInProgressException()) + return null + } + + return getBoundCameraSessionOrReportError(onError = onError) + ?.imageCapture + } + + private fun createPhotoOutputOrReportError( + onError: (Throwable) -> Unit, + ): ScratchOutput? { + val photoOutput = createScratchOutputOrNull(contentType = ContentType.IMAGE_JPEG) + if (photoOutput == null) { + onError( + ScratchFileCreationFailedException( + mediaLabel = "photo", + ), + ) + } + + return photoOutput + } + + private fun capturePhotoWithOutput( + imageCapture: ImageCapture, + photoOutput: ScratchOutput, + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) { + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoOutput.file).build() + _isPhotoCaptureInProgress.value = true + + runCatching { + imageCapture.takePicture( + outputOptions, + mainExecutor, + object : ImageCapture.OnImageSavedCallback { + override fun onError(exception: ImageCaptureException) { + handlePhotoCaptureFailure( + photoOutput = photoOutput, + exception = exception, + onError = onError, + ) + } + + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + handlePhotoCaptured( + photoOutput = photoOutput, + onCaptured = onCaptured, + ) + } + }, + ) + }.onFailure { throwable -> + _isPhotoCaptureInProgress.value = false + deleteScratchOutput(scratchOutput = photoOutput) + onError( + PhotoCaptureStartFailedException( + cause = throwable, + ), + ) + } + } + + private fun handlePhotoCaptureFailure( + photoOutput: ScratchOutput, + exception: ImageCaptureException, + onError: (Throwable) -> Unit, + ) { + _isPhotoCaptureInProgress.value = false + deleteScratchOutput(scratchOutput = photoOutput) + onError( + PhotoCaptureFailedException( + cause = exception, + ), + ) + } + + private fun handlePhotoCaptured( + photoOutput: ScratchOutput, + onCaptured: (ConversationCapturedMedia) -> Unit, + ) { + _isPhotoCaptureInProgress.value = false + onCaptured( + ConversationCapturedMedia( + contentUri = photoOutput.uri.toString(), + contentType = ContentType.IMAGE_JPEG, + ), + ) + } + + private fun getReadyVideoCaptureOrReportError( + onError: (Throwable) -> Unit, + ): VideoCapture? { + if (activeRecordingSession != null) { + onError(RecordingAlreadyInProgressException()) + return null + } + + return getBoundCameraSessionOrReportError(onError = onError)?.videoCapture + } + + private fun createVideoOutputOrReportError( + onError: (Throwable) -> Unit, + ): ScratchOutput? { + val videoOutput = createScratchOutputOrNull(contentType = ContentType.VIDEO_MP4) + if (videoOutput == null) { + onError( + ScratchFileCreationFailedException( + mediaLabel = "video", + ), + ) + } + + return videoOutput + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun prepareVideoRecording( + videoCapture: VideoCapture, + videoOutput: ScratchOutput, + withAudio: Boolean, + ): PendingRecording { + val outputOptions = FileOutputOptions.Builder(videoOutput.file).build() + var preparedRecording = videoCapture.output.prepareRecording( + applicationContext, + outputOptions, + ) + + if (withAudio) { + preparedRecording = preparedRecording.withAudioEnabled() + } + + return preparedRecording + } + + private fun startPreparedRecording( + preparedRecording: PendingRecording, + videoOutput: ScratchOutput, + callbacks: VideoRecordingCallbacks, + ) { + runCatching { + preparedRecording.start(mainExecutor) { event -> + handleVideoRecordEvent( + event = event, + callbacks = callbacks, + ) + } + }.onSuccess { recording -> + activeRecordingSession = ActiveRecordingSession( + discardOnFinalize = false, + recording = recording, + scratchOutput = videoOutput, + ) + }.onFailure { throwable -> + deleteScratchOutput(scratchOutput = videoOutput) + callbacks.onError(throwable) + } + } + + private fun handleVideoRecordEvent( + event: VideoRecordEvent, + callbacks: VideoRecordingCallbacks, + ) { + when (event) { + is VideoRecordEvent.Finalize -> { + handleVideoRecordingFinalized( + event = event, + callbacks = callbacks, + ) + } + + is VideoRecordEvent.Start -> { + handleVideoRecordingStarted() + } + + is VideoRecordEvent.Status -> { + handleVideoRecordingStatus(event = event) + } + } + } + + private fun handleVideoRecordingStarted() { + _isRecording.value = true + _recordingDurationMillis.value = 0L + } + + private fun handleVideoRecordingStatus(event: VideoRecordEvent.Status) { + _recordingDurationMillis.value = + event.recordingStats.recordedDurationNanos / NANOS_PER_MILLISECOND + } + + private fun handleVideoRecordingFinalized( + event: VideoRecordEvent.Finalize, + callbacks: VideoRecordingCallbacks, + ) { + val recordingSession = clearRecordingSession() + val recordingOutput = recordingSession?.scratchOutput + + when { + recordingSession?.discardOnFinalize == true -> { + deleteScratchOutput(scratchOutput = recordingOutput) + callbacks.onDiscarded() + } + + event.error == VideoRecordEvent.Finalize.ERROR_NONE -> { + callbacks.onCaptured( + ConversationCapturedMedia( + contentUri = requireNotNull(recordingOutput).uri.toString(), + contentType = ContentType.VIDEO_MP4, + ), + ) + } + + else -> { + deleteScratchOutput(scratchOutput = recordingOutput) + callbacks.onError(createVideoRecordingFailedException(event = event)) + } + } + } + + private fun clearRecordingSession(): ActiveRecordingSession? { + _isRecording.value = false + _recordingDurationMillis.value = 0L + val recordingSession = activeRecordingSession + activeRecordingSession = null + + return recordingSession + } + + private fun invalidateCurrentBinding() { + bindGeneration += 1 + } + + private fun stopRecordingForUnbind() { + val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = true) + recording?.stop() + } + + private fun clearBoundCameraReferences() { + boundCameraSession?.cameraProvider?.unbindAll() + boundCameraSession = null + + bindRequestLifecycleOwner = null + } + + private fun resetUiState() { + resetPhotoFlashAvailabilityState() + + _isPhotoCaptureInProgress.value = false + _isRecording.value = false + _recordingDurationMillis.value = 0L + _surfaceRequest.value = null + } + + private fun resolveSwitchTargetLensFacing(currentLensFacing: Int): Int { + return when (currentLensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraSelector.LENS_FACING_BACK + else -> CameraSelector.LENS_FACING_FRONT + } + } + + private fun requireAvailableLensFacing( + processCameraProvider: ProcessCameraProvider, + lensFacing: Int, + ) { + val cameraSelector = buildCameraSelector(lensFacing = lensFacing) + if (!processCameraProvider.hasCamera(cameraSelector)) { + throw CameraLensUnavailableException( + lensFacing = lensFacing, + ) + } + } + + private fun rebindForLensFacing( + boundCameraSession: BoundCameraSession, + lensFacing: Int, + ) { + preferredLensFacing = lensFacing + rebindUseCases( + lifecycleOwner = boundCameraSession.lifecycleOwner, + processCameraProvider = boundCameraSession.cameraProvider, + ) + } + + private fun resolveBindLensFacing( + processCameraProvider: ProcessCameraProvider, + ): Int { + val preferredCameraSelector = buildCameraSelector(lensFacing = preferredLensFacing) + if (processCameraProvider.hasCamera(preferredCameraSelector)) { + return preferredLensFacing + } + + val fallbackLensFacing = when (preferredLensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraSelector.LENS_FACING_BACK + else -> CameraSelector.LENS_FACING_FRONT + } + + val fallbackCameraSelector = buildCameraSelector(lensFacing = fallbackLensFacing) + if (!processCameraProvider.hasCamera(fallbackCameraSelector)) { + throw CameraLensUnavailableException( + lensFacing = fallbackLensFacing, + ) + } + + return fallbackLensFacing + } + + private fun buildCameraSelector(lensFacing: Int): CameraSelector { + return CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + } + + private fun createScratchOutputOrNull(contentType: String): ScratchOutput? { + val scratchFileUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + resolveScratchFileExtension(contentType = contentType), + ) + + return MediaScratchFileProvider.getFileFromUri(scratchFileUri)?.let { scratchFile -> + ScratchOutput( + file = scratchFile, + uri = scratchFileUri, + ) + } + } + + private fun deleteScratchOutput(scratchOutput: ScratchOutput?) { + if (scratchOutput != null) { + applicationContext.contentResolver.delete(scratchOutput.uri, null, null) + } + } + + private fun isCurrentBindGeneration(bindGeneration: Long): Boolean { + return this.bindGeneration == bindGeneration && bindRequestLifecycleOwner != null + } + + private fun getBoundCameraSessionOrReportError( + onError: (Throwable) -> Unit, + ): BoundCameraSession? { + val currentBoundCameraSession = boundCameraSession + if (currentBoundCameraSession == null) { + onError(CameraNotBoundException()) + } + + return currentBoundCameraSession + } + + private fun updateRecordingDiscardOnFinalize(discardOnFinalize: Boolean): Recording? { + val currentRecordingSession = activeRecordingSession ?: return null + + activeRecordingSession = currentRecordingSession.copy( + discardOnFinalize = discardOnFinalize, + ) + + return currentRecordingSession.recording + } + + private fun createVideoRecordingFailedException( + event: VideoRecordEvent.Finalize, + ): VideoRecordingFailedException { + return VideoRecordingFailedException( + cause = event.cause, + errorCode = event.error, + errorName = resolveVideoRecordingErrorName(errorCode = event.error), + ) + } + + private fun resolveVideoRecordingErrorName(errorCode: Int): String { + return when (errorCode) { + VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED -> "duration_limit_reached" + VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED -> "encoding_failed" + VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED -> "file_size_limit_reached" + VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE -> "insufficient_storage" + VideoRecordEvent.Finalize.ERROR_INVALID_OUTPUT_OPTIONS -> "invalid_output_options" + VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA -> "no_valid_data" + VideoRecordEvent.Finalize.ERROR_RECORDER_ERROR -> "recorder_error" + VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE -> "source_inactive" + else -> "unknown" + } + } + + private data class BoundUseCases( + val imageCapture: ImageCapture, + val preview: Preview, + val videoCapture: VideoCapture, + ) + + private data class BoundCameraSession( + val boundCamera: Camera, + val cameraProvider: ProcessCameraProvider, + val imageCapture: ImageCapture, + val lifecycleOwner: LifecycleOwner, + val lensFacing: Int, + val videoCapture: VideoCapture, + ) + + private data class ActiveRecordingSession( + val discardOnFinalize: Boolean, + val recording: Recording, + val scratchOutput: ScratchOutput, + ) + + private data class ScratchOutput( + val file: File, + val uri: Uri, + ) + + private data class VideoRecordingCallbacks( + val onCaptured: (ConversationCapturedMedia) -> Unit, + val onDiscarded: () -> Unit, + val onError: (Throwable) -> Unit, + ) + + private fun resolveScratchFileExtension(contentType: String): String { + val mimeTypeExtension = ContentType.getExtensionFromMimeType(contentType) + + return mimeTypeExtension ?: ContentType.getExtension(contentType) + } + + private companion object { + private const val NANOS_PER_MILLISECOND = 1_000_000L + } +} + +@Composable +internal fun rememberConversationCameraController(): ConversationCameraController { + val context = LocalContext.current + + return remember(context) { + ConversationCameraControllerImpl( + context = context, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt new file mode 100644 index 00000000..af41377d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt @@ -0,0 +1,39 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.LifecycleOwner +import com.android.messaging.R +import com.android.messaging.util.UiUtils + +@Composable +internal fun BindConversationCameraLifecycleEffect( + cameraController: ConversationCameraController, + cameraPermissionGranted: Boolean, + isCameraPreviewVisible: Boolean, + lifecycleOwner: LifecycleOwner, +) { + DisposableEffect( + cameraController, + cameraPermissionGranted, + isCameraPreviewVisible, + lifecycleOwner, + ) { + when { + cameraPermissionGranted && isCameraPreviewVisible -> { + cameraController.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + onError = { + UiUtils.showToastAtBottom(R.string.camera_error_opening) + }, + ) + } + + else -> cameraController.unbind() + } + + onDispose { + cameraController.unbind() + } + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt new file mode 100644 index 00000000..48cb0dcf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt @@ -0,0 +1,79 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import com.android.messaging.R +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.util.UiUtils + +internal fun handlePhotoCaptureRequest( + cameraController: ConversationCameraController, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, +) { + if (!onAttachmentStartRequest()) { + return + } + + cameraController.capturePhoto( + onCaptured = { capturedMedia -> + onCapturedMediaReady(capturedMedia) + onShowReview(capturedMedia.contentUri) + }, + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_failure_taking_picture, + ) + }, + ) +} + +internal fun handleSwitchCameraRequest(cameraController: ConversationCameraController) { + cameraController.switchCamera( + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_opening, + ) + }, + ) +} + +internal fun handleToggleFlashRequest(cameraController: ConversationCameraController) { + cameraController.cyclePhotoFlashMode( + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_opening, + ) + }, + ) +} + +internal fun handleVideoCaptureRequest( + cameraController: ConversationCameraController, + isRecording: Boolean, + onAttachmentStartRequest: () -> Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, +) { + if (isRecording) { + cameraController.stopVideoRecording() + return + } + + if (!onAttachmentStartRequest()) { + return + } + + cameraController.startVideoRecording( + withAudio = true, + onCaptured = { capturedMedia -> + onCapturedMediaReady(capturedMedia) + onShowReview(capturedMedia.contentUri) + }, + onDiscarded = {}, + onError = { + UiUtils.showToastAtBottom( + R.string.camera_media_failure, + ) + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt new file mode 100644 index 00000000..f0c15e97 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt @@ -0,0 +1,26 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import androidx.camera.core.ImageCapture + +internal enum class ConversationPhotoFlashMode( + val imageCaptureFlashMode: Int, +) { + Off( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_OFF, + ), + Auto( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_AUTO, + ), + On( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_ON, + ), + ; + + fun next(): ConversationPhotoFlashMode { + return when (this) { + Off -> Auto + Auto -> On + On -> Off + } + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt new file mode 100644 index 00000000..88f06956 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt @@ -0,0 +1,74 @@ +package com.android.messaging.ui.conversation.mediapicker.camera + +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCaptureException + +internal sealed class ConversationCameraControllerException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal class CameraNotBoundException : + ConversationCameraControllerException( + message = "Camera is not bound", + ) + +internal class CameraLensUnavailableException( + lensFacing: Int, +) : ConversationCameraControllerException( + message = "Requested camera lens is not available: ${resolveLensFacingName( + lensFacing = lensFacing + )}", +) + +internal class PhotoCaptureFailedException( + cause: ImageCaptureException, +) : ConversationCameraControllerException( + message = "Photo capture failed", + cause = cause, +) + +internal class PhotoCaptureAlreadyInProgressException : + ConversationCameraControllerException( + message = "Photo capture is already in progress", + ) + +internal class PhotoCaptureStartFailedException( + cause: Throwable, +) : ConversationCameraControllerException( + message = "Photo capture could not be started", + cause = cause, +) + +internal class RecordingAlreadyInProgressException : + ConversationCameraControllerException( + message = "Video recording is already in progress", + ) + +internal class ScratchFileCreationFailedException( + mediaLabel: String, +) : ConversationCameraControllerException( + message = "Unable to create $mediaLabel scratch file", +) + +internal class FlashUnavailableException : + ConversationCameraControllerException( + message = "Flash is not available for the current camera", + ) + +internal class VideoRecordingFailedException( + errorCode: Int, + errorName: String, + cause: Throwable? = null, +) : ConversationCameraControllerException( + message = "Video recording failed: $errorName ($errorCode)", + cause = cause, +) + +private fun resolveLensFacingName(lensFacing: Int): String { + return when (lensFacing) { + CameraSelector.LENS_FACING_BACK -> "back" + CameraSelector.LENS_FACING_FRONT -> "front" + else -> "unknown" + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt new file mode 100644 index 00000000..396ed863 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt @@ -0,0 +1,157 @@ +package com.android.messaging.ui.conversation.mediapicker.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material3.Button +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val PICKER_CONTROL_BUTTON_SIZE = 48.dp + +internal fun pickerOverlayContainerColor(alpha: Float): Color { + return Color.Black.copy(alpha = alpha) +} + +internal fun pickerOverlayContentColor(alpha: Float = 1f): Color { + return Color.White.copy(alpha = alpha) +} + +@Composable +internal fun PermissionFallback( + icon: @Composable () -> Unit, + message: String, + actionLabel: String, + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ) { + Box( + modifier = Modifier + .padding(all = 14.dp), + contentAlignment = Alignment.Center, + ) { + icon() + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + text = message, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .heightIn(min = 56.dp), + onClick = onActionClick, + shape = MaterialTheme.shapes.extraLarge, + ) { + Icon( + imageVector = Icons.Rounded.CameraAlt, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(width = 8.dp)) + Text(text = actionLabel) + } + } + } +} + +@Composable +internal fun PickerOverlayBackgroundButton( + modifier: Modifier = Modifier, + buttonSize: Dp = PICKER_CONTROL_BUTTON_SIZE, + containerColor: Color = pickerOverlayContainerColor(alpha = 0.48f), + contentDescription: String, + iconSize: Dp = 24.dp, + imageVector: ImageVector, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = modifier + .size(buttonSize), + onClick = onClick, + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = containerColor, + contentColor = pickerOverlayContentColor(), + ), + ) { + Icon( + modifier = Modifier + .size(iconSize), + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} + +@Composable +internal fun PickerOverlayIconButton( + modifier: Modifier = Modifier, + contentDescription: String, + enabled: Boolean = true, + imageVector: ImageVector, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = modifier + .size(PICKER_CONTROL_BUTTON_SIZE), + onClick = onClick, + enabled = enabled, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = pickerOverlayContainerColor(alpha = 0.5f), + contentColor = pickerOverlayContentColor(), + disabledContainerColor = pickerOverlayContainerColor(alpha = 0.25f), + disabledContentColor = pickerOverlayContentColor(alpha = 0.5f), + ), + shape = CircleShape, + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt new file mode 100644 index 00000000..b332f282 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -0,0 +1,231 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cameraswitch +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FlashAuto +import androidx.compose.material.icons.rounded.FlashOff +import androidx.compose.material.icons.rounded.FlashOn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayIconButton +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor + +@Composable +internal fun ConversationMediaCaptureTopBar( + modifier: Modifier = Modifier, + captureMode: ConversationCaptureMode, + hasFlashUnit: Boolean, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + photoFlashMode: ConversationPhotoFlashMode, + onCloseClick: () -> Unit, + onFlashClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_close_content_description, + ), + imageVector = Icons.Rounded.Close, + onClick = onCloseClick, + ) + if (hasFlashUnit && captureMode == ConversationCaptureMode.Photo) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_cycle_flash_mode_content_description, + ), + enabled = !isPhotoCaptureInProgress && !isRecording, + imageVector = when (photoFlashMode) { + ConversationPhotoFlashMode.Auto -> Icons.Rounded.FlashAuto + ConversationPhotoFlashMode.Off -> Icons.Rounded.FlashOff + ConversationPhotoFlashMode.On -> Icons.Rounded.FlashOn + }, + onClick = onFlashClick, + ) + } + } +} + +@Composable +internal fun ConversationMediaCaptureControls( + modifier: Modifier = Modifier, + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + recordingDurationMillis: Long, + onCaptureClick: () -> Unit, + onPhotoModeClick: () -> Unit, + onSwitchCameraClick: () -> Unit, + onVideoModeClick: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(48.dp)) + + Column( + modifier = Modifier + .weight(weight = 1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isRecording) { + ConversationMediaRecordingTimerPill( + durationMillis = recordingDurationMillis, + ) + } + + ConversationMediaCaptureShutterButton( + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + onClick = onCaptureClick, + ) + + ConversationMediaCaptureModeToggle( + captureMode = captureMode, + enabled = !isPhotoCaptureInProgress && !isRecording, + onPhotoModeClick = onPhotoModeClick, + onVideoModeClick = onVideoModeClick, + ) + } + PickerOverlayIconButton( + modifier = Modifier + .align(Alignment.CenterVertically), + contentDescription = stringResource( + id = R.string.camera_switch_camera_facing, + ), + enabled = !isPhotoCaptureInProgress && !isRecording, + imageVector = Icons.Rounded.Cameraswitch, + onClick = onSwitchCameraClick, + ) + } + } +} + +@Composable +private fun ConversationMediaCaptureModeToggle( + captureMode: ConversationCaptureMode, + enabled: Boolean, + onPhotoModeClick: () -> Unit, + onVideoModeClick: () -> Unit, +) { + Surface( + shape = CircleShape, + color = pickerOverlayContainerColor(alpha = 0.4f), + ) { + Row( + modifier = Modifier + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationMediaCaptureModeChip( + isSelected = captureMode == ConversationCaptureMode.Photo, + label = stringResource(id = R.string.conversation_media_picker_photo_mode), + enabled = enabled, + onClick = onPhotoModeClick, + ) + + ConversationMediaCaptureModeChip( + isSelected = captureMode == ConversationCaptureMode.Video, + label = stringResource(id = R.string.conversation_media_picker_video_mode), + enabled = enabled, + onClick = onVideoModeClick, + ) + } + } +} + +@Composable +private fun ConversationMediaCaptureModeChip( + isSelected: Boolean, + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .height(36.dp) + .clip(CircleShape) + .clickable( + enabled = enabled, + onClick = onClick, + ), + shape = CircleShape, + color = when { + isSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> pickerOverlayContainerColor(alpha = 0f) + }, + ) { + Box( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = when { + isSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> pickerOverlayContentColor(alpha = 0.9f) + }, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun ConversationMediaRecordingTimerPill( + durationMillis: Long, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 8.dp), + text = formatConversationAudioDuration(durationMillis = durationMillis), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.labelLarge, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt new file mode 100644 index 00000000..a39a79a5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -0,0 +1,458 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.Photo +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoIdle +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoRecording +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor + +private val PICKER_SHUTTER_BORDER_WIDTH = 3.dp +private val PICKER_SHUTTER_OUTER_SIZE = 78.dp +private val PICKER_SHUTTER_PHOTO_INNER_SIZE = 62.dp +private val PICKER_SHUTTER_FULL_INNER_SIZE = PICKER_SHUTTER_OUTER_SIZE - + (PICKER_SHUTTER_BORDER_WIDTH * 2) +private const val PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO = 0.7f +private const val PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS = 500f +private val PICKER_SHUTTER_COLOR_ANIMATION_SPEC = tween(durationMillis = 180) +private val PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC = spring( + dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, + stiffness = PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS, +) + +@Composable +internal fun ConversationMediaCaptureShutterButton( + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val isEnabled = captureMode != ConversationCaptureMode.Photo || !isPhotoCaptureInProgress + val shutterPhase = resolveConversationMediaCaptureShutterPhase( + captureMode = captureMode, + isRecording = isRecording, + ) + ConversationMediaCaptureShutterButtonAnimatedContent( + colorScheme = colorScheme, + isEnabled = isEnabled, + onClick = onClick, + shutterPhase = shutterPhase, + ) +} + +@Composable +private fun ConversationMediaCaptureShutterButtonAnimatedContent( + colorScheme: ColorScheme, + isEnabled: Boolean, + onClick: () -> Unit, + shutterPhase: ConversationMediaCaptureShutterPhase, +) { + val visualState = animateShutterVisualState( + colorScheme = colorScheme, + shutterPhase = shutterPhase, + ) + + ConversationMediaCaptureShutterButtonShell( + borderColor = pickerOverlayContentColor(), + isEnabled = isEnabled, + onClick = onClick, + outerContainerColor = visualState.outerContainerColor, + outerScale = visualState.outerScale, + ) { + ConversationMediaCaptureShutterInnerDisc( + innerShutterColor = visualState.innerShutterColor, + innerShutterSize = visualState.innerShutterSize, + ) { + if (shutterPhase != Photo) { + ConversationMediaCaptureVideoOverlay( + recordingStopAlpha = visualState.recordingStopAlpha, + recordingStopBackgroundColor = visualState.recordingStopBackgroundColor, + recordingStopScale = visualState.recordingStopScale, + videoCenterDotAlpha = visualState.videoCenterDotAlpha, + videoCenterDotColor = visualState.videoCenterDotColor, + videoCenterDotScale = visualState.videoCenterDotScale, + ) + } + } + } +} + +@Composable +private fun animateShutterVisualState( + colorScheme: ColorScheme, + shutterPhase: ConversationMediaCaptureShutterPhase, +): ConversationMediaCaptureShutterVisualState { + val transition = updateTransition( + targetState = shutterPhase, + label = "picker_shutter_phase", + ) + val surfaceVisualState = transition.animateShutterSurfaceVisualState( + colorScheme = colorScheme, + ) + val recordingStopVisualState = + transition.animateRecordingStopVisualState( + colorScheme = colorScheme, + ) + val videoCenterDotVisualState = + transition.animateVideoCenterDotVisualState( + colorScheme = colorScheme, + ) + val targetVisualState = shutterPhase.toVisualState(colorScheme = colorScheme) + + return ConversationMediaCaptureShutterVisualState( + innerShutterColor = surfaceVisualState.innerShutterColor, + innerShutterSize = surfaceVisualState.innerShutterSize, + outerContainerColor = surfaceVisualState.outerContainerColor, + outerScale = surfaceVisualState.outerScale, + recordingStopAlpha = recordingStopVisualState.alpha, + recordingStopBackgroundColor = targetVisualState.recordingStopBackgroundColor, + recordingStopScale = recordingStopVisualState.scale, + videoCenterDotAlpha = videoCenterDotVisualState.alpha, + videoCenterDotColor = targetVisualState.videoCenterDotColor, + videoCenterDotScale = videoCenterDotVisualState.scale, + ) +} + +@Composable +private fun Transition.animateShutterSurfaceVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureShutterSurfaceVisualState { + val innerShutterColor by animateColor( + transitionSpec = { + PICKER_SHUTTER_COLOR_ANIMATION_SPEC + }, + label = "picker_shutter_inner_color", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).innerShutterColor + }, + ) + val innerShutterSize by animateDp( + transitionSpec = { + spring( + dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, + stiffness = PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS, + ) + }, + label = "picker_shutter_inner_size", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).innerShutterSize + }, + ) + val outerContainerColor by animateColor( + transitionSpec = { + PICKER_SHUTTER_COLOR_ANIMATION_SPEC + }, + label = "picker_shutter_outer_color", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).outerContainerColor + }, + ) + val outerScale by animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_outer_scale", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).outerScale + }, + ) + + return ConversationMediaCaptureShutterSurfaceVisualState( + innerShutterColor = innerShutterColor, + innerShutterSize = innerShutterSize, + outerContainerColor = outerContainerColor, + outerScale = outerScale, + ) +} + +@Composable +private fun Transition.animateRecordingStopVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureRecordingStopVisualState { + val recordingStopAlpha by animateFloat( + transitionSpec = { + tween(durationMillis = 130) + }, + label = "picker_shutter_recording_stop_alpha", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).recordingStopAlpha + }, + ) + val recordingStopScale by animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_recording_stop_scale", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).recordingStopScale + }, + ) + + return ConversationMediaCaptureRecordingStopVisualState( + alpha = recordingStopAlpha, + scale = recordingStopScale, + ) +} + +@Composable +private fun Transition.animateVideoCenterDotVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureVideoCenterDotVisualState { + val videoCenterDotAlpha by animateFloat( + transitionSpec = { + tween(durationMillis = 110) + }, + label = "picker_shutter_video_center_dot_alpha", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).videoCenterDotAlpha + }, + ) + val videoCenterDotScale by animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_video_center_dot_scale", + targetValueByState = { phase -> + phase.toVisualState(colorScheme = colorScheme).videoCenterDotScale + }, + ) + + return ConversationMediaCaptureVideoCenterDotVisualState( + alpha = videoCenterDotAlpha, + scale = videoCenterDotScale, + ) +} + +@Composable +private fun ConversationMediaCaptureShutterButtonShell( + borderColor: Color, + isEnabled: Boolean, + onClick: () -> Unit, + outerContainerColor: Color, + outerScale: Float, + content: @Composable () -> Unit, +) { + Surface( + modifier = Modifier + .size(PICKER_SHUTTER_OUTER_SIZE) + .graphicsLayer { + alpha = if (isEnabled) 1f else 0.7f + scaleX = outerScale + scaleY = outerScale + }, + enabled = isEnabled, + onClick = onClick, + shape = CircleShape, + color = outerContainerColor, + border = BorderStroke( + width = PICKER_SHUTTER_BORDER_WIDTH, + color = borderColor, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun ConversationMediaCaptureShutterInnerDisc( + innerShutterColor: Color, + innerShutterSize: Dp, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + modifier = Modifier.size(innerShutterSize), + shape = CircleShape, + color = innerShutterColor, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = content, + ) + } +} + +@Composable +private fun ConversationMediaCaptureVideoOverlay( + recordingStopAlpha: Float, + recordingStopBackgroundColor: Color, + recordingStopScale: Float, + videoCenterDotAlpha: Float, + videoCenterDotColor: Color, + videoCenterDotScale: Float, +) { + ConversationMediaCaptureRecordingStopGlyph( + alpha = recordingStopAlpha, + backgroundColor = recordingStopBackgroundColor, + scale = recordingStopScale, + ) + + ConversationMediaCaptureVideoIdleDotGlyph( + alpha = videoCenterDotAlpha, + color = videoCenterDotColor, + scale = videoCenterDotScale, + ) +} + +@Composable +private fun ConversationMediaCaptureRecordingStopGlyph( + alpha: Float, + backgroundColor: Color, + scale: Float, +) { + Box( + modifier = Modifier + .size(28.dp) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + } + .background( + color = backgroundColor, + shape = RoundedCornerShape(size = 10.dp), + ), + ) +} + +@Composable +private fun ConversationMediaCaptureVideoIdleDotGlyph( + alpha: Float, + color: Color, + scale: Float, +) { + Surface( + modifier = Modifier + .size(16.dp) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + }, + shape = CircleShape, + color = color, + ) {} +} + +private fun resolveConversationMediaCaptureShutterPhase( + captureMode: ConversationCaptureMode, + isRecording: Boolean, +): ConversationMediaCaptureShutterPhase { + return when { + isRecording -> VideoRecording + captureMode == ConversationCaptureMode.Video -> VideoIdle + else -> Photo + } +} + +@Suppress("ktlint:standard:trailing-comma-on-declaration-site") +private enum class ConversationMediaCaptureShutterPhase { + Photo, + VideoIdle, + VideoRecording; + + fun toVisualState(colorScheme: ColorScheme): ConversationMediaCaptureShutterVisualState { + return when (this) { + Photo -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = pickerOverlayContentColor(), + innerShutterSize = PICKER_SHUTTER_PHOTO_INNER_SIZE, + outerContainerColor = pickerOverlayContainerColor(alpha = 0.2f), + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = pickerOverlayContentColor(), + videoCenterDotScale = 0.7f, + ) + + VideoIdle -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = pickerOverlayContainerColor(alpha = 0.5f), + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 1f, + videoCenterDotColor = pickerOverlayContentColor(), + videoCenterDotScale = 1f, + ) + + VideoRecording -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.errorContainer, + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 0.97f, + recordingStopAlpha = 1f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 1f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = pickerOverlayContentColor(), + videoCenterDotScale = 0.7f, + ) + } + } +} + +private data class ConversationMediaCaptureShutterVisualState( + val innerShutterColor: Color, + val innerShutterSize: Dp, + val outerContainerColor: Color, + val outerScale: Float, + val recordingStopAlpha: Float, + val recordingStopBackgroundColor: Color, + val recordingStopScale: Float, + val videoCenterDotAlpha: Float, + val videoCenterDotColor: Color, + val videoCenterDotScale: Float, +) + +private data class ConversationMediaCaptureShutterSurfaceVisualState( + val innerShutterColor: Color, + val innerShutterSize: Dp, + val outerContainerColor: Color, + val outerScale: Float, +) + +private data class ConversationMediaCaptureRecordingStopVisualState( + val alpha: Float, + val scale: Float, +) + +private data class ConversationMediaCaptureVideoCenterDotVisualState( + val alpha: Float, + val scale: Float, +) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt new file mode 100644 index 00000000..d14b0fb0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt @@ -0,0 +1,180 @@ +package com.android.messaging.ui.conversation.mediapicker.component.capture + +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.SurfaceRequest +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.mediapicker.component.PermissionFallback + +@Composable +internal fun ConversationMediaCameraPreviewSurface( + modifier: Modifier = Modifier, + cameraPermissionGranted: Boolean, + contentPadding: PaddingValues, + surfaceRequest: SurfaceRequest?, + onRequestCameraPermission: () -> Unit, +) { + Box( + modifier = modifier + .background(color = MaterialTheme.colorScheme.scrim), + ) { + when { + !cameraPermissionGranted -> { + ConversationMediaCameraPermissionFallback( + contentPadding = contentPadding, + onRequestCameraPermission = onRequestCameraPermission, + ) + } + + surfaceRequest == null -> { + ConversationMediaCameraLoadingState() + } + + else -> { + ConversationMediaCameraViewfinder( + surfaceRequest = surfaceRequest, + ) + } + } + } +} + +@Composable +private fun ConversationMediaCameraPermissionFallback( + contentPadding: PaddingValues, + onRequestCameraPermission: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.Center, + ) { + PermissionFallback( + icon = { + Icon( + imageVector = Icons.Rounded.CameraAlt, + contentDescription = null, + ) + }, + message = stringResource( + id = R.string.conversation_media_picker_camera_permission_message, + ), + actionLabel = stringResource( + id = R.string.conversation_media_picker_allow_camera, + ), + onActionClick = onRequestCameraPermission, + ) + } +} + +@Composable +private fun ConversationMediaCameraLoadingState() { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ConversationMediaCameraViewfinder( + surfaceRequest: SurfaceRequest, +) { + CameraXViewfinder( + modifier = Modifier + .fillMaxSize(), + surfaceRequest = surfaceRequest, + ) +} + +@Composable +internal fun ConversationMediaCaptureContent( + modifier: Modifier = Modifier, + audioPermissionGranted: Boolean, + captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, + hasFlashUnit: Boolean, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + photoFlashMode: ConversationPhotoFlashMode, + onCloseClick: () -> Unit, + onRequestAudioPermission: () -> Unit, + onPhotoCaptureClick: () -> Unit, + onPhotoModeClick: () -> Unit, + onSwitchCameraClick: () -> Unit, + onToggleFlashClick: () -> Unit, + onVideoCaptureClick: () -> Unit, + onVideoModeClick: () -> Unit, + recordingDurationMillis: Long, +) { + Box( + modifier = modifier, + ) { + ConversationMediaCaptureTopBar( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + captureMode = captureMode, + hasFlashUnit = cameraPermissionGranted && hasFlashUnit, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + photoFlashMode = photoFlashMode, + onCloseClick = onCloseClick, + onFlashClick = onToggleFlashClick, + ) + + if (cameraPermissionGranted) { + ConversationMediaCaptureControls( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 20.dp, vertical = 24.dp), + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + recordingDurationMillis = recordingDurationMillis, + onCaptureClick = { + when (captureMode) { + ConversationCaptureMode.Video -> { + when { + !isRecording && !audioPermissionGranted -> { + onRequestAudioPermission() + } + + else -> onVideoCaptureClick() + } + } + + else -> onPhotoCaptureClick() + } + }, + onPhotoModeClick = onPhotoModeClick, + onSwitchCameraClick = onSwitchCameraClick, + onVideoModeClick = onVideoModeClick, + ) + } + } +} 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 new file mode 100644 index 00000000..fead995b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -0,0 +1,490 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +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.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.composer.ui.ConversationSendActionButton +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayIconButton +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +private const val PICKER_REVIEW_PAGE_ASPECT_RATIO = 0.8f +private const val PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION = 0.95f +private const val PICKER_REVIEW_PAGE_WIDTH_FRACTION = 0.8f + +@Composable +internal fun ConversationMediaReviewScene( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + attachments: ImmutableList, + conversationTitle: String?, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + isSendActionEnabled: Boolean, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap = + persistentMapOf(), + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onAddMoreClick: () -> Unit, + onClearReview: () -> Unit, + onCloseClick: () -> Unit, + onSendClick: () -> Unit, +) { + if (attachments.isEmpty()) { + return + } + + val reviewPagerState = rememberConversationMediaReviewPagerState( + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + reviewRequestSequence = reviewRequestSequence, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + ) + + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + + val reviewBottomPadding = maxOf( + contentPadding.calculateBottomPadding(), + imeBottomPadding, + ) + 12.dp + + ConversationMediaReviewSceneContent( + modifier = modifier, + attachments = attachments, + conversationTitle = conversationTitle, + reviewBottomPadding = reviewBottomPadding, + reviewPagerState = reviewPagerState, + isSendActionEnabled = isSendActionEnabled, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onAddMoreClick, + onClearReview = onClearReview, + onCloseClick = onCloseClick, + onSendClick = onSendClick, + ) +} + +@Composable +private fun ConversationMediaReviewSceneContent( + modifier: Modifier = Modifier, + attachments: ImmutableList, + conversationTitle: String?, + reviewBottomPadding: Dp, + reviewPagerState: ConversationMediaReviewPagerState, + isSendActionEnabled: Boolean, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onAddMoreClick: () -> Unit, + onClearReview: () -> Unit, + onCloseClick: () -> Unit, + onSendClick: () -> Unit, +) { + Box( + modifier = modifier, + ) { + ConversationMediaReviewBackground( + modifier = Modifier.fillMaxSize(), + pagerState = reviewPagerState.pagerState, + attachments = attachments, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = 12.dp, + bottom = reviewBottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ConversationMediaReviewTopBar( + conversationTitle = conversationTitle, + onAddMoreClick = onAddMoreClick, + onCloseClick = onCloseClick, + ) + + ConversationMediaReviewPager( + modifier = Modifier + .weight(weight = 1f) + .fillMaxWidth(), + attachmentContentUris = reviewPagerState.attachmentContentUris, + attachments = attachments, + pagerState = reviewPagerState.pagerState, + visibleDeleteChipPage = reviewPagerState.visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + + ConversationMediaReviewBottomBar( + attachment = reviewPagerState.currentAttachment, + isSendActionEnabled = isSendActionEnabled, + onCaptionChange = onCaptionChange, + onSendClick = onSendClick, + ) + } + } +} + +@Composable +private fun ConversationMediaReviewTopBar( + conversationTitle: String?, + onAddMoreClick: () -> Unit, + onCloseClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .statusBarsPadding(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_close_content_description, + ), + imageVector = Icons.Rounded.Close, + onClick = onCloseClick, + ) + Text( + modifier = Modifier + .weight(weight = 1f), + text = conversationTitle.orEmpty(), + color = pickerOverlayContentColor(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_add_more_content_description, + ), + imageVector = Icons.Rounded.AddAPhoto, + onClick = onAddMoreClick, + ) + } +} + +@Composable +private fun ConversationMediaReviewPager( + modifier: Modifier = Modifier, + attachmentContentUris: ImmutableList, + attachments: ImmutableList, + pagerState: PagerState, + visibleDeleteChipPage: Int?, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + BoxWithConstraints( + modifier = modifier, + ) { + val pagerLayout = rememberConversationMediaReviewPagerLayout( + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), + state = pagerState, + contentPadding = PaddingValues(horizontal = pagerLayout.pageHorizontalInset), + pageSize = PageSize.Fixed(pagerLayout.pageWidth), + pageSpacing = 12.dp, + key = { page -> + attachmentContentUris.getOrElse(index = page) { + "stale-review-page-$page" + } + }, + ) { page -> + ConversationMediaReviewPageSlot( + attachments = attachments, + page = page, + pageHeight = pagerLayout.pageHeight, + pageWidth = pagerLayout.pageWidth, + pagerState = pagerState, + previewSize = pagerLayout.previewSize, + visibleDeleteChipPage = visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + } + } +} + +@Composable +private fun rememberConversationMediaReviewPagerLayout( + maxWidth: Dp, + maxHeight: Dp, +): ConversationMediaReviewPagerLayout { + val maxPageWidth = maxWidth * PICKER_REVIEW_PAGE_WIDTH_FRACTION + val maxPageHeight = maxHeight * PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION + val pageWidthFromHeight = maxPageHeight * PICKER_REVIEW_PAGE_ASPECT_RATIO + + val pageWidth = when { + maxPageWidth <= pageWidthFromHeight -> maxPageWidth + else -> pageWidthFromHeight + } + + val pageHeight = pageWidth / PICKER_REVIEW_PAGE_ASPECT_RATIO + val density = LocalDensity.current + val currentPreviewSize = remember(pageWidth, pageHeight, density) { + with(density) { + IntSize( + width = pageWidth.roundToPx().coerceAtLeast(minimumValue = 1), + height = pageHeight.roundToPx().coerceAtLeast(minimumValue = 1), + ) + } + } + + return ConversationMediaReviewPagerLayout( + pageHeight = pageHeight, + pageHorizontalInset = (maxWidth - pageWidth) / 2, + pageWidth = pageWidth, + previewSize = rememberLargestReviewPreviewSize( + currentPreviewSize = currentPreviewSize, + ), + ) +} + +@Composable +private fun ConversationMediaReviewPageSlot( + attachments: ImmutableList, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + visibleDeleteChipPage: Int?, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + val attachment = attachments.getOrNull(index = page) + + when { + attachment != null -> { + ConversationMediaReviewPageCard( + attachment = attachment, + attachments = attachments, + page = page, + pageHeight = pageHeight, + pageWidth = pageWidth, + pagerState = pagerState, + previewSize = previewSize, + shouldShowDeleteChip = page == visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + } + + else -> { + Box( + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun rememberLargestReviewPreviewSize( + currentPreviewSize: IntSize, +): IntSize { + var largestPreviewSize by remember { + mutableStateOf(value = currentPreviewSize) + } + + SideEffect { + val updatedPreviewSize = IntSize( + width = maxOf( + largestPreviewSize.width, + currentPreviewSize.width, + ), + height = maxOf( + largestPreviewSize.height, + currentPreviewSize.height, + ), + ) + + if (updatedPreviewSize != largestPreviewSize) { + largestPreviewSize = updatedPreviewSize + } + } + + return largestPreviewSize +} + +private data class ConversationMediaReviewPagerLayout( + val pageHeight: Dp, + val pageHorizontalInset: Dp, + val pageWidth: Dp, + val previewSize: IntSize, +) + +@Composable +private fun ReviewCaptionTextField( + modifier: Modifier = Modifier, + attachmentContentUri: String, + captionText: String, + onCaptionChange: (String) -> Unit, +) { + val containerColor = MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.95f) + var isFocused by remember { + mutableStateOf(value = false) + } + var fieldValue by remember(attachmentContentUri) { + mutableStateOf( + value = captionText.toCaptionTextFieldValue(), + ) + } + + LaunchedEffect(attachmentContentUri, captionText) { + if (!isFocused && fieldValue.text != captionText) { + fieldValue = captionText.toCaptionTextFieldValue() + } + } + + TextField( + modifier = modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + }, + value = fieldValue, + onValueChange = { updatedFieldValue -> + val previousText = fieldValue.text + fieldValue = updatedFieldValue + if (updatedFieldValue.text != previousText) { + onCaptionChange(updatedFieldValue.text) + } + }, + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + disabledContainerColor = containerColor, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f, + ), + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.5f, + ), + ), + placeholder = { + Text( + text = stringResource(R.string.conversation_media_picker_caption_hint), + ) + }, + singleLine = true, + ) +} + +private fun String.toCaptionTextFieldValue(): TextFieldValue { + return TextFieldValue( + text = this, + selection = TextRange(index = length), + ) +} + +@Composable +private fun ConversationMediaReviewBottomBar( + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + isSendActionEnabled: Boolean, + onCaptionChange: (String, String) -> Unit, + onSendClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ReviewCaptionTextField( + modifier = Modifier.weight(weight = 1f), + attachmentContentUri = attachment.contentUri, + captionText = attachment.captionText, + onCaptionChange = { captionText -> + onCaptionChange( + attachment.contentUri, + captionText, + ) + }, + ) + + ConversationSendActionButton( + enabled = isSendActionEnabled, + mode = ConversationSendActionButtonMode.Send, + isRecordingActive = false, + isRecordingLocked = false, + onClick = onSendClick, + onLockedStopClick = {}, + onRecordGestureStart = {}, + onRecordGestureMove = { _ -> }, + onRecordGestureLock = { false }, + onRecordGestureFinish = {}, + onSendActionLongClick = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt new file mode 100644 index 00000000..6531aaa7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -0,0 +1,214 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.core.net.toUri +import com.android.messaging.ui.conversation.attachment.ui.loadConversationMediaThumbnailBitmap +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +private const val PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX = 40 + +@Composable +internal fun ConversationMediaReviewBackground( + modifier: Modifier = Modifier, + pagerState: PagerState, + attachments: ImmutableList, +) { + val backgroundState = rememberConversationMediaReviewBackgroundState( + pagerState = pagerState, + attachments = attachments, + ) + + ConversationMediaReviewBackgroundContent( + modifier = modifier, + state = backgroundState, + ) +} + +@Composable +private fun ConversationMediaReviewBackgroundContent( + modifier: Modifier = Modifier, + state: ConversationMediaReviewBackgroundState, +) { + Box( + modifier = modifier + .fillMaxSize() + .background( + color = state.fallbackBackgroundColor, + ), + ) { + if (state.settledBackgroundImageBitmap != null) { + Image( + bitmap = state.settledBackgroundImageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Low, + modifier = Modifier.fillMaxSize(), + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = pickerOverlayContainerColor(alpha = 0.5f), + ), + ) + } + } +} + +@Composable +private fun rememberConversationMediaReviewBackgroundState( + pagerState: PagerState, + attachments: ImmutableList, +): ConversationMediaReviewBackgroundState { + val backgroundSelection = remember( + attachments, + pagerState.settledPage, + ) { + getConversationMediaReviewBackgroundSelection( + attachments = attachments, + settledPage = pagerState.settledPage, + ) + } + + val backgroundBitmapCache = rememberConversationMediaReviewBitmapCache( + attachments = attachments, + attachmentsToPrefetch = backgroundSelection.attachmentsToPrefetch, + ) + + val fallbackBackgroundColor = MaterialTheme + .colorScheme + .surfaceContainerHighest + .copy(alpha = 0.9f) + + val settledBackgroundBitmap = backgroundSelection + .attachmentsToPrefetch + .firstOrNull() + ?.let { attachment -> + backgroundBitmapCache[attachment.contentUri] + } + + val settledBackgroundImageBitmap = settledBackgroundBitmap?.asImageBitmap() + + return ConversationMediaReviewBackgroundState( + settledBackgroundImageBitmap = settledBackgroundImageBitmap, + fallbackBackgroundColor = fallbackBackgroundColor, + ) +} + +@Composable +private fun rememberConversationMediaReviewBitmapCache( + attachments: ImmutableList, + attachmentsToPrefetch: ImmutableList, +): ConversationMediaReviewBitmapCache { + val context = LocalContext.current + + val backgroundBitmapCache = remember { + ConversationMediaReviewBitmapCache() + } + + LaunchedEffect(attachments) { + backgroundBitmapCache.removeInactive( + activeContentUris = attachments + .asSequence() + .map { it.contentUri } + .toSet(), + ) + } + + LaunchedEffect(attachmentsToPrefetch) { + attachmentsToPrefetch + .asSequence() + .filter { backgroundBitmapCache[it.contentUri] == null } + .forEach { attachment -> + loadConversationMediaThumbnailBitmap( + contentResolver = context.contentResolver, + contentUri = attachment.contentUri.toUri(), + contentType = attachment.contentType, + size = IntSize( + width = PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX, + height = PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX, + ), + softenBitmap = true, + )?.let { bitmap -> + backgroundBitmapCache.put( + contentUri = attachment.contentUri, + bitmap = bitmap, + ) + } + } + } + + return backgroundBitmapCache +} + +private fun getConversationMediaReviewBackgroundSelection( + attachments: ImmutableList, + settledPage: Int, +): ConversationMediaReviewBackgroundSelection { + if (attachments.isEmpty()) { + return ConversationMediaReviewBackgroundSelection( + attachmentsToPrefetch = persistentListOf(), + ) + } + + val settledIndex = settledPage.coerceIn( + minimumValue = 0, + maximumValue = attachments.lastIndex, + ) + + val settledAttachment = attachments[settledIndex] + + val previousAttachment = attachments + .getOrNull(index = settledIndex - 1) + ?.takeIf { it.contentUri != settledAttachment.contentUri } + + val nextAttachment = attachments + .getOrNull(index = settledIndex + 1) + ?.takeIf { attachment -> + attachment.contentUri != settledAttachment.contentUri && + attachment.contentUri != previousAttachment?.contentUri + } + + val attachmentsToPrefetch = listOfNotNull( + settledAttachment, + previousAttachment, + nextAttachment, + ).toImmutableList() + + return ConversationMediaReviewBackgroundSelection( + attachmentsToPrefetch = attachmentsToPrefetch, + ) +} + +@Immutable +private data class ConversationMediaReviewBackgroundSelection( + val attachmentsToPrefetch: ImmutableList, +) + +@Immutable +private data class ConversationMediaReviewBackgroundState( + val settledBackgroundImageBitmap: ImageBitmap?, + val fallbackBackgroundColor: Color, +) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt new file mode 100644 index 00000000..c09d915c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt @@ -0,0 +1,29 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import android.graphics.Bitmap +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf + +@Stable +internal class ConversationMediaReviewBitmapCache { + private val cachedBackgroundBitmapsByContentUri = mutableStateMapOf() + + operator fun get(contentUri: String): Bitmap? { + return cachedBackgroundBitmapsByContentUri[contentUri] + } + + fun put(contentUri: String, bitmap: Bitmap) { + cachedBackgroundBitmapsByContentUri[contentUri] = bitmap + } + + fun removeInactive(activeContentUris: Set) { + cachedBackgroundBitmapsByContentUri + .keys + .asSequence() + .filterNot { it in activeContentUris } + .toSet() + .let { inactiveContentUris -> + cachedBackgroundBitmapsByContentUri -= inactiveContentUris + } + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt new file mode 100644 index 00000000..d5b8b4f4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -0,0 +1,332 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import com.android.messaging.R +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayBackgroundButton +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor +import kotlin.math.absoluteValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay + +private const val PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS = 160 + +@Composable +internal fun ConversationMediaReviewPageCard( + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + attachments: ImmutableList, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + shouldShowDeleteChip: Boolean, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + val pageCardState = rememberConversationMediaReviewPageCardState( + attachment = attachment, + attachments = attachments, + shouldShowDeleteChip = shouldShowDeleteChip, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + + ConversationMediaReviewPageCardContent( + attachment = attachment, + page = page, + pageHeight = pageHeight, + pageWidth = pageWidth, + pagerState = pagerState, + previewSize = previewSize, + contentState = pageCardState.contentState, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemoveClick = { pageCardState.markPendingRemoval() }, + ) +} + +@Composable +private fun rememberConversationMediaReviewPageCardState( + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + attachments: ImmutableList, + shouldShowDeleteChip: Boolean, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +): ConversationMediaReviewPageCardState { + var isPendingRemoval by remember(attachment.contentUri) { + mutableStateOf(false) + } + + val deleteChipVisibilityProgress by animateFloatAsState( + targetValue = when { + shouldShowDeleteChip && !isPendingRemoval -> 1f + else -> 0f + }, + animationSpec = tween(durationMillis = 120), + label = "reviewDeleteChipVisibility", + ) + + val removalVisibilityProgress by animateFloatAsState( + targetValue = when { + isPendingRemoval -> 0f + else -> 1f + }, + animationSpec = tween(durationMillis = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS), + label = "reviewPageRemovalVisibility", + ) + + val shouldClearReviewAfterRemoval = attachments.size == 1 + + LaunchedEffect(isPendingRemoval) { + if (!isPendingRemoval) { + return@LaunchedEffect + } + + delay(timeMillis = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS.toLong()) + onAttachmentRemove(attachment.contentUri) + + if (shouldClearReviewAfterRemoval) { + onClearReview() + } + } + + return ConversationMediaReviewPageCardState( + contentState = ConversationMediaReviewPageCardContentState( + isPreviewEnabled = !isPendingRemoval, + isDeleteChipVisible = deleteChipVisibilityProgress > 0f, + deleteChipVisibilityProgress = deleteChipVisibilityProgress, + removalVisibilityProgress = removalVisibilityProgress, + ), + markPendingRemoval = { + if (!isPendingRemoval) { + isPendingRemoval = true + } + }, + ) +} + +@Composable +private fun ConversationMediaReviewPageCardContent( + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + contentState: ConversationMediaReviewPageCardContentState, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentRemoveClick: () -> Unit, +) { + val pageCardModifier = Modifier + .fillMaxSize() + .padding(vertical = 4.dp) + .wrapContentSize(align = Alignment.Center) + .width(width = pageWidth) + .height(height = pageHeight) + .graphicsLayer { + val pageOffset = resolveReviewPageOffset( + page = page, + pagerState = pagerState, + ) + val pageScale = lerp( + start = 0.98f, + stop = 1f, + fraction = 1f - pageOffset, + ) + val removalScale = lerp( + start = 0.9f, + stop = 1f, + fraction = contentState.removalVisibilityProgress, + ) + alpha = contentState.removalVisibilityProgress + scaleX = pageScale * removalScale + scaleY = pageScale * removalScale + } + + Box( + modifier = pageCardModifier, + ) { + ConversationMediaReviewPreview( + modifier = Modifier + .fillMaxSize() + .clickable( + enabled = contentState.isPreviewEnabled, + onClick = { onAttachmentPreviewClick(attachment) }, + ), + attachment = attachment, + previewSize = previewSize, + ) + + if (contentState.isDeleteChipVisible) { + ConversationMediaReviewDeleteButton( + modifier = Modifier + .align(alignment = Alignment.TopEnd) + .zIndex(zIndex = 1f) + .padding( + top = 8.dp, + end = 8.dp, + ), + visibilityProgress = contentState.deleteChipVisibilityProgress, + onClick = onAttachmentRemoveClick, + ) + } + } +} + +@Composable +private fun ConversationMediaReviewPreview( + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + modifier: Modifier = Modifier, + previewSize: IntSize, +) { + val previewShape = RoundedCornerShape(28.dp) + + Surface( + modifier = modifier + .clip(previewShape), + shape = previewShape, + color = MaterialTheme + .colorScheme + .surfaceColorAtElevation(elevation = 6.dp) + .copy(alpha = 0.25f), + shadowElevation = 20.dp, + tonalElevation = 6.dp, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ConversationMediaThumbnail( + modifier = Modifier.fillMaxSize(), + contentUri = attachment.contentUri, + contentType = attachment.contentType, + size = previewSize, + contentScale = ContentScale.Crop, + backgroundColor = Color.Transparent, + ) + + if (attachment is ComposerAttachmentUiModel.Resolved.VisualMedia.Video) { + ConversationMediaReviewVideoBadge() + } + } + } +} + +@Composable +private fun ConversationMediaReviewVideoBadge( + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = pickerOverlayContainerColor(alpha = 0.5f), + ) { + Icon( + modifier = Modifier.padding(12.dp), + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = pickerOverlayContentColor(), + ) + } +} + +@Composable +private fun ConversationMediaReviewDeleteButton( + modifier: Modifier = Modifier, + visibilityProgress: Float, + onClick: () -> Unit, +) { + val scale = lerp( + start = 0.9f, + stop = 1f, + fraction = visibilityProgress, + ) + + PickerOverlayBackgroundButton( + modifier = modifier.graphicsLayer { + alpha = visibilityProgress + scaleX = scale + scaleY = scale + }, + containerColor = pickerOverlayContainerColor(alpha = 0.5f), + contentDescription = stringResource( + id = R.string.conversation_media_picker_remove_attachment_content_description, + ), + buttonSize = 32.dp, + iconSize = 18.dp, + imageVector = Icons.Rounded.Close, + onClick = onClick, + ) +} + +private fun resolveReviewPageOffset( + page: Int, + pagerState: PagerState, +): Float { + val rawPageOffset = when { + pagerState.isScrollInProgress -> { + pagerState.currentPage - page + pagerState.currentPageOffsetFraction + } + + else -> (pagerState.settledPage - page).toFloat() + } + + return rawPageOffset.absoluteValue.coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) +} + +@Immutable +private data class ConversationMediaReviewPageCardState( + val contentState: ConversationMediaReviewPageCardContentState, + val markPendingRemoval: () -> Unit, +) + +@Immutable +private data class ConversationMediaReviewPageCardContentState( + val isPreviewEnabled: Boolean, + val isDeleteChipVisible: Boolean, + val deleteChipVisibilityProgress: Float, + val removalVisibilityProgress: Float, +) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt new file mode 100644 index 00000000..2c10328e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -0,0 +1,207 @@ +package com.android.messaging.ui.conversation.mediapicker.component.review + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList + +internal data class ConversationMediaReviewPagerState( + val attachmentContentUris: ImmutableList, + val currentAttachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + val pagerState: PagerState, + val visibleDeleteChipPage: Int?, +) + +@Composable +internal fun rememberConversationMediaReviewPagerState( + attachments: ImmutableList, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, +): ConversationMediaReviewPagerState { + val attachmentContentUris = remember(attachments) { + attachments + .asSequence() + .map { it.contentUri } + .toImmutableList() + } + + val initiallyReviewedPage = resolveInitialReviewPage( + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + ) + + val pagerState = rememberPagerState( + initialPage = initiallyReviewedPage, + pageCount = { attachments.size }, + ) + + val reviewPagerCoordinator = remember { + ConversationMediaReviewPagerCoordinator( + initialReviewRequestSequence = reviewRequestSequence, + ) + } + val settledReviewPage = clampAttachmentPage( + page = pagerState.settledPage, + attachments = attachments, + ) + + LaunchedEffect( + attachmentContentUris, + initiallyReviewedContentUri, + photoPickerSourceContentUriByAttachmentContentUri, + reviewRequestSequence, + settledReviewPage, + ) { + reviewPagerCoordinator.syncTargetPage( + attachmentContentUris = attachmentContentUris, + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + reviewRequestSequence = reviewRequestSequence, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + pagerState = pagerState, + ) + } + val visibleDeleteChipPage = resolveVisibleDeleteChipPage( + attachments = attachments, + pagerState = pagerState, + ) + + return ConversationMediaReviewPagerState( + attachmentContentUris = attachmentContentUris, + currentAttachment = attachments[settledReviewPage], + pagerState = pagerState, + visibleDeleteChipPage = visibleDeleteChipPage, + ) +} + +private class ConversationMediaReviewPagerCoordinator( + initialReviewRequestSequence: Int, +) { + private var pendingRequestedReviewContentUri: String? = null + private var latestReviewRequestSequence: Int = initialReviewRequestSequence + + suspend fun syncTargetPage( + attachmentContentUris: List, + attachments: List, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + photoPickerSourceContentUriByAttachmentContentUri: Map, + pagerState: PagerState, + ) { + if (reviewRequestSequence != latestReviewRequestSequence) { + pendingRequestedReviewContentUri = initiallyReviewedContentUri + latestReviewRequestSequence = reviewRequestSequence + } + + val requestedAttachmentPage = resolveReviewedAttachmentPage( + attachmentContentUris = attachmentContentUris, + attachments = attachments, + requestedReviewContentUri = pendingRequestedReviewContentUri, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + ) + + val targetPage = requestedAttachmentPage ?: clampAttachmentPage( + page = pagerState.currentPage, + attachments = attachments, + ) + + if (pagerState.currentPage != targetPage) { + when { + requestedAttachmentPage != null -> { + pagerState.animateScrollToPage( + page = targetPage, + animationSpec = tween(durationMillis = 220), + ) + } + + else -> { + pagerState.scrollToPage(page = targetPage) + } + } + } + + if (requestedAttachmentPage != null) { + pendingRequestedReviewContentUri = null + } + } + + private fun resolveReviewedAttachmentPage( + attachmentContentUris: List, + attachments: List, + requestedReviewContentUri: String?, + photoPickerSourceContentUriByAttachmentContentUri: Map, + ): Int? { + if (requestedReviewContentUri == null) { + return null + } + + return attachmentContentUris + .indexOf(element = requestedReviewContentUri) + .takeIf { it >= 0 } + ?: attachments.indexOfFirst { attachment -> + photoPickerSourceContentUriByAttachmentContentUri[attachment.contentUri] == + requestedReviewContentUri + }.takeIf { it >= 0 } + } +} + +private fun resolveInitialReviewPage( + attachments: List, + initiallyReviewedContentUri: String?, + photoPickerSourceContentUriByAttachmentContentUri: Map, +): Int { + if (initiallyReviewedContentUri == null) { + return attachments.lastIndex + } + + return attachments + .indexOfFirst { attachment -> + attachment.contentUri == initiallyReviewedContentUri || + photoPickerSourceContentUriByAttachmentContentUri[attachment.contentUri] == + initiallyReviewedContentUri + } + .takeIf { it >= 0 } + ?: attachments.lastIndex +} + +private fun clampAttachmentPage( + page: Int, + attachments: List, +): Int { + return page.coerceIn( + minimumValue = 0, + maximumValue = attachments.lastIndex, + ) +} + +private fun resolveVisibleDeleteChipPage( + attachments: List, + pagerState: PagerState, +): Int? { + val clampedCurrentPage = clampAttachmentPage( + page = pagerState.currentPage, + attachments = attachments, + ) + + val clampedSettledPage = clampAttachmentPage( + page = pagerState.settledPage, + attachments = attachments, + ) + + return when { + !pagerState.isScrollInProgress -> clampedCurrentPage + clampedCurrentPage == clampedSettledPage -> null + else -> clampedCurrentPage + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt new file mode 100644 index 00000000..0d48e3f5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt @@ -0,0 +1,406 @@ +package com.android.messaging.ui.conversation.mediapicker.delegate + +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapper +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +internal interface ConversationMediaPickerDelegate { + val effects: Flow + val photoPickerSourceContentUriByAttachmentContentUri: StateFlow> + + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) + + fun onPhotoPickerMediaSelected(contentUris: List) + + fun onPhotoPickerMediaDeselected(contentUris: List) + + fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) + + fun onContactCardPicked(contactUri: String?) + + fun onRemovePendingAttachment(pendingAttachmentId: String) + + fun onRemoveResolvedAttachment(contentUri: String) + + fun onScreenCleared() +} + +internal class ConversationMediaPickerDelegateImpl @Inject constructor( + private val conversationDraftDelegate: ConversationDraftDelegate, + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, + private val conversationDraftAttachmentMapper: ConversationDraftAttachmentMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMediaPickerDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val photoPickerAttachmentLock = Any() + + private val pendingAttachmentJobs = mutableMapOf() + private val photoPickerContentUris = mutableSetOf() + private val attachmentContentUriByPhotoPickerContentUri = mutableMapOf() + private val photoPickerContentUriByAttachmentContentUri = mutableMapOf() + private val _photoPickerSourceContentUriByAttachmentContentUri = + MutableStateFlow>(persistentMapOf()) + + override val effects = _effects.asSharedFlow() + override val photoPickerSourceContentUriByAttachmentContentUri = + _photoPickerSourceContentUriByAttachmentContentUri.asStateFlow() + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + conversationIdFlow + .drop(count = 1) + .collect { + cancelPendingAttachmentJobs() + } + } + } + + override fun onPhotoPickerMediaSelected(contentUris: List) { + val claimedContentUris = claimNewPhotoPickerContentUris(contentUris = contentUris) + + if (claimedContentUris.isEmpty()) { + return + } + + if (!conversationDraftDelegate.tryStartAddingAttachment()) { + releasePhotoPickerContentUris(contentUris = claimedContentUris) + return + } + + launchPhotoPickerAttachmentResolution(contentUris = claimedContentUris) + } + + private fun claimNewPhotoPickerContentUris(contentUris: List): List { + return synchronized(photoPickerAttachmentLock) { + contentUris.filter { contentUri -> + contentUri.isNotBlank() && photoPickerContentUris.add(contentUri) + } + } + } + + private fun launchPhotoPickerAttachmentResolution(contentUris: List) { + boundScope?.launch(defaultDispatcher) { + conversationAttachmentsRepository + .createDraftAttachmentsFromPhotoPicker(contentUris = contentUris) + .catch { throwable -> + handlePhotoPickerAttachmentResolutionException( + contentUris = contentUris, + throwable = throwable, + ) + } + .collect { result -> + handlePhotoPickerAttachmentResult(result = result) + } + } + } + + private suspend fun handlePhotoPickerAttachmentResolutionException( + contentUris: List, + throwable: Throwable, + ) { + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Unable to resolve photo picker attachments", throwable) + + releasePhotoPickerContentUris(contentUris = contentUris) + emitAttachmentLoadFailedEffect() + } + + private suspend fun handlePhotoPickerAttachmentResult( + result: PhotoPickerDraftAttachmentResult, + ) { + when (result) { + is PhotoPickerDraftAttachmentResult.Resolved -> { + onPhotoPickerAttachmentResolved(result.photoPickerDraftAttachment) + } + + is PhotoPickerDraftAttachmentResult.Failed -> { + val wasSelected = releasePhotoPickerContentUri(result.sourceContentUri) + + if (wasSelected) { + emitAttachmentLoadFailedEffect() + } + } + } + } + + private fun onPhotoPickerAttachmentResolved( + photoPickerAttachment: PhotoPickerDraftAttachment, + ) { + val sourceContentUri = photoPickerAttachment.sourceContentUri + val draftAttachment = photoPickerAttachment.draftAttachment + + if (!isPhotoPickerContentUriSelected(contentUri = sourceContentUri)) { + deleteTemporaryAttachment(contentUri = draftAttachment.contentUri) + return + } + + val wasAddedToDraft = addDraftAttachmentIfAccepted(draftAttachment = draftAttachment) + val wasRegistered = wasAddedToDraft && registerPhotoPickerAttachmentIfStillSelected( + photoPickerAttachment = photoPickerAttachment, + ) + + if (!wasRegistered) { + discardUnregisteredPhotoPickerAttachment( + sourceContentUri = sourceContentUri, + attachmentContentUri = draftAttachment.contentUri, + wasAddedToDraft = wasAddedToDraft, + ) + } + } + + private fun isPhotoPickerContentUriSelected(contentUri: String): Boolean { + return synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.contains(contentUri) + } + } + + private fun addDraftAttachmentIfAccepted( + draftAttachment: ConversationDraftAttachment, + ): Boolean { + val acceptedAttachments = conversationDraftDelegate.addAttachments( + attachments = listOf(draftAttachment), + ) + + return acceptedAttachments.any { acceptedAttachment -> + acceptedAttachment.contentUri == draftAttachment.contentUri + } + } + + private fun registerPhotoPickerAttachmentIfStillSelected( + photoPickerAttachment: PhotoPickerDraftAttachment, + ): Boolean { + return synchronized(photoPickerAttachmentLock) { + if (!photoPickerContentUris.contains(photoPickerAttachment.sourceContentUri)) { + return@synchronized false + } + + registerPhotoPickerAttachment(photoPickerAttachment) + true + } + } + + private fun discardUnregisteredPhotoPickerAttachment( + sourceContentUri: String, + attachmentContentUri: String, + wasAddedToDraft: Boolean, + ) { + releasePhotoPickerContentUri(sourceContentUri) + + if (wasAddedToDraft) { + conversationDraftDelegate.removeAttachment(attachmentContentUri) + } + + deleteTemporaryAttachment(attachmentContentUri) + } + + private fun releasePhotoPickerContentUri(contentUri: String): Boolean { + return synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.remove(contentUri) + } + } + + private fun releasePhotoPickerContentUris(contentUris: List) { + synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.removeAll(contentUris.toSet()) + } + } + + private suspend fun emitAttachmentLoadFailedEffect() { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.fail_to_load_attachment, + ), + ) + } + + override fun onPhotoPickerMediaDeselected(contentUris: List) { + contentUris + .filter { it.isNotBlank() } + .forEach { photoPickerContentUri -> + val attachmentContentUri = synchronized(photoPickerAttachmentLock) { + val registeredContentUri = unregisterPhotoPickerAttachmentByPickerUri( + photoPickerContentUri = photoPickerContentUri, + ) + + photoPickerContentUris.remove(photoPickerContentUri) + + registeredContentUri + } ?: photoPickerContentUri + + conversationDraftDelegate.removeAttachment(attachmentContentUri) + deleteTemporaryAttachment(attachmentContentUri) + } + } + + override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { + val attachment = conversationDraftAttachmentMapper.map(capturedMedia) + + val acceptedAttachments = conversationDraftDelegate.addAttachments( + attachments = listOf(attachment), + ) + + val wasAccepted = acceptedAttachments.any { acceptedAttachment -> + acceptedAttachment.contentUri == attachment.contentUri + } + + if (!wasAccepted) { + deleteTemporaryAttachment(attachment.contentUri) + } + } + + override fun onContactCardPicked(contactUri: String?) { + val resolvedContactUri = contactUri?.takeIf { it.isNotBlank() } ?: return + + boundScope?.launch(defaultDispatcher) { + conversationAttachmentsRepository + .createDraftAttachmentFromContact(contactUri = resolvedContactUri) + .filterNotNull() + .map(::listOf) + .collect(conversationDraftDelegate::addAttachments) + } + } + + override fun onRemovePendingAttachment(pendingAttachmentId: String) { + synchronized(photoPickerAttachmentLock) { + pendingAttachmentJobs.remove(pendingAttachmentId) + }?.cancel() + + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) + } + + override fun onRemoveResolvedAttachment(contentUri: String) { + conversationDraftDelegate.removeAttachment(contentUri = contentUri) + + deleteTemporaryAttachment(contentUri = contentUri) + + synchronized(photoPickerAttachmentLock) { + unregisterPhotoPickerAttachmentByAttachmentUri( + attachmentContentUri = contentUri, + )?.also { photoPickerContentUri -> + photoPickerContentUris.remove(photoPickerContentUri) + } + } + } + + override fun onScreenCleared() { + cancelPendingAttachmentJobs() + } + + private fun cancelPendingAttachmentJobs() { + val jobs = synchronized(photoPickerAttachmentLock) { + val jobs = pendingAttachmentJobs.values.toList() + pendingAttachmentJobs.clear() + photoPickerContentUris.clear() + attachmentContentUriByPhotoPickerContentUri.clear() + photoPickerContentUriByAttachmentContentUri.clear() + publishPhotoPickerSourceContentUrisLocked() + + jobs + } + + jobs.forEach { it.cancel() } + } + + private fun registerPhotoPickerAttachment(photoPickerAttachment: PhotoPickerDraftAttachment) { + val sourceContentUri = photoPickerAttachment.sourceContentUri + val attachmentContentUri = photoPickerAttachment.draftAttachment.contentUri + + attachmentContentUriByPhotoPickerContentUri[sourceContentUri] = attachmentContentUri + photoPickerContentUriByAttachmentContentUri[attachmentContentUri] = sourceContentUri + publishPhotoPickerSourceContentUrisLocked() + } + + private fun unregisterPhotoPickerAttachmentByPickerUri( + photoPickerContentUri: String, + ): String? { + val attachmentContentUri = attachmentContentUriByPhotoPickerContentUri + .remove(photoPickerContentUri) + ?: return null + + photoPickerContentUriByAttachmentContentUri.remove(attachmentContentUri) + publishPhotoPickerSourceContentUrisLocked() + + return attachmentContentUri + } + + private fun unregisterPhotoPickerAttachmentByAttachmentUri( + attachmentContentUri: String, + ): String? { + val photoPickerContentUri = photoPickerContentUriByAttachmentContentUri + .remove(attachmentContentUri) + ?: return null + + attachmentContentUriByPhotoPickerContentUri.remove(photoPickerContentUri) + publishPhotoPickerSourceContentUrisLocked() + + return photoPickerContentUri + } + + private fun publishPhotoPickerSourceContentUrisLocked() { + _photoPickerSourceContentUriByAttachmentContentUri.value = + photoPickerContentUriByAttachmentContentUri.toPersistentMap() + } + + private fun deleteTemporaryAttachment(contentUri: String) { + boundScope?.launch(defaultDispatcher) { + conversationAttachmentsRepository + .deleteTemporaryAttachment(contentUri = contentUri) + .collect() + } + } + + private companion object { + private const val TAG = "ConversationMediaPickerDelegate" + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt new file mode 100644 index 00000000..5d6a6be2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt @@ -0,0 +1,34 @@ +package com.android.messaging.ui.conversation.mediapicker.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.data.media.model.ConversationMediaItem +import javax.inject.Inject + +internal interface ConversationDraftAttachmentMapper { + fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment + + fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment +} + +internal class ConversationDraftAttachmentMapperImpl @Inject constructor() : + ConversationDraftAttachmentMapper { + + override fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = mediaItem.contentType, + contentUri = mediaItem.contentUri, + width = mediaItem.width, + height = mediaItem.height, + ) + } + + override fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = capturedMedia.contentType, + contentUri = capturedMedia.contentUri, + width = capturedMedia.width, + height = capturedMedia.height, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt b/src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt new file mode 100644 index 00000000..d49702a8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.mediapicker.model + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat + +@Stable +internal class ConversationMediaPickerPermissionState( + context: Context, +) { + var audioPermissionGranted by mutableStateOf(value = hasAudioPermission(context = context)) + var cameraPermissionGranted by mutableStateOf(value = hasCameraPermission(context = context)) + + fun refresh(context: Context) { + audioPermissionGranted = hasAudioPermission(context = context) + cameraPermissionGranted = hasCameraPermission(context = context) + } +} + +private fun hasCameraPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.CAMERA, + ) +} + +private fun hasAudioPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.RECORD_AUDIO, + ) +} + +private fun isPermissionGranted( + context: Context, + permission: String, +): Boolean { + return ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt new file mode 100644 index 00000000..aab52801 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -0,0 +1,563 @@ +package com.android.messaging.ui.conversation.messages.delegate + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import com.android.messaging.R +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface ConversationMessageSelectionDelegate : + ConversationScreenDelegate { + val effects: Flow + + fun onMessageClick(messageId: String) + + fun onMessageDownloadClick(messageId: String) + + fun onMessageLongClick(messageId: String) + + fun onMessageResendClick(messageId: String) + + fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) + + fun dismissDeleteMessageConfirmation() + + fun dismissMessageSelection() + + fun confirmDeleteSelectedMessages() + + fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean +} + +internal class ConversationMessageSelectionDelegateImpl @Inject constructor( + private val checkConversationActionRequirements: CheckConversationActionRequirements, + private val clipboardManager: ClipboardManager, + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, + private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val createForwardedMessage: CreateForwardedMessage, + private val conversationsRepository: ConversationsRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMessageSelectionDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _state = MutableStateFlow(ConversationMessageSelectionUiState()) + private val messageSelectionState = MutableStateFlow( + ConversationMessageSelectionState(), + ) + + override val effects = _effects.asSharedFlow() + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + private var pendingDefaultSmsRoleResendMessageId: String? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + bindSelectionUiState(scope = scope) + bindConversationChanges( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + } + + override fun onMessageClick(messageId: String) { + if (state.value.isSelectionMode) { + toggleMessageSelection(messageId = messageId) + } + } + + override fun onMessageDownloadClick(messageId: String) { + conversationsRepository.downloadMessage(messageId = messageId) + } + + override fun onMessageLongClick(messageId: String) { + toggleMessageSelection(messageId = messageId) + } + + override fun onMessageResendClick(messageId: String) { + resendMessageWhenActionRequirementsSatisfied(messageId = messageId) + } + + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { + when (action) { + ConversationMessageSelectionAction.Copy -> { + copySelectedMessageText() + } + + ConversationMessageSelectionAction.Delete -> { + requestDeleteSelectedMessages() + } + + ConversationMessageSelectionAction.Details -> { + openSelectedMessageDetails() + } + + ConversationMessageSelectionAction.Download -> { + downloadSelectedMessage() + } + + ConversationMessageSelectionAction.Forward -> { + forwardSelectedMessage() + } + + ConversationMessageSelectionAction.Resend -> { + resendSelectedMessage() + } + + ConversationMessageSelectionAction.SaveAttachment -> { + saveSelectedMessageAttachments() + } + + ConversationMessageSelectionAction.Share -> { + shareSelectedMessage() + } + } + } + + override fun dismissDeleteMessageConfirmation() { + messageSelectionState.update { currentState -> + currentState.copy( + pendingDeleteMessageIds = persistentSetOf(), + ) + } + } + + override fun dismissMessageSelection() { + clearMessageSelection() + } + + override fun confirmDeleteSelectedMessages() { + val deleteConfirmation = state.value.deleteConfirmation ?: return + + clearMessageSelection() + conversationsRepository.deleteMessages( + messageIds = deleteConfirmation.messageIds, + ) + } + + override fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val messageId = pendingDefaultSmsRoleResendMessageId ?: return false + pendingDefaultSmsRoleResendMessageId = null + + if (resultCode == Activity.RESULT_OK) { + resendMessageWhenActionRequirementsSatisfied(messageId = messageId) + } + + return true + } + + private fun bindSelectionUiState(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + combine( + conversationMessagesDelegate.state, + messageSelectionState, + ) { messagesUiState, selectionState -> + buildMessageSelectionUiState( + messagesUiState = messagesUiState, + selectionState = selectionState, + ) + }.collect { selectionUiState -> + _state.value = selectionUiState + } + } + } + + private fun bindConversationChanges( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + scope.launch(defaultDispatcher) { + conversationIdFlow.collect { + clearMessageSelection() + } + } + } + + private fun clearMessageSelection() { + messageSelectionState.value = ConversationMessageSelectionState() + } + + private fun copySelectedMessageText() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + val text = selectedMessage.text?.takeIf(String::isNotBlank) ?: return + + clipboardManager.setPrimaryClip( + ClipData.newPlainText( + null, + text, + ), + ) + + clearMessageSelection() + } + + private fun downloadSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + conversationsRepository.downloadMessage(selectedMessage.messageId) + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) + } + } + + private fun forwardSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + + boundScope?.launch(defaultDispatcher) { + val forwardedMessage = createForwardedMessage( + conversationId = selectedMessage.conversationId, + messageId = selectedMessage.messageId, + ) ?: return@launch + + _effects.emit( + ConversationScreenEffect.LaunchForwardMessage( + message = forwardedMessage, + ), + ) + } + } + + private fun openSelectedMessageDetails() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + boundScope?.launch(defaultDispatcher) { + conversationsRepository + .getMessageDetailsData( + conversationId = selectedMessage.conversationId, + messageId = selectedMessage.messageId, + ) + ?.let { messageDetailsData -> + ConversationScreenEffect.ShowMessageDetails( + message = messageDetailsData.message, + participants = messageDetailsData.participants, + selfParticipant = messageDetailsData.selfParticipant, + ) + }?.let { effect -> + _effects.emit(effect) + } + } + } + + private fun requestDeleteSelectedMessages() { + val selectedMessageIds = state.value.selectedMessageIds + + if (selectedMessageIds.isEmpty()) { + return + } + + messageSelectionState.update { currentState -> + currentState.copy( + pendingDeleteMessageIds = selectedMessageIds, + ) + } + } + + private fun resendSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + + resendMessageWhenActionRequirementsSatisfied(messageId = selectedMessage.messageId) + } + + private fun resendMessageWhenActionRequirementsSatisfied(messageId: String) { + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + conversationsRepository.resendMessage( + messageId = messageId, + ) + } + + ConversationActionRequirementsResult.SmsNotCapable -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + ) + } + + ConversationActionRequirementsResult.NoPreferredSmsSim -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + ) + } + + ConversationActionRequirementsResult.MissingDefaultSmsRole -> { + pendingDefaultSmsRoleResendMessageId = messageId + + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = true, + ), + ) + } + } + } + + private fun singleSelectedMessageOrNull(): ConversationMessageUiModel? { + val messagesUiState = conversationMessagesDelegate.state.value + val selectedMessageIds = state + .value + .selectedMessageIds + .takeIf { it.size == 1 } + ?: return null + + return when (messagesUiState) { + is ConversationMessagesUiState.Present -> { + messagesUiState.messages.firstOrNull { message -> + message.messageId == selectedMessageIds.first() + } + } + + ConversationMessagesUiState.Loading -> null + } + } + + private fun saveSelectedMessageAttachments() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + val attachments = selectedMessage.parts + .asSequence() + .filterIsInstance() + .filterNot { it.contentType.isBlank() } + .mapNotNull { attachment -> + when (val contentUri = attachment.contentUri) { + null -> null + + else -> { + AttachmentToSave( + contentType = attachment.contentType, + contentUri = contentUri.toString(), + ) + } + } + } + .toList() + + clearMessageSelection() + + if (attachments.isEmpty()) { + return + } + + boundScope?.launch(defaultDispatcher) { + conversationAttachmentsRepository + .saveAttachmentsToMediaStore(attachments = attachments) + .collect { result -> + _effects.emit( + ConversationScreenEffect.ShowSaveAttachmentsResult( + imageCount = result.imageCount, + videoCount = result.videoCount, + otherCount = result.otherCount, + failCount = result.failCount, + ), + ) + } + } + } + + private fun shareSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + val messageText = selectedMessage.text?.takeIf(String::isNotBlank) + + val firstAttachment = when { + messageText != null -> null + else -> { + selectedMessage.parts + .asSequence() + .mapNotNull { part -> + part as? ConversationMessagePartUiModel.Attachment + } + .firstOrNull { attachment -> + attachment.contentType.isNotBlank() && attachment.contentUri != null + } + } + } + + clearMessageSelection() + emitEffect( + effect = ConversationScreenEffect.ShareMessage( + attachmentContentType = firstAttachment?.contentType, + attachmentContentUri = firstAttachment?.contentUri?.toString(), + text = messageText, + ), + ) + } + + private fun toggleMessageSelection(messageId: String) { + if (messageId.isBlank()) { + return + } + + val selectedMessageIds = state.value.selectedMessageIds + + val updatedMessageIds = when { + selectedMessageIds.contains(messageId) -> { + (selectedMessageIds - messageId).toImmutableSet() + } + + else -> { + (selectedMessageIds + messageId).toImmutableSet() + } + } + + messageSelectionState.update { currentState -> + currentState.copy( + selectedMessageIds = updatedMessageIds, + pendingDeleteMessageIds = persistentSetOf(), + ) + } + } + + private fun buildMessageSelectionUiState( + messagesUiState: ConversationMessagesUiState, + selectionState: ConversationMessageSelectionState, + ): ConversationMessageSelectionUiState { + val messages = when (messagesUiState) { + is ConversationMessagesUiState.Present -> messagesUiState.messages + ConversationMessagesUiState.Loading -> return ConversationMessageSelectionUiState() + } + + val messagesById = messages.associateBy(ConversationMessageUiModel::messageId) + val currentMessageIds = messagesById.keys + + val selectedMessageIds = selectionState + .selectedMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() + + val pendingDeleteMessageIds = selectionState + .pendingDeleteMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() + + val selectedMessage = when (selectedMessageIds.size) { + 1 -> messagesById[selectedMessageIds.first()] + else -> null + } + + return ConversationMessageSelectionUiState( + selectedMessageIds = selectedMessageIds, + availableActions = availableSelectionActions( + selectedMessage = selectedMessage, + selectedMessageCount = selectedMessageIds.size, + ), + deleteConfirmation = pendingDeleteMessageIds + .takeIf { messageIds -> + messageIds.isNotEmpty() + } + ?.let { messageIds -> + ConversationMessageDeleteConfirmationUiState( + messageIds = messageIds, + ) + }, + ) + } + + private fun availableSelectionActions( + selectedMessage: ConversationMessageUiModel?, + selectedMessageCount: Int, + ): ImmutableSet { + return when { + selectedMessageCount <= 0 -> persistentSetOf() + selectedMessageCount > 1 || selectedMessage == null -> { + persistentSetOf( + ConversationMessageSelectionAction.Delete, + ) + } + + else -> { + availableSingleMessageSelectionActions(selectedMessage = selectedMessage) + } + } + } + + private fun availableSingleMessageSelectionActions( + selectedMessage: ConversationMessageUiModel, + ): ImmutableSet { + val actions = LinkedHashSet() + + if (selectedMessage.canDownloadMessage) { + actions += ConversationMessageSelectionAction.Download + } + + if (selectedMessage.canResendMessage) { + actions += ConversationMessageSelectionAction.Resend + } + + actions += ConversationMessageSelectionAction.Delete + + if (selectedMessage.canForwardMessage) { + actions += ConversationMessageSelectionAction.Share + actions += ConversationMessageSelectionAction.Forward + } + + if (selectedMessage.canSaveAttachments) { + actions += ConversationMessageSelectionAction.SaveAttachment + } + + if (selectedMessage.canCopyMessageToClipboard) { + actions += ConversationMessageSelectionAction.Copy + } + + actions += ConversationMessageSelectionAction.Details + + return actions.toImmutableSet() + } +} + +private data class ConversationMessageSelectionState( + val selectedMessageIds: ImmutableSet = persistentSetOf(), + val pendingDeleteMessageIds: ImmutableSet = persistentSetOf(), +) diff --git a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt new file mode 100644 index 00000000..a6a0effa --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt @@ -0,0 +1,196 @@ +package com.android.messaging.ui.conversation.messages.delegate + +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +internal interface ConversationMessagesDelegate : + ConversationScreenDelegate + +internal class ConversationMessagesDelegateImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, + private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMessagesDelegate { + + private val _state = MutableStateFlow( + value = ConversationMessagesUiState.Loading, + ) + + override val state = _state.asStateFlow() + + private var isBound = false + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (isBound) { + return + } + + isBound = true + + scope.launch(defaultDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + _state.value = ConversationMessagesUiState.Loading + + if (conversationId == null) { + return@collectLatest + } + + observeConversationMessagesUiState( + conversationId = conversationId, + ).collect { currentMessagesUiState -> + _state.value = currentMessagesUiState + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeConversationMessagesUiState( + conversationId: String, + ): Flow { + return conversationsRepository + .getConversationMessages(conversationId = conversationId) + .map { messages -> + messages + .asSequence() + .map(conversationMessageUiModelMapper::map) + .toImmutableList() + } + .flatMapLatest { messages -> + observeConversationMessagesUiState( + messages = messages, + ) + } + .flowOn(defaultDispatcher) + } + + private fun observeConversationMessagesUiState( + messages: List, + ): Flow { + val vCardContentUris = messages + .asSequence() + .flatMap { message -> message.parts.asSequence() } + .mapNotNull { part -> + (part as? ConversationMessagePartUiModel.Attachment.VCard) + ?.contentUri + ?.toString() + } + .distinct() + .toList() + + if (vCardContentUris.isEmpty()) { + return flowOf( + ConversationMessagesUiState.Present( + messages = messages.toImmutableList(), + ), + ) + } + + val vCardMetadataFlows = vCardContentUris.map { contentUri -> + conversationVCardMetadataRepository + .observeAttachmentMetadata(contentUri = contentUri) + .map { metadata -> + contentUri to metadata + } + } + + return combine(flows = vCardMetadataFlows) { contentUriAndMetadata -> + val vCardAttachmentMetadata = contentUriAndMetadata.associate { pair -> + pair.first to pair.second + } + + ConversationMessagesUiState.Present( + messages = updateMessagesWithVCardUiModel( + messages = messages, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ), + ) + } + } + + private fun updateMessagesWithVCardUiModel( + messages: List, + vCardAttachmentMetadata: Map, + ): ImmutableList { + return messages + .map { message -> + updateMessageUiModelWithVCardUiModel( + message = message, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .toImmutableList() + } + + private fun updateMessageUiModelWithVCardUiModel( + message: ConversationMessageUiModel, + vCardAttachmentMetadata: Map, + ): ConversationMessageUiModel { + return message.copy( + parts = message.parts.map { part -> + updateMessagePartUiModelWithVCardUiModel( + part = part, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + }, + ) + } + + private fun updateMessagePartUiModelWithVCardUiModel( + part: ConversationMessagePartUiModel, + vCardAttachmentMetadata: Map, + ): ConversationMessagePartUiModel { + return when (part) { + is ConversationMessagePartUiModel.Attachment.VCard -> { + val contentUri = part.contentUri?.toString() + val metadata = contentUri?.let(vCardAttachmentMetadata::get) + + part.copy( + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = metadata, + ), + ) + } + + is ConversationMessagePartUiModel.Attachment.Audio, + is ConversationMessagePartUiModel.Attachment.File, + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.Video, + is ConversationMessagePartUiModel.Text, + -> { + part + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt new file mode 100644 index 00000000..742ebb65 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt @@ -0,0 +1,244 @@ +package com.android.messaging.ui.conversation.messages.mapper + +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import javax.inject.Inject + +internal interface ConversationMessageUiModelMapper { + fun map(data: ConversationMessageData): ConversationMessageUiModel +} + +internal class ConversationMessageUiModelMapperImpl @Inject constructor( + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, +) : ConversationMessageUiModelMapper { + + override fun map(data: ConversationMessageData): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = data.messageId ?: "", + conversationId = data.conversationId ?: "", + text = data.text, + parts = data.parts?.map(::mapPart) ?: emptyList(), + sentTimestamp = data.sentTimeStamp, + receivedTimestamp = data.receivedTimeStamp, + displayTimestamp = conversationMessageDisplayTimestamp( + sentTimestamp = data.sentTimeStamp, + receivedTimestamp = data.receivedTimeStamp, + isIncoming = data.isIncoming, + ), + status = mapStatus(data.status), + isIncoming = data.isIncoming, + senderDisplayName = data.senderDisplayName, + senderAvatarUri = data.senderProfilePhotoUri, + senderContactId = data.senderContactId, + senderContactLookupKey = data.senderContactLookupKey, + senderNormalizedDestination = data.senderNormalizedDestination + ?.takeIf { it.isNotBlank() }, + senderParticipantId = data.participantId?.takeIf { it.isNotBlank() }, + selfParticipantId = data.selfParticipantId?.takeIf { it.isNotBlank() }, + canClusterWithPrevious = data.canClusterWithPreviousMessage, + canClusterWithNext = data.canClusterWithNextMessage, + canCopyMessageToClipboard = data.canCopyMessageToClipboard, + canDownloadMessage = data.showDownloadMessage, + canForwardMessage = data.canForwardMessage, + canResendMessage = data.showResendMessage, + canSaveAttachments = canSaveAttachments(data), + mmsDownload = mapMmsDownload(data = data), + mmsSubject = data.mmsSubject, + protocol = mapProtocol(data), + ) + } + + private fun mapMmsDownload(data: ConversationMessageData): MmsDownloadUiModel? { + val state = when (data.status) { + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD -> { + MmsDownloadUiModel.State.AwaitingManualDownload + } + + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD, + -> { + MmsDownloadUiModel.State.Downloading + } + + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> { + MmsDownloadUiModel.State.DownloadFailed + } + + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> { + MmsDownloadUiModel.State.ExpiredOrUnavailable + } + + else -> null + } + + return state?.let { + MmsDownloadUiModel( + state = state, + sizeBytes = data.smsMessageSize.toLong(), + expiryTimestamp = data.mmsExpiry, + ) + } + } + + private fun canSaveAttachments(data: ConversationMessageData): Boolean { + return when (val parts = data.parts) { + null -> false + + else -> { + parts.any { part -> + !part.contentType.isNullOrBlank() && + part.contentUri != null && + !ContentType.isTextType(part.contentType) + } + } + } + } + + private fun mapPart(part: MessagePartData): ConversationMessagePartUiModel { + val contentType = part.contentType ?: "" + + return when { + ContentType.isTextType(contentType) -> { + ConversationMessagePartUiModel.Text( + text = part.text.orEmpty(), + ) + } + + ContentType.isAudioType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Audio( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + ContentType.isImageType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Image( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + ContentType.isVCardType(contentType) -> { + ConversationMessagePartUiModel.Attachment.VCard( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = null, + ), + ) + } + + ContentType.isVideoType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Video( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + else -> { + ConversationMessagePartUiModel.Attachment.File( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + } + } + + @Suppress("CyclomaticComplexMethod") + private fun mapStatus(javaStatus: Int): Status { + return when (javaStatus) { + MessageData.BUGLE_STATUS_UNKNOWN -> Status.Unknown + + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE -> Status.Outgoing.Complete + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED -> Status.Outgoing.Delivered + MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> Status.Outgoing.Draft + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> Status.Outgoing.YetToSend + MessageData.BUGLE_STATUS_OUTGOING_SENDING -> Status.Outgoing.Sending + MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> Status.Outgoing.Resending + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> Status.Outgoing.AwaitingRetry + MessageData.BUGLE_STATUS_OUTGOING_FAILED -> Status.Outgoing.Failed + MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER -> + Status.Outgoing.FailedEmergencyNumber + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE -> Status.Incoming.Complete + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD -> + Status.Incoming.YetToManualDownload + + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD -> + Status.Incoming.RetryingManualDownload + + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING -> + Status.Incoming.ManualDownloading + + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD -> + Status.Incoming.RetryingAutoDownload + + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING -> Status.Incoming.AutoDownloading + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> Status.Incoming.DownloadFailed + + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> + Status.Incoming.ExpiredOrNotAvailable + + else -> { + LogUtil.e(LOG_TAG, "mapStatus: unexpected value=$javaStatus") + + Status.Unknown + } + } + } + + private fun mapProtocol(data: ConversationMessageData): ConversationMessageUiModel.Protocol { + return when { + data.isSms -> ConversationMessageUiModel.Protocol.SMS + data.isMmsNotification -> ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION + data.isMms -> ConversationMessageUiModel.Protocol.MMS + else -> ConversationMessageUiModel.Protocol.UNKNOWN + } + } + + private fun conversationMessageDisplayTimestamp( + sentTimestamp: Long, + receivedTimestamp: Long, + isIncoming: Boolean, + ): Long { + val primaryTimestamp = when { + isIncoming -> receivedTimestamp + else -> sentTimestamp + } + + return when { + primaryTimestamp > 0L -> primaryTimestamp + isIncoming -> sentTimestamp + else -> receivedTimestamp + } + } + + private companion object { + private const val LOG_TAG = "ConversationMessageUiModelMapper" + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt new file mode 100644 index 00000000..29058b0f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt @@ -0,0 +1,18 @@ +package com.android.messaging.ui.conversation.messages.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationAttachmentOpenAction { + + @Immutable + data class OpenContent( + val contentType: String, + val contentUri: String, + ) : ConversationAttachmentOpenAction + + @Immutable + data class OpenExternal( + val uri: String, + ) : ConversationAttachmentOpenAction +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt new file mode 100644 index 00000000..f0d2e29f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt @@ -0,0 +1,27 @@ +package com.android.messaging.ui.conversation.messages.model.attachment + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationAttachmentSections( + val galleryVisualAttachments: ImmutableList, + val trailingItems: ImmutableList, +) + +@Immutable +internal sealed interface ConversationAttachmentItem { + val key: String + + @Immutable + data class StandaloneVisual( + override val key: String, + val attachment: ConversationMessageAttachment, + ) : ConversationAttachmentItem + + @Immutable + data class Inline( + override val key: String, + val attachment: ConversationInlineAttachment, + ) : ConversationAttachmentItem +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt new file mode 100644 index 00000000..d26d5e92 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt @@ -0,0 +1,41 @@ +package com.android.messaging.ui.conversation.messages.model.attachment + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType + +@Immutable +internal sealed interface ConversationInlineAttachment { + val key: String + val openAction: ConversationAttachmentOpenAction? + + @Immutable + data class Audio( + override val key: String, + val contentUri: String, + override val openAction: ConversationAttachmentOpenAction?, + val titleText: String?, + val titleTextResId: Int?, + ) : ConversationInlineAttachment + + @Immutable + data class File( + override val key: String, + override val openAction: ConversationAttachmentOpenAction?, + val subtitleTextResId: Int?, + val titleText: String?, + val titleTextResId: Int?, + ) : ConversationInlineAttachment + + @Immutable + data class VCard( + override val key: String, + val contentUri: String, + override val openAction: ConversationAttachmentOpenAction?, + val type: ConversationVCardAttachmentType, + val avatarUri: String?, + val titleText: String?, + val titleTextResId: Int?, + val subtitleText: String?, + val subtitleTextResId: Int?, + ) : ConversationInlineAttachment +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt new file mode 100644 index 00000000..9ff880a3 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation.messages.model.attachment + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel + +@Immutable +internal sealed interface ConversationMessageAttachment { + val key: String + + @Immutable + data class Media( + override val key: String, + val part: ConversationMessagePartUiModel.Attachment, + ) : ConversationMessageAttachment + + @Immutable + data class Unsupported( + override val key: String, + val part: ConversationMessagePartUiModel.Attachment, + ) : ConversationMessageAttachment + + @Immutable + data class YouTubePreview( + override val key: String, + val sourceUrl: String, + val thumbnailUrl: String, + ) : ConversationMessageAttachment +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt new file mode 100644 index 00000000..1452d37c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationMessageContent( + val subjectText: String?, + val bodyText: String?, + val attachments: ImmutableList, + val attachmentSections: ConversationAttachmentSections, + val isAttachmentOnly: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt new file mode 100644 index 00000000..57911971 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt @@ -0,0 +1,74 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import android.net.Uri +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel + +@Immutable +internal sealed interface ConversationMessagePartUiModel { + val text: String? + + @Immutable + data class Text( + override val text: String, + ) : ConversationMessagePartUiModel + + val hasCaptionText: Boolean + get() { + return !text.isNullOrBlank() + } + + @Immutable + sealed interface Attachment : ConversationMessagePartUiModel { + val contentType: String + val contentUri: Uri? + val width: Int + val height: Int + + @Immutable + data class Audio( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment + + @Immutable + data class File( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment + + @Immutable + data class Image( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment + + @Immutable + data class VCard( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + val vCardUiModel: ConversationVCardAttachmentUiModel, + ) : Attachment + + @Immutable + data class Video( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt new file mode 100644 index 00000000..69487b43 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt @@ -0,0 +1,78 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.android.messaging.datamodel.data.ParticipantData + +@Immutable +internal data class ConversationMessageUiModel( + val messageId: String, + val conversationId: String, + val text: String?, + val parts: List, + val sentTimestamp: Long, + val receivedTimestamp: Long, + val displayTimestamp: Long, + val status: Status, + val isIncoming: Boolean, + val senderDisplayName: String?, + val senderAvatarUri: Uri?, + val senderContactId: Long, + val senderContactLookupKey: String?, + val senderNormalizedDestination: String?, + val senderParticipantId: String?, + val selfParticipantId: String?, + val canClusterWithPrevious: Boolean, + val canClusterWithNext: Boolean, + val canCopyMessageToClipboard: Boolean, + val canDownloadMessage: Boolean, + val canForwardMessage: Boolean, + val canResendMessage: Boolean, + val canSaveAttachments: Boolean, + val mmsDownload: MmsDownloadUiModel?, + val mmsSubject: String?, + val protocol: Protocol, +) { + + val canShowContactCard: Boolean + get() { + return senderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED || + !senderNormalizedDestination.isNullOrBlank() + } + + @Stable + sealed interface Status { + data object Unknown : Status + + sealed interface Outgoing : Status { + data object Complete : Outgoing + data object Delivered : Outgoing + data object Draft : Outgoing + data object YetToSend : Outgoing + data object Sending : Outgoing + data object Resending : Outgoing + data object AwaitingRetry : Outgoing + data object Failed : Outgoing + data object FailedEmergencyNumber : Outgoing + } + + sealed interface Incoming : Status { + data object Complete : Incoming + data object YetToManualDownload : Incoming + data object RetryingManualDownload : Incoming + data object ManualDownloading : Incoming + data object RetryingAutoDownload : Incoming + data object AutoDownloading : Incoming + data object DownloadFailed : Incoming + data object ExpiredOrNotAvailable : Incoming + } + } + + enum class Protocol { + UNKNOWN, + SMS, + MMS, + MMS_PUSH_NOTIFICATION, + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt new file mode 100644 index 00000000..9ac1a199 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt @@ -0,0 +1,17 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal sealed interface ConversationMessagesUiState { + + @Immutable + data object Loading : ConversationMessagesUiState + + @Immutable + data class Present( + val messages: ImmutableList = persistentListOf(), + ) : ConversationMessagesUiState +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt new file mode 100644 index 00000000..0f7289ef --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt @@ -0,0 +1,17 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class MmsDownloadUiModel( + val state: State, + val sizeBytes: Long, + val expiryTimestamp: Long, +) { + enum class State { + AwaitingManualDownload, + Downloading, + DownloadFailed, + ExpiredOrUnavailable, + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt b/src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt new file mode 100644 index 00000000..72de8f97 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt @@ -0,0 +1,10 @@ +package com.android.messaging.ui.conversation.messages.model.text + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationTextLink( + val start: Int, + val end: Int, + val url: String, +) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt new file mode 100644 index 00000000..8af66308 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -0,0 +1,413 @@ +package com.android.messaging.ui.conversation.messages.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageItemTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.message.ConversationMessage +import com.android.messaging.ui.conversation.messages.ui.message.conversationMessageDisplayEpochDay +import com.android.messaging.ui.conversation.messages.ui.message.formatDateSeparatorText +import com.android.messaging.ui.conversation.messages.ui.message.resolveConversationMessageSimDisplayName +import com.android.messaging.ui.conversation.resolveDisplayName +import java.util.TimeZone +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableMap + +private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 24.dp, +) + +private val CONVERSATION_MESSAGES_CLUSTER_TOP_PADDING = 2.dp +private val CONVERSATION_MESSAGES_GROUP_TOP_PADDING = 12.dp +private val CONVERSATION_MESSAGES_SEPARATOR_SPACING = 12.dp +private val CONVERSATION_MESSAGES_SEPARATOR_PADDING = PaddingValues( + horizontal = 14.dp, + vertical = 6.dp, +) + +private enum class ConversationMessagesItemContentType { + Message, + MessageWithDateSeparator, +} + +@Composable +internal fun ConversationMessages( + modifier: Modifier = Modifier, + messages: ImmutableList, + listState: LazyListState, + selectedMessageIds: ImmutableSet = persistentSetOf(), + showIncomingParticipantIdentity: Boolean = true, + subscriptions: ImmutableList = persistentListOf(), + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit = {}, +) { + val configuration = LocalConfiguration.current + val displayMessages = remember(messages) { + messages.asReversed() + } + val timeZone = remember(configuration) { + TimeZone.getDefault() + } + val simDisplayNameByParticipantId = rememberSimDisplayNameByParticipantId( + subscriptions = subscriptions, + ) + + LazyColumn( + state = listState, + reverseLayout = true, + modifier = modifier + .fillMaxSize() + .testTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .background(color = MaterialTheme.colorScheme.background), + contentPadding = CONVERSATION_MESSAGES_CONTENT_PADDING, + ) { + itemsIndexed( + items = displayMessages, + key = { _, message -> message.messageId }, + contentType = { index, _ -> + conversationMessagesItemContentType( + messages = displayMessages, + index = index, + timeZone = timeZone, + ) + }, + ) { index, message -> + ConversationMessagesItem( + message = message, + messageAbove = messageAboveCurrent( + messages = displayMessages, + index = index, + ), + messageBelow = messageBelowCurrent( + messages = displayMessages, + index = index, + ), + isSelectionMode = selectedMessageIds.isNotEmpty(), + isSelected = selectedMessageIds.contains(message.messageId), + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + simDisplayNameByParticipantId = simDisplayNameByParticipantId, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } + } +} + +@Composable +private fun rememberSimDisplayNameByParticipantId( + subscriptions: ImmutableList, +): ImmutableMap { + val resources = LocalResources.current + + return remember(subscriptions, resources) { + subscriptions + .associate { subscription -> + subscription.selfParticipantId to subscription.label.resolveDisplayName( + resources = resources, + ) + } + .toImmutableMap() + } +} + +@Immutable +private data class ConversationMessagesItemPresentation( + val showDateSeparator: Boolean, + val dateSeparatorText: String?, + val topPadding: Dp, +) + +private fun conversationMessagesItemContentType( + messages: List, + index: Int, + timeZone: TimeZone, +): ConversationMessagesItemContentType { + val shouldShowDateSeparator = shouldShowDateSeparator( + currentMessage = messages[index], + messageAbove = messageAboveCurrent( + messages = messages, + index = index, + ), + timeZone = timeZone, + ) + + return when { + shouldShowDateSeparator -> ConversationMessagesItemContentType.MessageWithDateSeparator + else -> ConversationMessagesItemContentType.Message + } +} + +private fun messageAboveCurrent( + messages: List, + index: Int, +): ConversationMessageUiModel? { + return messages.getOrNull(index + 1) +} + +private fun messageBelowCurrent( + messages: List, + index: Int, +): ConversationMessageUiModel? { + return messages.getOrNull(index - 1) +} + +@Composable +private fun ConversationMessagesItem( + message: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel?, + messageBelow: ConversationMessageUiModel?, + isSelectionMode: Boolean, + isSelected: Boolean, + showIncomingParticipantIdentity: Boolean, + simDisplayNameByParticipantId: ImmutableMap, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit, +) { + val presentation = rememberConversationMessagesItemPresentation( + message = message, + messageAbove = messageAbove, + ) + + val simDisplayName = remember(message, messageBelow, simDisplayNameByParticipantId) { + resolveConversationMessageSimDisplayName( + message = message, + messageBelow = messageBelow, + simDisplayNameByParticipantId = simDisplayNameByParticipantId, + ) + } + + ColumnWithSeparator( + showDateSeparator = presentation.showDateSeparator, + dateSeparatorText = presentation.dateSeparatorText, + ) { + ConversationMessage( + modifier = Modifier + .testTag(conversationMessageItemTestTag(messageId = message.messageId)) + .padding(top = presentation.topPadding), + isSelected = isSelected, + isSelectionMode = isSelectionMode, + message = message, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = { + onMessageClick(message.messageId) + }, + onMessageAvatarClick = { + onMessageAvatarClick(message.messageId) + }, + onMessageDownloadClick = { + onMessageDownloadClick(message.messageId) + }, + onMessageLongClick = { + onMessageLongClick(message.messageId) + }, + onMessageResendClick = { + onMessageResendClick(message.messageId) + }, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + +@Composable +private fun rememberConversationMessagesItemPresentation( + message: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel?, +): ConversationMessagesItemPresentation { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val timeZone = remember(configuration) { + TimeZone.getDefault() + } + + val showDateSeparator = remember( + timeZone, + message.displayTimestamp, + messageAbove?.displayTimestamp, + ) { + shouldShowDateSeparator( + currentMessage = message, + messageAbove = messageAbove, + timeZone = timeZone, + ) + } + + val dateSeparatorText = remember( + context, + configuration, + showDateSeparator, + message.displayTimestamp, + ) { + if (!showDateSeparator) { + null + } else { + formatDateSeparatorText( + context = context, + message = message, + ) + } + } + + val topPadding = remember( + showDateSeparator, + messageAbove, + message.canClusterWithPrevious, + ) { + messageItemTopPadding( + message = message, + messageAbove = messageAbove, + showDateSeparator = showDateSeparator, + ) + } + + return remember( + showDateSeparator, + dateSeparatorText, + topPadding, + ) { + ConversationMessagesItemPresentation( + showDateSeparator = showDateSeparator, + dateSeparatorText = dateSeparatorText, + topPadding = topPadding, + ) + } +} + +private fun messageItemTopPadding( + message: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel?, + showDateSeparator: Boolean, +): Dp { + return when { + messageAbove == null || showDateSeparator -> 0.dp + message.canClusterWithPrevious -> CONVERSATION_MESSAGES_CLUSTER_TOP_PADDING + else -> CONVERSATION_MESSAGES_GROUP_TOP_PADDING + } +} + +@Composable +private fun ColumnWithSeparator( + showDateSeparator: Boolean, + dateSeparatorText: String?, + content: @Composable () -> Unit, +) { + val verticalSpace = when { + showDateSeparator -> CONVERSATION_MESSAGES_SEPARATOR_SPACING + else -> 0.dp + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = verticalSpace), + ) { + if (showDateSeparator && dateSeparatorText != null) { + ConversationDateSeparator( + text = dateSeparatorText, + ) + } + + content() + } +} + +@Composable +private fun ConversationDateSeparator( + text: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(CONVERSATION_MESSAGES_SEPARATOR_PADDING), + ) + } +} + +private fun shouldShowDateSeparator( + currentMessage: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel?, + timeZone: TimeZone, +): Boolean { + return when (messageAbove) { + null -> true + + else -> { + shouldShowDateSeparatorBetweenMessages( + currentMessage = currentMessage, + messageAbove = messageAbove, + timeZone = timeZone, + ) + } + } +} + +private fun shouldShowDateSeparatorBetweenMessages( + currentMessage: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel, + timeZone: TimeZone, +): Boolean { + val currentEpochDay = conversationMessageDisplayEpochDay( + displayTimestamp = currentMessage.displayTimestamp, + timeZone = timeZone, + ) ?: return false + + val messageAboveEpochDay = conversationMessageDisplayEpochDay( + displayTimestamp = messageAbove.displayTimestamp, + timeZone = timeZone, + ) + + return messageAboveEpochDay != currentEpochDay +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt new file mode 100644 index 00000000..0f091060 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt @@ -0,0 +1,50 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment + +internal fun dispatchConversationAttachmentOpenAction( + action: ConversationAttachmentOpenAction, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + when (action) { + is ConversationAttachmentOpenAction.OpenContent -> { + onAttachmentClick( + action.contentType, + action.contentUri, + ) + } + + is ConversationAttachmentOpenAction.OpenExternal -> { + onExternalUriClick(action.uri) + } + } +} + +internal fun ConversationMessageAttachment.toConversationAttachmentOpenActionOrNull(): + ConversationAttachmentOpenAction? { + return when (this) { + is ConversationMessageAttachment.Media -> { + ConversationAttachmentOpenAction.OpenContent( + contentType = part.contentType, + contentUri = part.contentUri.toString(), + ) + } + + is ConversationMessageAttachment.Unsupported -> { + part.contentUri?.let { contentUri -> + ConversationAttachmentOpenAction.OpenContent( + contentType = part.contentType, + contentUri = contentUri.toString(), + ) + } + } + + is ConversationMessageAttachment.YouTubePreview -> { + ConversationAttachmentOpenAction.OpenExternal( + uri = sourceUrl, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt new file mode 100644 index 00000000..7df42dc8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -0,0 +1,216 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import com.android.messaging.R +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal fun buildConversationAttachmentSections( + attachments: ImmutableList, + vCardSubtitleTextResIdOverride: Int? = null, +): ConversationAttachmentSections { + val galleryVisualAttachments = attachments + .asSequence() + .filter(::isGalleryVisualAttachment) + .toImmutableList() + + val trailingItems = attachments + .asSequence() + .filterNot(::isGalleryVisualAttachment) + .mapNotNull { attachment -> + toConversationAttachmentItem( + attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + ) + } + .toImmutableList() + + return ConversationAttachmentSections( + galleryVisualAttachments = galleryVisualAttachments, + trailingItems = trailingItems, + ) +} + +private fun isGalleryVisualAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media -> { + attachment.part is ConversationMessagePartUiModel.Attachment.Image + } + + is ConversationMessageAttachment.YouTubePreview -> true + is ConversationMessageAttachment.Unsupported -> false + } +} + +private fun isStandaloneVisualAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media -> + attachment.part is ConversationMessagePartUiModel.Attachment.Video + + is ConversationMessageAttachment.Unsupported, + is ConversationMessageAttachment.YouTubePreview, + -> false + } +} + +private fun toConversationAttachmentItem( + attachment: ConversationMessageAttachment, + vCardSubtitleTextResIdOverride: Int?, +): ConversationAttachmentItem? { + return when { + isStandaloneVisualAttachment(attachment = attachment) -> { + ConversationAttachmentItem.StandaloneVisual( + key = attachment.key, + attachment = attachment, + ) + } + + isInlineAttachment(attachment = attachment) -> { + toInlineAttachment( + attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + )?.let { inlineAttachment -> + ConversationAttachmentItem.Inline( + key = inlineAttachment.key, + attachment = inlineAttachment, + ) + } + } + + else -> null + } +} + +private fun isInlineAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media, + is ConversationMessageAttachment.Unsupported, + -> true + + else -> false + } +} + +private fun toInlineAttachment( + attachment: ConversationMessageAttachment, + vCardSubtitleTextResIdOverride: Int?, +): ConversationInlineAttachment? { + return when (attachment) { + is ConversationMessageAttachment.Media -> { + toMediaInlineAttachment( + attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + ) + } + + is ConversationMessageAttachment.Unsupported -> { + createFileInlineAttachment( + key = attachment.key, + titleText = attachment.part.contentType.ifBlank { null }, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + + is ConversationMessageAttachment.YouTubePreview -> null + } +} + +private fun toMediaInlineAttachment( + attachment: ConversationMessageAttachment.Media, + vCardSubtitleTextResIdOverride: Int?, +): ConversationInlineAttachment? { + return when (val part = attachment.part) { + is ConversationMessagePartUiModel.Attachment.Audio -> { + createAudioInlineAttachment( + key = attachment.key, + contentUri = part.contentUri.toString(), + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + + is ConversationMessagePartUiModel.Attachment.VCard -> { + createVCardInlineAttachment( + key = attachment.key, + contentUri = part.contentUri.toString(), + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + vCardUiModel = part.vCardUiModel, + subtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + ) + } + + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.Video, + -> null + + is ConversationMessagePartUiModel.Attachment.File -> { + createFileInlineAttachment( + key = attachment.key, + titleText = part.contentType.ifBlank { null }, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + } +} + +private fun createAudioInlineAttachment( + key: String, + contentUri: String, + openAction: ConversationAttachmentOpenAction?, +): ConversationInlineAttachment { + return ConversationInlineAttachment.Audio( + key = key, + contentUri = contentUri, + openAction = openAction, + titleText = null, + titleTextResId = R.string.audio_attachment_content_description, + ) +} + +private fun createVCardInlineAttachment( + key: String, + contentUri: String, + openAction: ConversationAttachmentOpenAction?, + vCardUiModel: ConversationVCardAttachmentUiModel, + subtitleTextResIdOverride: Int?, +): ConversationInlineAttachment { + return ConversationInlineAttachment.VCard( + key = key, + contentUri = contentUri, + openAction = openAction, + type = vCardUiModel.type, + avatarUri = vCardUiModel.avatarUri, + titleText = vCardUiModel.titleText, + titleTextResId = vCardUiModel.titleTextResId, + subtitleText = when { + subtitleTextResIdOverride == null -> vCardUiModel.subtitleText + else -> null + }, + subtitleTextResId = subtitleTextResIdOverride ?: vCardUiModel.subtitleTextResId, + ) +} + +private fun createFileInlineAttachment( + key: String, + titleText: String?, + openAction: ConversationAttachmentOpenAction?, +): ConversationInlineAttachment { + return ConversationInlineAttachment.File( + key = key, + openAction = openAction, + subtitleTextResId = null, + titleText = titleText, + titleTextResId = R.string.notification_file, + ) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt new file mode 100644 index 00000000..0673baba --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt @@ -0,0 +1,117 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment + +@Composable +internal fun ConversationGenericInlineAttachmentRow( + attachment: ConversationInlineAttachment.File, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit, +) { + val title = attachment + .titleText + ?: attachment.titleTextResId?.let { stringResource(it) }.orEmpty() + + val subtitle = attachment.subtitleTextResId?.let { stringResource(it) } + + val onClick = attachment.openAction?.let { action -> + { + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + val shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape = shape) + .combinedClickable( + enabled = true, + onClick = { + onClick?.invoke() + }, + onLongClick = onLongClick, + ), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = shape, + ) { + ConversationGenericInlineAttachmentContent( + title = title, + subtitle = subtitle, + ) + } +} + +@Composable +private fun ConversationGenericInlineAttachmentContent( + title: String, + subtitle: String?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, + ) { + ConversationFileInlineAttachmentIcon() + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + subtitle?.let { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ConversationFileInlineAttachmentIcon() { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt new file mode 100644 index 00000000..f6e86802 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -0,0 +1,46 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.runtime.Composable +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment + +@Composable +internal fun ConversationInlineAttachmentRow( + attachment: ConversationInlineAttachment, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit = {}, +) { + when (attachment) { + is ConversationInlineAttachment.Audio -> { + ConversationInlineAudioAttachmentRow( + attachment = attachment, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, + onLongClick = onLongClick, + ) + } + + is ConversationInlineAttachment.VCard -> { + ConversationVCardInlineAttachmentRow( + attachment = attachment, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onLongClick, + ) + } + + is ConversationInlineAttachment.File -> { + ConversationGenericInlineAttachmentRow( + attachment = attachment, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onLongClick, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt new file mode 100644 index 00000000..f1e63944 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt @@ -0,0 +1,217 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import android.content.Context +import android.media.MediaPlayer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import com.android.messaging.R +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration +import com.android.messaging.util.UiUtils +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +private val audioProgressUpdateIntervalMs = 250L.milliseconds + +@Composable +internal fun rememberConversationInlineAudioAttachmentPlaybackState( + contentUri: String, +): ConversationInlineAudioAttachmentPlaybackState { + val playbackState = remember(contentUri) { + ConversationInlineAudioAttachmentPlaybackState( + onPlaybackFailure = { + UiUtils.showToastAtBottom(R.string.audio_recording_replay_failed) + }, + ) + } + + DisposableEffect(contentUri) { + onDispose { + playbackState.release() + } + } + + LaunchedEffect(playbackState.isPlaying, contentUri) { + while (playbackState.isPlaying) { + playbackState.updateProgress() + delay(audioProgressUpdateIntervalMs) + } + } + + return playbackState +} + +@Stable +internal class ConversationInlineAudioAttachmentPlaybackState( + private val onPlaybackFailure: () -> Unit, +) { + var durationMillis by mutableLongStateOf(0L) + private set + + var isPlaying by mutableStateOf(false) + private set + + var positionMillis by mutableLongStateOf(0L) + private set + + private var hasPlaybackCompleted by mutableStateOf(false) + private var isPrepared by mutableStateOf(false) + private var mediaPlayer by mutableStateOf(null) + private var shouldStartPlaybackWhenPrepared by mutableStateOf(false) + + val durationLabel: String + get() { + return formatAudioDuration( + durationMillis = when { + durationMillis > 0L -> durationMillis + else -> positionMillis + }, + positionMillis = positionMillis, + ) + } + + val progress: Float + get() { + return calculateAudioProgress( + durationMillis = durationMillis, + positionMillis = positionMillis, + ) + } + + fun release() { + mediaPlayer?.release() + mediaPlayer = null + isPrepared = false + isPlaying = false + shouldStartPlaybackWhenPrepared = false + } + + fun togglePlayback( + context: Context, + contentUri: String, + ) { + val currentMediaPlayer = mediaPlayer + + when { + currentMediaPlayer == null -> { + shouldStartPlaybackWhenPrepared = true + ensureMediaPlayer( + context = context, + contentUri = contentUri, + ) + } + + !isPrepared -> { + shouldStartPlaybackWhenPrepared = !shouldStartPlaybackWhenPrepared + } + + isPlaying -> { + currentMediaPlayer.pause() + positionMillis = currentMediaPlayer + .currentPosition + .toLong() + .coerceAtLeast(0L) + + isPlaying = false + } + + else -> { + startPlayback() + } + } + } + + fun updateProgress() { + val currentMediaPlayer = mediaPlayer ?: return + positionMillis = currentMediaPlayer.currentPosition.toLong().coerceAtLeast(0L) + } + + private fun ensureMediaPlayer( + context: Context, + contentUri: String, + ) { + if (mediaPlayer != null) { + return + } + + val createdMediaPlayer = MediaPlayer() + mediaPlayer = createdMediaPlayer + + try { + createdMediaPlayer.setDataSource(context, contentUri.toUri()) + createdMediaPlayer.setOnCompletionListener { + isPlaying = false + hasPlaybackCompleted = true + positionMillis = 0L + } + createdMediaPlayer.setOnErrorListener { _, _, _ -> + handlePlaybackFailure() + true + } + createdMediaPlayer.setOnPreparedListener { preparedMediaPlayer -> + isPrepared = true + durationMillis = preparedMediaPlayer.duration.toLong().coerceAtLeast(0L) + positionMillis = 0L + if (shouldStartPlaybackWhenPrepared) { + shouldStartPlaybackWhenPrepared = false + startPlayback() + } + } + createdMediaPlayer.prepareAsync() + } catch (_: Exception) { + handlePlaybackFailure() + } + } + + private fun handlePlaybackFailure() { + onPlaybackFailure() + release() + durationMillis = 0L + positionMillis = 0L + hasPlaybackCompleted = false + } + + private fun startPlayback() { + val currentMediaPlayer = mediaPlayer ?: return + + if (hasPlaybackCompleted) { + currentMediaPlayer.seekTo(0) + positionMillis = 0L + hasPlaybackCompleted = false + } + + currentMediaPlayer.start() + isPlaying = true + } +} + +private fun calculateAudioProgress( + durationMillis: Long, + positionMillis: Long, +): Float { + return when { + durationMillis <= 0L -> 0f + else -> { + (positionMillis.toFloat() / durationMillis.toFloat()).coerceIn(0f, 1f) + } + } +} + +private fun formatAudioDuration( + durationMillis: Long, + positionMillis: Long, +): String { + val displayedMillis = when { + positionMillis > 0L -> positionMillis + else -> durationMillis + } + + return formatConversationAudioDuration(durationMillis = displayedMillis) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt new file mode 100644 index 00000000..e3666c5b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt @@ -0,0 +1,342 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment + +private val AUDIO_ATTACHMENT_HEIGHT = 70.dp + +@Composable +internal fun ConversationInlineAudioAttachmentRow( + attachment: ConversationInlineAttachment.Audio, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, + onLongClick: () -> Unit, +) { + val context = LocalContext.current + val contentUri = attachment.contentUri + + val title = attachment.titleText + ?: attachment.titleTextResId?.let { stringResource(it) } + ?: stringResource(R.string.audio_attachment_content_description) + + val colors = rememberConversationInlineAudioAttachmentColors( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, + ) + + val playbackState = rememberConversationInlineAudioAttachmentPlaybackState( + contentUri = contentUri, + ) + + ConversationInlineAudioAttachmentRowContent( + colors = colors, + isSelectionMode = isSelectionMode, + isPlaying = playbackState.isPlaying, + title = title, + durationLabel = playbackState.durationLabel, + progress = playbackState.progress, + onClick = { + playbackState.togglePlayback( + context = context, + contentUri = contentUri, + ) + }, + onLongClick = onLongClick, + ) +} + +@Composable +internal fun ConversationInlineAudioAttachmentRowContent( + colors: ConversationInlineAudioAttachmentColors, + isSelectionMode: Boolean, + isPlaying: Boolean, + title: String, + durationLabel: String, + progress: Float, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val modifier = when { + isSelectionMode -> Modifier + + else -> { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .height(height = AUDIO_ATTACHMENT_HEIGHT) + .then(modifier), + color = colors.container, + shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationInlineAudioAttachmentPlayButton( + isPlaying = isPlaying, + colors = colors, + ) + + ConversationInlineAudioAttachmentContent( + title = title, + durationLabel = durationLabel, + isPlaying = isPlaying, + progress = progress, + colors = colors, + ) + } + } +} + +@Composable +private fun ConversationInlineAudioAttachmentPlayButton( + isPlaying: Boolean, + colors: ConversationInlineAudioAttachmentColors, +) { + Surface( + modifier = Modifier + .size(size = 40.dp) + .testTag(tag = CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG), + color = colors.playButton, + shape = RoundedCornerShape(size = 20.dp), + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when { + isPlaying -> Icons.Rounded.Pause + else -> Icons.Rounded.PlayArrow + }, + contentDescription = when { + isPlaying -> stringResource(R.string.audio_pause_content_description) + else -> stringResource(R.string.audio_play_content_description) + }, + tint = colors.playIcon, + ) + } + } +} + +@Composable +private fun RowScope.ConversationInlineAudioAttachmentContent( + title: String, + durationLabel: String, + isPlaying: Boolean, + progress: Float, + colors: ConversationInlineAudioAttachmentColors, +) { + val shouldShowProgress = isPlaying || progress > 0f + + Column( + modifier = Modifier + .weight(weight = 1f) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(space = 6.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(weight = 1f, fill = false), + text = title, + style = MaterialTheme.typography.bodyMedium, + color = colors.content, + maxLines = 1, + ) + + Text( + modifier = Modifier + .width(width = 48.dp), + text = durationLabel, + style = MaterialTheme.typography.labelMedium, + color = colors.secondaryContent, + ) + } + + AnimatedVisibility( + visible = shouldShowProgress, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .testTag(tag = CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG), + progress = { progress }, + color = colors.progress, + drawStopIndicator = {}, + strokeCap = StrokeCap.Butt, + trackColor = colors.progressTrack, + ) + } + } +} + +@Composable +internal fun rememberConversationInlineAudioAttachmentColors( + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, +): ConversationInlineAudioAttachmentColors { + return ConversationInlineAudioAttachmentColors( + container = getAudioAttachmentContainerColor( + isIncoming = isIncoming, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, + ), + content = getAudioAttachmentContentColor(isIncoming = isIncoming), + playButton = getAudioAttachmentPlayButtonColor( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + ), + playIcon = getAudioAttachmentPlayIconColor( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + ), + progress = getAudioAttachmentProgressColor(isIncoming = isIncoming), + progressTrack = getAudioAttachmentProgressTrackColor(isIncoming = isIncoming), + secondaryContent = getAudioAttachmentSecondaryContentColor(isIncoming = isIncoming), + ) +} + +@Composable +private fun getAudioAttachmentContainerColor( + isIncoming: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, +): Color { + return when { + !useStandaloneAudioAttachmentBackground -> { + MaterialTheme.colorScheme.surfaceContainerHighest + } + + isIncoming -> MaterialTheme.colorScheme.surfaceContainerHighest + else -> MaterialTheme.colorScheme.primaryContainer + } +} + +@Composable +private fun getAudioAttachmentContentColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun getAudioAttachmentPlayButtonColor( + isIncoming: Boolean, + isSelectionMode: Boolean, +): Color { + return when { + isSelectionMode -> MaterialTheme.colorScheme.surfaceVariant + isIncoming -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.primary + } +} + +@Composable +private fun getAudioAttachmentPlayIconColor( + isIncoming: Boolean, + isSelectionMode: Boolean, +): Color { + return when { + isSelectionMode -> MaterialTheme.colorScheme.onSurfaceVariant + isIncoming -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onPrimary + } +} + +@Composable +private fun getAudioAttachmentProgressColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun getAudioAttachmentProgressTrackColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + } +} + +@Composable +private fun getAudioAttachmentSecondaryContentColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + } +} + +internal data class ConversationInlineAudioAttachmentColors( + val container: Color, + val content: Color, + val playButton: Color, + val playIcon: Color, + val progress: Color, + val progressTrack: Color, + val secondaryContent: Color, +) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt new file mode 100644 index 00000000..7a67ae7e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt @@ -0,0 +1,73 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections + +@Composable +internal fun ConversationMessageAttachments( + modifier: Modifier = Modifier, + attachmentSections: ConversationAttachmentSections, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBg: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val hasGalleryVisualAttachments = attachmentSections.galleryVisualAttachments.isNotEmpty() + val hasTrailingItems = attachmentSections.trailingItems.isNotEmpty() + + if (!hasGalleryVisualAttachments && !hasTrailingItems) { + return + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + if (hasGalleryVisualAttachments) { + ConversationGalleryVisualAttachments( + attachments = attachmentSections.galleryVisualAttachments, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + + attachmentSections.trailingItems.forEach { trailingItem -> + when (trailingItem) { + is ConversationAttachmentItem.Inline -> { + ConversationInlineAttachmentRow( + attachment = trailingItem.attachment, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBg, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onMessageLongClick, + ) + } + + is ConversationAttachmentItem.StandaloneVisual -> { + ConversationStandaloneVisualAttachment( + attachment = trailingItem.attachment, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt new file mode 100644 index 00000000..e72e03f6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -0,0 +1,79 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.attachment.ui.ConversationVCardAttachmentCardContent +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment + +@Composable +internal fun ConversationVCardInlineAttachmentRow( + attachment: ConversationInlineAttachment.VCard, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit, +) { + val onClick = attachment.openAction?.let { action -> + { + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + ConversationVCardInlineAttachmentRowContent( + attachment = attachment, + isSelectionMode = isSelectionMode, + onClick = onClick, + onLongClick = onLongClick, + ) +} + +@Composable +internal fun ConversationVCardInlineAttachmentRowContent( + attachment: ConversationInlineAttachment.VCard, + isSelectionMode: Boolean, + onClick: (() -> Unit)?, + onLongClick: () -> Unit, +) { + val modifier = when { + isSelectionMode -> Modifier + else -> { + Modifier.combinedClickable( + onClick = { + onClick?.invoke() + }, + onLongClick = onLongClick, + ) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .then(other = modifier), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS), + ) { + ConversationVCardAttachmentCardContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + type = attachment.type, + avatarUri = attachment.avatarUri, + titleText = attachment.titleText, + titleTextResId = attachment.titleTextResId, + subtitleText = attachment.subtitleText, + subtitleTextResId = attachment.subtitleTextResId, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt new file mode 100644 index 00000000..ffdba5bf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt @@ -0,0 +1,398 @@ +package com.android.messaging.ui.conversation.messages.ui.attachment + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.util.ContentType +import kotlinx.collections.immutable.ImmutableList + +internal val MESSAGE_ATTACHMENT_CORNER_RADIUS = 0.dp +internal val MESSAGE_ATTACHMENT_GRID_SPACING = 6.dp +private const val MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO = 4f / 3f +private const val MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f +private const val MESSAGE_ATTACHMENT_MAX_ASPECT_RATIO = 1.8f +private const val MESSAGE_ATTACHMENT_MIN_ASPECT_RATIO = 0.75f +private const val MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX = 1 + +@Composable +internal fun ConversationGalleryVisualAttachments( + attachments: ImmutableList, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + when (attachments.size) { + 0 -> {} + 1 -> { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachments.first(), + aspectRatio = resolveAttachmentAspectRatio( + attachment = attachments.first(), + ), + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + + else -> { + ConversationVisualAttachmentGrid( + attachments = attachments, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } +} + +@Composable +internal fun ConversationStandaloneVisualAttachment( + attachment: ConversationMessageAttachment, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachment, + aspectRatio = resolveAttachmentAspectRatio( + attachment = attachment, + ), + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) +} + +@Composable +private fun ConversationVisualAttachmentGrid( + attachments: ImmutableList, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val attachmentRows = remember(attachments) { + attachments.chunked(size = 2) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = MESSAGE_ATTACHMENT_GRID_SPACING), + ) { + attachmentRows.forEachIndexed { rowIndex, attachmentRow -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = MESSAGE_ATTACHMENT_GRID_SPACING, + ), + ) { + attachmentRow.forEachIndexed { columnIndex, attachment -> + Box( + modifier = Modifier.weight(weight = 1f), + ) { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachment, + aspectRatio = 1f, + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments && + rowIndex == 0, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments && + rowIndex == attachmentRows.lastIndex, + hasRoundedStartCorners = columnIndex == 0, + hasRoundedEndCorners = columnIndex == attachmentRow.lastIndex, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + + if (attachmentRow.size == 1) { + Box( + modifier = Modifier.weight(weight = 1f), + ) + } + } + } + } +} + +@Composable +private fun ConversationVisualAttachmentCard( + modifier: Modifier, + attachment: ConversationMessageAttachment, + aspectRatio: Float, + attachmentShape: RoundedCornerShape, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationVisualAttachmentSurface( + modifier = modifier.aspectRatio(ratio = aspectRatio), + attachment = attachment, + attachmentShape = attachmentShape, + contentScale = ContentScale.Crop, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + overlay = { + if (attachment.requiresPlaybackAffordance()) { + CenterPlayAffordance() + } + }, + ) +} + +@Composable +private fun ConversationVisualAttachmentSurface( + modifier: Modifier, + attachment: ConversationMessageAttachment, + attachmentShape: RoundedCornerShape, + contentScale: ContentScale, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, + overlay: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val openAction = remember(attachment) { + attachment.toConversationAttachmentOpenActionOrNull() + } + + Surface( + modifier = modifier + .clip(shape = attachmentShape) + .combinedClickable( + enabled = true, + onClick = { + openAction?.let { action -> + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + }, + onLongClick = onMessageLongClick, + ), + shape = attachmentShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val thumbnailSize = remember(maxWidth, maxHeight, density) { + with(density) { + IntSize( + width = maxWidth.roundToPx().coerceAtLeast( + minimumValue = MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX, + ), + height = maxHeight.roundToPx().coerceAtLeast( + minimumValue = MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX, + ), + ) + } + } + + ConversationAttachmentThumbnail( + modifier = Modifier.fillMaxSize(), + attachment = attachment, + contentScale = contentScale, + thumbnailSize = thumbnailSize, + ) + + overlay() + } + } +} + +@Composable +private fun ConversationAttachmentThumbnail( + modifier: Modifier, + attachment: ConversationMessageAttachment, + contentScale: ContentScale, + thumbnailSize: IntSize, +) { + when (attachment) { + is ConversationMessageAttachment.Media -> { + ConversationMediaThumbnail( + modifier = modifier, + contentUri = attachment.part.contentUri.toString(), + contentType = attachment.part.contentType, + size = thumbnailSize, + contentScale = contentScale, + ) + } + + is ConversationMessageAttachment.YouTubePreview -> { + ConversationMediaThumbnail( + modifier = modifier, + contentUri = attachment.thumbnailUrl, + contentType = ContentType.IMAGE_JPEG, + size = thumbnailSize, + contentScale = contentScale, + ) + } + + is ConversationMessageAttachment.Unsupported -> { + Box( + modifier = modifier.background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) + } + } + } +} + +private fun visualAttachmentShape( + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + hasRoundedStartCorners: Boolean = true, + hasRoundedEndCorners: Boolean = true, +): RoundedCornerShape { + return RoundedCornerShape( + topStart = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedStartCorners && !hasTextAboveVisualAttachments, + ), + topEnd = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedEndCorners && !hasTextAboveVisualAttachments, + ), + bottomStart = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedStartCorners && !hasTextBelowVisualAttachments, + ), + bottomEnd = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedEndCorners && !hasTextBelowVisualAttachments, + ), + ) +} + +private fun roundedAttachmentCornerSize(shouldRoundCorner: Boolean): Dp { + return when { + shouldRoundCorner -> MESSAGE_ATTACHMENT_CORNER_RADIUS + else -> 0.dp + } +} + +@Composable +private fun BoxScope.CenterPlayAffordance() { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(size = 999.dp), + modifier = Modifier.align(alignment = Alignment.Center), + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier + .padding(all = 10.dp) + .size(size = 26.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} + +private fun ConversationMessageAttachment.requiresPlaybackAffordance(): Boolean { + return when (this) { + is ConversationMessageAttachment.Media -> { + part is ConversationMessagePartUiModel.Attachment.Video + } + is ConversationMessageAttachment.YouTubePreview -> true + is ConversationMessageAttachment.Unsupported -> false + } +} + +private fun resolveAttachmentAspectRatio( + attachment: ConversationMessageAttachment, +): Float { + val preferredAspectRatio = when (attachment) { + is ConversationMessageAttachment.Media -> { + resolvePartAspectRatio(part = attachment.part) + } + + is ConversationMessageAttachment.YouTubePreview -> { + MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + } + + is ConversationMessageAttachment.Unsupported -> { + MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO + } + } + + return preferredAspectRatio.coerceIn( + minimumValue = MESSAGE_ATTACHMENT_MIN_ASPECT_RATIO, + maximumValue = MESSAGE_ATTACHMENT_MAX_ASPECT_RATIO, + ) +} + +private fun resolvePartAspectRatio( + part: ConversationMessagePartUiModel.Attachment, +): Float { + val hasMeasuredSize = part.width > 0 && part.height > 0 + + return when { + hasMeasuredSize -> { + part.width.toFloat() / part.height.toFloat() + } + + part is ConversationMessagePartUiModel.Attachment.Video -> { + MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + } + + else -> MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt new file mode 100644 index 00000000..f29773d5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt @@ -0,0 +1,421 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.content.Context +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.sms.cleanseMmsSubject +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status + +private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 +private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f +private const val MESSAGE_BUBBLE_CORNER_RADIUS_DP = 24 +private const val MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP = 6 + +@Composable +internal fun ConversationMessage( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + showIncomingParticipantIdentity: Boolean = true, + simDisplayName: String? = null, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, + onExternalUriClick: (String) -> Unit = {}, + onMessageClick: () -> Unit = {}, + onMessageAvatarClick: () -> Unit = {}, + onMessageDownloadClick: () -> Unit = {}, + onMessageLongClick: () -> Unit = {}, + onMessageResendClick: () -> Unit = {}, + onSimSelectorClick: () -> Unit = {}, +) { + BoxWithConstraints( + modifier = modifier + .fillMaxWidth(), + ) { + val layout = rememberConversationMessageLayout( + message = message, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + ) + + val maxBubbleWidth = remember(maxWidth) { + (maxWidth * MESSAGE_BUBBLE_WIDTH_FRACTION) + .coerceAtMost(MESSAGE_BUBBLE_MAX_WIDTH_DP.dp) + } + + val maxAdjustedBubbleWidth = remember( + maxBubbleWidth, + layout.showAvatarGutter, + ) { + conversationMessageMaxBubbleWidth( + maxBubbleWidth = maxBubbleWidth, + showAvatarGutter = layout.showAvatarGutter, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = messageHorizontalArrangement(message = message), + ) { + ConversationMessageContent( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxAdjustedBubbleWidth, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } + } +} + +@Immutable +internal data class ConversationMessageLayout( + val bubbleShape: RoundedCornerShape, + val bubbleLayoutMode: ConversationMessageBubbleLayoutMode, + val content: ConversationMessageContent, + val metadataText: String?, + val showSender: Boolean, + val showAvatarGutter: Boolean, + val showAvatar: Boolean, +) + +internal enum class ConversationMessageBubbleLayoutMode { + AttachmentOnlyWithoutSurface, + AttachmentsInSurface, + TextInSurface, +} + +@Composable +private fun rememberConversationMessageLayout( + message: ConversationMessageUiModel, + showIncomingParticipantIdentity: Boolean, +): ConversationMessageLayout { + val bubbleShape = remember( + message.canClusterWithPrevious, + message.canClusterWithNext, + ) { + messageBubbleShape(message = message) + } + + val content = rememberConversationMessageContent(message = message) + val metadataText = rememberConversationMessageMetadataText(message = message) + + val showSender = message.isIncoming && + showIncomingParticipantIdentity && + !message.senderDisplayName.isNullOrBlank() && + !message.canClusterWithPrevious + + val showAvatarGutter = message.isIncoming && showIncomingParticipantIdentity + + val showAvatar = showAvatarGutter && !message.canClusterWithNext + + val bubbleLayoutMode = remember( + content, + showSender, + ) { + buildConversationMessageBubbleLayoutMode( + content = content, + showSender = showSender, + ) + } + + return remember( + bubbleShape, + bubbleLayoutMode, + content, + metadataText, + showSender, + showAvatarGutter, + showAvatar, + ) { + ConversationMessageLayout( + bubbleShape = bubbleShape, + bubbleLayoutMode = bubbleLayoutMode, + content = content, + metadataText = metadataText, + showSender = showSender, + showAvatarGutter = showAvatarGutter, + showAvatar = showAvatar, + ) + } +} + +private fun conversationMessageMaxBubbleWidth( + maxBubbleWidth: Dp, + showAvatarGutter: Boolean, +): Dp { + return when { + showAvatarGutter -> { + (maxBubbleWidth - CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH) + .coerceAtLeast(0.dp) + } + + else -> maxBubbleWidth + } +} + +@Composable +private fun rememberConversationMessageContent( + message: ConversationMessageUiModel, +): ConversationMessageContent { + val resources = LocalResources.current + val configuration = LocalConfiguration.current + val subjectText = remember( + resources, + configuration, + message.mmsSubject, + ) { + cleanseMmsSubject( + resources = resources, + subject = message.mmsSubject, + ) + } + + return remember( + message.canResendMessage, + message.text, + message.mmsSubject, + message.parts, + subjectText, + ) { + buildConversationMessageContent( + message = message, + subjectText = subjectText, + ) + } +} + +@Composable +private fun rememberConversationMessageMetadataText( + message: ConversationMessageUiModel, +): String? { + if (message.mmsDownload != null) { + return null + } + + val context = LocalContext.current + val configuration = LocalConfiguration.current + + val statusTextResourceId = remember(message.status) { + messageStatusTextResourceId(status = message.status) + } + val statusText = statusTextResourceId?.let { stringResource(it) } + + return remember( + context, + configuration, + message.canClusterWithNext, + message.displayTimestamp, + statusText, + ) { + buildMessageMetadataText( + context = context, + canClusterWithNext = message.canClusterWithNext, + timestamp = message.displayTimestamp, + statusText = statusText, + ) + } +} + +private fun messageHorizontalArrangement( + message: ConversationMessageUiModel, +): Arrangement.Horizontal { + return when { + message.isIncoming -> Arrangement.Start + else -> Arrangement.End + } +} + +@Composable +private fun ConversationMessageContent( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + simDisplayName: String?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, + onMessageDownloadClick: () -> Unit, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, + onSimSelectorClick: () -> Unit, +) { + Column( + horizontalAlignment = messageContentHorizontalAlignment(message = message), + ) { + ConversationMessageBubbleRow( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + ) + + ConversationMessageMetadataRow( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + +private fun messageContentHorizontalAlignment( + message: ConversationMessageUiModel, +): Alignment.Horizontal { + return when { + message.isIncoming -> Alignment.Start + else -> Alignment.End + } +} + +private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { + val cornerRadius = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp + + val topStartCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithPrevious, + ) + val topEndCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithPrevious, + useFreeSide = true, + defaultRadius = cornerRadius, + ) + val bottomStartCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithNext, + ) + val bottomEndCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithNext, + useFreeSide = true, + defaultRadius = cornerRadius, + ) + + return RoundedCornerShape( + topStart = if (message.isIncoming) topStartCornerRadius else topEndCornerRadius, + topEnd = if (message.isIncoming) topEndCornerRadius else topStartCornerRadius, + bottomStart = if (message.isIncoming) bottomStartCornerRadius else bottomEndCornerRadius, + bottomEnd = if (message.isIncoming) bottomEndCornerRadius else bottomStartCornerRadius, + ) +} + +private fun clusteredCornerRadius( + clustersWithAdjacent: Boolean, + useFreeSide: Boolean = false, + defaultRadius: Dp = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp, +): Dp { + return when { + !clustersWithAdjacent -> defaultRadius + useFreeSide -> defaultRadius + else -> MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP.dp + } +} + +private fun buildConversationMessageBubbleLayoutMode( + content: ConversationMessageContent, + showSender: Boolean, +): ConversationMessageBubbleLayoutMode { + val hasAttachments = content.attachments.isNotEmpty() + if (!hasAttachments) { + return ConversationMessageBubbleLayoutMode.TextInSurface + } + + val hasAttachmentHeaderOrFooter = showSender || + !content.subjectText.isNullOrBlank() || + !content.bodyText.isNullOrBlank() + + return when { + content.isAttachmentOnly && !hasAttachmentHeaderOrFooter -> { + ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface + } + else -> ConversationMessageBubbleLayoutMode.AttachmentsInSurface + } +} + +private fun buildMessageMetadataText( + context: Context, + canClusterWithNext: Boolean, + timestamp: Long, + statusText: String?, +): String? { + return when { + canClusterWithNext -> null + timestamp <= 0L -> statusText + + else -> { + val formattedTime = DateUtils.formatDateTime( + context, + timestamp, + DateUtils.FORMAT_SHOW_TIME, + ) + + buildTimestampMetadataText( + formattedTime = formattedTime, + statusText = statusText, + ) + } + } +} + +private fun buildTimestampMetadataText( + formattedTime: String, + statusText: String?, +): String { + return when (statusText) { + null -> formattedTime + else -> "$formattedTime \u2022 $statusText" + } +} + +private fun messageStatusTextResourceId(status: Status): Int? { + return when (status) { + Status.Outgoing.Delivered -> R.string.delivered_status_content_description + Status.Outgoing.YetToSend -> R.string.message_status_sending + Status.Outgoing.Sending -> R.string.message_status_sending + Status.Outgoing.Resending -> R.string.message_status_send_retrying + Status.Outgoing.AwaitingRetry -> R.string.message_status_failed + Status.Outgoing.Failed -> R.string.message_status_send_failed + Status.Outgoing.FailedEmergencyNumber -> { + R.string.message_status_send_failed_emergency_number + } + else -> null + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt new file mode 100644 index 00000000..2debb8c0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt @@ -0,0 +1,190 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +internal val CONVERSATION_MESSAGE_AVATAR_SIZE = 32.dp +internal val CONVERSATION_MESSAGE_AVATAR_GAP = 8.dp +internal val CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH = + CONVERSATION_MESSAGE_AVATAR_SIZE + CONVERSATION_MESSAGE_AVATAR_GAP + +@Composable +internal fun ConversationMessageAvatar( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + val label = remember(message.senderDisplayName) { + conversationMessageAvatarLabel(displayName = message.senderDisplayName) + } + + val fallbackColors = rememberConversationMessageAvatarColors(message = message) + + Box( + modifier = modifier + .size(size = CONVERSATION_MESSAGE_AVATAR_SIZE) + .clip(shape = CircleShape) + .combinedClickable( + enabled = true, + onClick = onClick, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + }, + ), + contentAlignment = Alignment.Center, + ) { + ConversationMessageAvatarFallback( + colors = fallbackColors, + label = label, + ) + + message.senderAvatarUri?.let { + AsyncImage( + model = message.senderAvatarUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun ConversationMessageAvatarFallback( + modifier: Modifier = Modifier, + colors: ConversationMessageAvatarColors, + label: String?, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = colors.container, + contentColor = colors.content, + shape = CircleShape, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ConversationMessageAvatarFallbackContent( + label = label, + ) + } + } +} + +@Composable +private fun ConversationMessageAvatarFallbackContent( + modifier: Modifier = Modifier, + label: String?, +) { + when (label) { + null -> { + Icon( + modifier = modifier.size(size = 18.dp), + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + + else -> { + Text( + modifier = modifier, + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun rememberConversationMessageAvatarColors( + message: ConversationMessageUiModel, +): ConversationMessageAvatarColors { + val colorScheme = MaterialTheme.colorScheme + + val colorKey = remember(message) { + conversationMessageAvatarColorKey(message = message) + } + + return remember(colorScheme, colorKey) { + val colorOptions = listOf( + ConversationMessageAvatarColors( + container = colorScheme.primaryContainer, + content = colorScheme.onPrimaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.secondaryContainer, + content = colorScheme.onSecondaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.tertiaryContainer, + content = colorScheme.onTertiaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.surfaceContainerHigh, + content = colorScheme.onSurface, + ), + ConversationMessageAvatarColors( + container = colorScheme.surfaceContainerHighest, + content = colorScheme.onSurface, + ), + ) + + val colorIndex = (colorKey.hashCode() and Int.MAX_VALUE) % colorOptions.size + + colorOptions[colorIndex] + } +} + +private fun conversationMessageAvatarColorKey( + message: ConversationMessageUiModel, +): String { + return message.senderParticipantId + ?: message.senderContactLookupKey?.takeIf { it.isNotBlank() } + ?: message.senderDisplayName?.takeIf { it.isNotBlank() } + ?: message.conversationId +} + +private fun conversationMessageAvatarLabel( + displayName: String?, +): String? { + val trimmedDisplayName = displayName?.trim() + + return when { + trimmedDisplayName == null -> null + trimmedDisplayName.isBlank() -> null + MmsSmsUtils.isPhoneNumber(trimmedDisplayName) -> null + else -> trimmedDisplayName.first().uppercaseChar().toString() + } +} + +private data class ConversationMessageAvatarColors( + val container: Color, + val content: Color, +) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt new file mode 100644 index 00000000..4a3853e8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt @@ -0,0 +1,506 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments +import com.android.messaging.ui.conversation.messages.ui.text.ConversationMessageText +import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkColor + +private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp +private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp +private val MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING = 16.dp +private val MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING = 12.dp +private const val MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA = 0.2f + +@Composable +internal fun ConversationMessageBubble( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + simDisplayName: String?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val bubbleModifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier) + + when (layout.bubbleLayoutMode) { + ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { + ConversationMessageAttachmentOnlyBubble( + modifier = bubbleModifier, + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + + ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { + ConversationMessageAttachmentSurfaceBubble( + modifier = bubbleModifier, + layout = layout, + isSelected = isSelected, + message = message, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + + ConversationMessageBubbleLayoutMode.TextInSurface -> { + ConversationMessageTextSurfaceBubble( + modifier = bubbleModifier, + layout = layout, + isSelected = isSelected, + message = message, + isSelectionMode = isSelectionMode, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } +} + +@Composable +private fun ConversationMessageAttachmentOnlyBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageAttachmentOnlyContainer( + modifier = modifier, + bubbleShape = layout.bubbleShape, + message = message, + isSelected = isSelected, + ) { + ConversationMessageAttachmentBubbleContent( + modifier = Modifier.fillMaxWidth(), + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageAttachmentSurfaceBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + isSelected: Boolean, + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageBubbleSurface( + modifier = modifier, + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageAttachmentBubbleContent( + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageTextSurfaceBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + isSelected: Boolean, + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + simDisplayName: String?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageBubbleSurface( + modifier = modifier, + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageTextBubbleContent( + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + simDisplayName = simDisplayName, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageBubbleSurface( + modifier: Modifier = Modifier, + isSelected: Boolean, + message: ConversationMessageUiModel, + layout: ConversationMessageLayout, + bubbleContent: @Composable () -> Unit, +) { + val contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ) + + Surface( + color = messageBubbleColor( + message = message, + isSelected = isSelected, + ), + contentColor = contentColor, + shape = layout.bubbleShape, + modifier = modifier, + ) { + CompositionLocalProvider( + LocalConversationMessageLinkColor provides contentColor, + ) { + bubbleContent() + } + } +} + +@Composable +private fun ConversationMessageAttachmentOnlyContainer( + modifier: Modifier = Modifier, + bubbleShape: RoundedCornerShape, + message: ConversationMessageUiModel, + isSelected: Boolean, + content: @Composable () -> Unit, +) { + val overlayColor by animateColorAsState( + targetValue = when { + isSelected -> { + messageBubbleColor( + message = message, + isSelected = true, + ).copy(alpha = MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA) + } + + else -> Color.Transparent + }, + label = "conversationMessageSelectionOverlayColor", + ) + + Box( + modifier = modifier.clip(shape = bubbleShape), + ) { + content() + + if (overlayColor != Color.Transparent) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(shape = bubbleShape) + .background(color = overlayColor), + ) + } + } +} + +@Composable +private fun ConversationMessageTextBubbleContent( + layout: ConversationMessageLayout, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + simDisplayName: String?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + Column( + modifier = Modifier.padding( + horizontal = MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING, + vertical = MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING, + ), + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + ConversationMessageSender( + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + ) + + when { + message.mmsDownload != null -> { + ConversationMmsDownloadBody( + download = message.mmsDownload, + canDownloadMessage = message.canDownloadMessage, + isSelected = isSelected, + contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ), + simDisplayName = simDisplayName, + ) + } + + else -> { + ConversationMessageBody( + content = layout.content, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + } +} + +@Composable +private fun ConversationMessageAttachmentBubbleContent( + modifier: Modifier = Modifier, + layout: ConversationMessageLayout, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val content = layout.content + val hasHeader = layout.showSender || !content.subjectText.isNullOrBlank() + val hasBodyText = !content.bodyText.isNullOrBlank() + + Column( + modifier = modifier.fillMaxWidth(), + ) { + ConversationMessageSender( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = conversationMessageSenderBottomPadding(content), + ), + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + ) + + content.subjectText?.let { subjectText -> + Text( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = conversationMessageSubjectTopPadding(showSender = layout.showSender), + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + ), + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = hasHeader, + hasTextBelowVisualAttachments = hasBodyText, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = false, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + ), + text = bodyText, + style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } +} + +private fun conversationMessageSenderBottomPadding( + content: ConversationMessageContent, +): Dp { + return when { + content.subjectText.isNullOrBlank() -> 6.dp + else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING + } +} + +private fun conversationMessageSubjectTopPadding(showSender: Boolean): Dp { + return when { + showSender -> 0.dp + else -> MESSAGE_BUBBLE_MEDIA_TEXT_PADDING + } +} + +@Composable +private fun ConversationMessageBody( + content: ConversationMessageContent, + isIncoming: Boolean, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + content.subjectText?.let { subjectText -> + Text( + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = false, + hasTextBelowVisualAttachments = false, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = true, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + text = bodyText, + style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageSender( + modifier: Modifier = Modifier, + color: Color, + senderDisplayName: String?, + showSender: Boolean, +) { + if (!showSender || senderDisplayName == null) { + return + } + + Text( + modifier = modifier, + text = senderDisplayName, + style = MaterialTheme.typography.labelMedium, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun messageBubbleColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> MaterialTheme.colorScheme.primary + message.isIncoming -> MaterialTheme.colorScheme.surfaceContainerHigh + else -> MaterialTheme.colorScheme.primaryContainer + } +} + +@Composable +private fun messageBubbleContentColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> MaterialTheme.colorScheme.onPrimary + message.isIncoming -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun messageSenderColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> { + messageBubbleContentColor( + message = message, + isSelected = true, + ) + } + + message.isIncoming -> MaterialTheme.colorScheme.primary + + else -> { + messageBubbleContentColor( + message = message, + isSelected = false, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt new file mode 100644 index 00000000..7aeb1631 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt @@ -0,0 +1,182 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.net.Uri +import android.util.Patterns +import android.webkit.URLUtil +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.attachment.buildConversationAttachmentSections +import com.android.messaging.util.YouTubeUtil +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal fun buildConversationMessageContent( + message: ConversationMessageUiModel, + subjectText: String?, +): ConversationMessageContent { + val attachments = buildConversationMessageAttachments(message = message) + val attachmentSections = buildConversationAttachmentSections( + attachments = attachments, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride(message), + ) + + val bodyText = buildConversationMessageBodyText( + message = message, + ) + + val isAttachmentOnly = subjectText.isNullOrBlank() && + bodyText.isNullOrBlank() && + attachments.isNotEmpty() + + return ConversationMessageContent( + subjectText = subjectText, + bodyText = bodyText, + attachments = attachments, + attachmentSections = attachmentSections, + isAttachmentOnly = isAttachmentOnly, + ) +} + +private fun vCardSubtitleTextResIdOverride(message: ConversationMessageUiModel): Int? { + return when { + message.canResendMessage -> R.string.message_status_send_failed + else -> null + } +} + +private fun buildConversationMessageAttachments( + message: ConversationMessageUiModel, +): ImmutableList { + val attachmentItems = message + .parts + .mapIndexedNotNull(::toConversationMessageAttachment) + .toImmutableList() + + val hasImageAttachment = attachmentItems.any { attachment -> + attachment is ConversationMessageAttachment.Media && + attachment.part is ConversationMessagePartUiModel.Attachment.Image + } + + if (hasImageAttachment) { + return attachmentItems + } + + return message.text + ?.let(::findSingleYouTubePreview) + ?.let { youtubePreview -> + (attachmentItems + youtubePreview).toImmutableList() + } + ?: attachmentItems +} + +private fun toConversationMessageAttachment( + index: Int, + part: ConversationMessagePartUiModel, +): ConversationMessageAttachment? { + val attachmentPart = part as? ConversationMessagePartUiModel.Attachment ?: return null + + val key = buildConversationMessageAttachmentKey( + index = index, + contentType = attachmentPart.contentType, + contentUri = attachmentPart.contentUri, + ) + + return when { + attachmentPart.isSupportedAttachment() && attachmentPart.contentUri != null -> { + ConversationMessageAttachment.Media( + key = key, + part = attachmentPart, + ) + } + + else -> { + ConversationMessageAttachment.Unsupported( + key = key, + part = attachmentPart, + ) + } + } +} + +private fun buildConversationMessageAttachmentKey( + index: Int, + contentType: String, + contentUri: Uri?, +): String { + return buildString { + append(index) + append(':') + append(contentType) + append(':') + append(contentUri ?: "missing") + } +} + +private fun buildConversationMessageBodyText(message: ConversationMessageUiModel): String? { + message.text + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { bodyText -> + return bodyText + } + + return message.parts + .asSequence() + .filter { it.hasCaptionText } + .mapNotNull { part -> + part.text?.trim()?.takeIf { text -> text.isNotEmpty() } + } + .distinct() + .joinToString(separator = "\n") + .takeIf { text -> text.isNotEmpty() } +} + +private fun ConversationMessagePartUiModel.Attachment.isSupportedAttachment(): Boolean { + return when (this) { + is ConversationMessagePartUiModel.Attachment.Audio, + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.VCard, + is ConversationMessagePartUiModel.Attachment.Video, + -> true + + is ConversationMessagePartUiModel.Attachment.File -> false + } +} + +private fun findSingleYouTubePreview( + text: String, +): ConversationMessageAttachment.YouTubePreview? { + return extractConversationWebUrls(text) + .asSequence() + .mapNotNull { sourceUrl -> + val thumbnailUrl = YouTubeUtil + .getYoutubePreviewImageLink(sourceUrl) + ?: return@mapNotNull null + + ConversationMessageAttachment.YouTubePreview( + key = "youtube:$sourceUrl", + sourceUrl = sourceUrl, + thumbnailUrl = thumbnailUrl, + ) + } + .take(2) + .singleOrNull() +} + +private fun extractConversationWebUrls(text: String): Set { + val webUrlMatcher = Patterns.WEB_URL.matcher(text) + val urls = LinkedHashSet() + + while (webUrlMatcher.find()) { + webUrlMatcher + .group() + .takeIf { it.isNotBlank() } + ?.let(URLUtil::guessUrl) + ?.let(urls::add) + } + + return urls +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt new file mode 100644 index 00000000..3d4a7448 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt @@ -0,0 +1,70 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.content.Context +import android.text.format.DateUtils +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone + +private const val MILLIS_PER_DAY = 86_400_000L + +private const val COMMON_DATE_SEPARATOR_FORMAT_FLAGS = DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH + +internal fun conversationMessageDisplayEpochDay( + displayTimestamp: Long, + timeZone: TimeZone, +): Long? { + return when { + displayTimestamp <= 0 -> null + + else -> { + val localTimestamp = displayTimestamp + timeZone.getOffset(displayTimestamp) + Math.floorDiv(localTimestamp, MILLIS_PER_DAY) + } + } +} + +private fun conversationMessageDisplayLocalDate( + displayTimestamp: Long, +): LocalDate? { + return when { + displayTimestamp > 0 -> { + Instant + .ofEpochMilli(displayTimestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + + else -> null + } +} + +internal fun formatDateSeparatorText( + context: Context, + message: ConversationMessageUiModel, +): String? { + val timestamp = message.displayTimestamp + + if (timestamp <= 0L) { + return null + } + + val isSameYear = conversationMessageDisplayLocalDate( + displayTimestamp = timestamp, + )?.year == LocalDate.now().year + + val dateTimeFormatFlags = when { + isSameYear -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_NO_YEAR + else -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_SHOW_YEAR + } + + return DateUtils.formatDateTime( + context, + timestamp, + dateTimeFormatFlags, + ) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt new file mode 100644 index 00000000..61179dbd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt @@ -0,0 +1,165 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status + +private const val METADATA_SEPARATOR = " • " +private const val SIM_ANNOTATION_PLACEHOLDER = "%1\$s" + +@Composable +internal fun ConversationMessageMetadata( + message: ConversationMessageUiModel, + metadataText: String?, + simDisplayName: String?, + onSimSelectorClick: () -> Unit, +) { + if (message.mmsDownload != null) { + return + } + + val linkColor = MaterialTheme.colorScheme.primary + val resources = LocalResources.current + + val annotatedText = remember( + metadataText, + simDisplayName, + linkColor, + resources, + onSimSelectorClick, + ) { + buildMessageMetadataAnnotatedString( + metadataText = metadataText, + simDisplayName = simDisplayName, + simAnnotationTemplate = resources.getString( + R.string.conversation_message_sim_annotation, + ), + linkColor = linkColor, + onSimSelectorClick = onSimSelectorClick, + ) + } + + if (annotatedText == null) { + return + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + text = annotatedText, + style = MaterialTheme.typography.labelSmall, + color = messageMetadataColor(message = message), + textAlign = when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End + }, + ) +} + +private fun buildMessageMetadataAnnotatedString( + metadataText: String?, + simDisplayName: String?, + simAnnotationTemplate: String, + linkColor: Color, + onSimSelectorClick: () -> Unit, +): AnnotatedString? { + return when { + metadataText == null && simDisplayName == null -> null + simDisplayName == null -> AnnotatedString(text = metadataText.orEmpty()) + else -> buildSimLinkAnnotatedString( + metadataText = metadataText, + simDisplayName = simDisplayName, + simAnnotationTemplate = simAnnotationTemplate, + linkColor = linkColor, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + +private fun buildSimLinkAnnotatedString( + metadataText: String?, + simDisplayName: String, + simAnnotationTemplate: String, + linkColor: Color, + onSimSelectorClick: () -> Unit, +): AnnotatedString { + val placeholderIndex = simAnnotationTemplate.indexOf(SIM_ANNOTATION_PLACEHOLDER) + + val annotationPrefix = when { + placeholderIndex >= 0 -> simAnnotationTemplate.substring(0, placeholderIndex) + else -> simAnnotationTemplate + } + + val annotationSuffix = when { + placeholderIndex >= 0 -> { + simAnnotationTemplate.substring( + placeholderIndex + SIM_ANNOTATION_PLACEHOLDER.length, + ) + } + else -> "" + } + + val link = LinkAnnotation.Clickable( + tag = SIM_LINK_TAG, + styles = TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ), + ) { + onSimSelectorClick() + } + + return buildAnnotatedString { + if (!metadataText.isNullOrEmpty()) { + append(metadataText) + append(METADATA_SEPARATOR) + } + + append(annotationPrefix) + + withLink(link = link) { + append(simDisplayName) + } + + if (annotationSuffix.isNotEmpty()) { + append(annotationSuffix) + } + } +} + +@Composable +private fun messageMetadataColor( + message: ConversationMessageUiModel, +): Color { + return when (message.status) { + Status.Outgoing.AwaitingRetry, + Status.Outgoing.Failed, + Status.Outgoing.FailedEmergencyNumber, + -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +private const val SIM_LINK_TAG = "sim_selector" 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 new file mode 100644 index 00000000..4c2c2863 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -0,0 +1,308 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +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.unit.Dp +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +@Composable +internal fun ConversationMessageBubbleRow( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + simDisplayName: String?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, + onMessageDownloadClick: () -> Unit, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, +) { + ConversationMessageBubbleRowContainer( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageLongClick = onMessageLongClick, + ) { + ConversationMessageBubble( + modifier = Modifier.conversationMessageBubbleInteractionModifier( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + ), + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, + onAttachmentClick = { contentType, contentUri -> + when { + isSelectionMode -> onMessageClick() + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() + else -> onAttachmentClick(contentType, contentUri) + } + }, + onExternalUriClick = { uri -> + when { + isSelectionMode -> onMessageClick() + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() + else -> onExternalUriClick(uri) + } + }, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageBubbleRowContainer( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, + onMessageLongClick: () -> Unit, + content: @Composable () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .conversationMessageSelectionModeRowModifier( + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationMessageSelectionIndicator( + visible = isSelectionMode, + isSelected = isSelected, + expandFrom = Alignment.Start, + shrinkTowards = Alignment.Start, + ) + + Row( + modifier = Modifier.weight(weight = 1f), + horizontalArrangement = conversationMessageRowHorizontalArrangement( + message = message, + ), + verticalAlignment = Alignment.Bottom, + ) { + ConversationMessageAvatarGutter( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + onAvatarClick = onMessageAvatarClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ) + + content() + } + } +} + +@Composable +private fun ConversationMessageAvatarGutter( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onAvatarClick: () -> Unit, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, +) { + if (isSelectionMode || !layout.showAvatarGutter) { + return + } + + Box( + modifier = Modifier + .width(width = CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH), + contentAlignment = Alignment.CenterStart, + ) { + if (layout.showAvatar) { + ConversationMessageAvatar( + message = message, + onClick = { + when { + isSelectionMode -> onMessageClick() + else -> onAvatarClick() + } + }, + onLongClick = onMessageLongClick, + ) + } + } +} + +private fun conversationMessageRowHorizontalArrangement( + message: ConversationMessageUiModel, +): Arrangement.Horizontal { + return when { + message.isIncoming -> Arrangement.Start + else -> Arrangement.End + } +} + +@Composable +private fun Modifier.conversationMessageSelectionModeRowModifier( + isSelected: Boolean, + isSelectionMode: Boolean, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, +): Modifier { + val hapticFeedback = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + return when { + !isSelectionMode -> this + + else -> { + this + .semantics { + role = Role.Checkbox + selected = isSelected + } + .combinedClickable( + interactionSource = interactionSource, + indication = null, + enabled = true, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onMessageClick() + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) + } + } +} + +@Composable +private fun Modifier.conversationMessageBubbleInteractionModifier( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onMessageDownloadClick: () -> Unit, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, +): Modifier { + val hapticFeedback = LocalHapticFeedback.current + val bubbleModifier = this + .clip(shape = layout.bubbleShape) + + return when { + isSelectionMode -> bubbleModifier + + else -> { + bubbleModifier.combinedClickable( + enabled = true, + onClick = { + when { + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) + } + } +} + +@Composable +internal fun ConversationMessageMetadataRow( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + simDisplayName: String?, + onSimSelectorClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + ConversationMessageSelectionIndicatorOffset( + visible = isSelectionMode, + expandFrom = Alignment.Start, + shrinkTowards = Alignment.Start, + ) + + Row( + modifier = Modifier.weight(weight = 1f), + horizontalArrangement = conversationMessageRowHorizontalArrangement( + message = message, + ), + ) { + ConversationMessageAvatarMetadataOffset( + isSelectionMode = isSelectionMode, + layout = layout, + ) + + Column( + modifier = Modifier.widthIn(max = maxBubbleWidth), + horizontalAlignment = when { + message.isIncoming -> Alignment.Start + else -> Alignment.End + }, + ) { + ConversationMessageMetadata( + message = message, + metadataText = layout.metadataText, + simDisplayName = simDisplayName, + onSimSelectorClick = onSimSelectorClick, + ) + } + } + } +} + +@Composable +private fun ConversationMessageAvatarMetadataOffset( + isSelectionMode: Boolean, + layout: ConversationMessageLayout, +) { + if (isSelectionMode || !layout.showAvatarGutter) { + return + } + + Box( + modifier = Modifier + .width(width = CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH), + ) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt new file mode 100644 index 00000000..85aea618 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt @@ -0,0 +1,254 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +private val MESSAGE_SELECTION_INDICATOR_SIZE = 22.dp +private val MESSAGE_SELECTION_INDICATOR_HORIZONTAL_GAP = 16.dp +private val MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH = MESSAGE_SELECTION_INDICATOR_SIZE + + MESSAGE_SELECTION_INDICATOR_HORIZONTAL_GAP +private val MESSAGE_SELECTION_INDICATOR_CHECK_SIZE = 16.dp +private val MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH = 2.dp + +@Composable +internal fun ConversationMessageSelectionIndicator( + visible: Boolean, + isSelected: Boolean, + expandFrom: Alignment.Horizontal, + shrinkTowards: Alignment.Horizontal, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ) + expandHorizontally( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + expandFrom = expandFrom, + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ) + shrinkHorizontally( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + shrinkTowards = shrinkTowards, + ), + label = "conversationMessageSelectionIndicatorVisibility", + ) { + ConversationMessageSelectionIndicatorContent( + isSelected = isSelected, + ) + } +} + +@Composable +private fun ConversationMessageSelectionIndicatorContent( + isSelected: Boolean, +) { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "conversationMessageSelectionIndicator", + ) + + val containerColor by selectionTransition.animateSelectionIndicatorContainerColor() + val borderColor by selectionTransition.animateSelectionIndicatorBorderColor() + val checkmarkAlpha by selectionTransition.animateSelectionIndicatorCheckmarkAlpha() + val checkmarkScale by selectionTransition.animateSelectionIndicatorCheckmarkScale() + + Box( + modifier = Modifier + .width(width = MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH) + .height(height = MESSAGE_SELECTION_INDICATOR_SIZE), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .size(size = MESSAGE_SELECTION_INDICATOR_SIZE) + .background( + color = containerColor, + shape = CircleShape, + ) + .border( + width = MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH, + color = borderColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .size(size = MESSAGE_SELECTION_INDICATOR_CHECK_SIZE) + .graphicsLayer { + alpha = checkmarkAlpha + scaleX = checkmarkScale + scaleY = checkmarkScale + }, + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +@Composable +internal fun ConversationMessageSelectionIndicatorOffset( + visible: Boolean, + expandFrom: Alignment.Horizontal, + shrinkTowards: Alignment.Horizontal, +) { + AnimatedVisibility( + visible = visible, + enter = expandHorizontally( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + expandFrom = expandFrom, + ), + exit = shrinkHorizontally( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + shrinkTowards = shrinkTowards, + ), + label = "conversationMessageSelectionIndicatorOffset", + ) { + Spacer( + modifier = Modifier + .width(width = MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH), + ) + } +} + +@Composable +private fun Transition.animateSelectionIndicatorContainerColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorContainerColor", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> MaterialTheme.colorScheme.primary + else -> Color.Transparent + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorBorderColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorBorderColor", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorCheckmarkAlpha(): State { + return animateFloat( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorCheckmarkAlpha", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> 1f + else -> 0f + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorCheckmarkScale(): State { + return animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "conversationMessageSelectionIndicatorCheckmarkScale", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> 1f + else -> 0.7f + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt new file mode 100644 index 00000000..6ef54dc5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt @@ -0,0 +1,23 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +internal fun resolveConversationMessageSimDisplayName( + message: ConversationMessageUiModel, + messageBelow: ConversationMessageUiModel?, + simDisplayNameByParticipantId: Map, +): String? { + val selfParticipantId = message.selfParticipantId + val displayName = selfParticipantId?.let(simDisplayNameByParticipantId::get) + + val isLastInSimRun = messageBelow == null || + messageBelow.isIncoming != message.isIncoming || + messageBelow.selfParticipantId != selfParticipantId + + return when { + simDisplayNameByParticipantId.size <= 1 -> null + message.mmsDownload != null -> displayName + !isLastInSimRun -> null + else -> displayName + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt new file mode 100644 index 00000000..478f5e49 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt @@ -0,0 +1,224 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.content.Context +import android.content.res.Resources +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel + +private const val MMS_DOWNLOAD_STATUS_SEPARATOR = " • " + +@Composable +internal fun ConversationMmsDownloadBody( + download: MmsDownloadUiModel, + canDownloadMessage: Boolean, + isSelected: Boolean, + contentColor: Color, + simDisplayName: String?, +) { + val supportingColor = mmsDownloadSupportingColor( + isSelected = isSelected, + contentColor = contentColor, + ) + + ConversationMmsDownloadBodyContent( + titleText = stringResource(id = mmsDownloadTitleResId(state = download.state)), + infoText = rememberMmsDownloadInfoText(download = download), + statusLineText = rememberMmsDownloadStatusLineText( + statusText = stringResource(id = mmsDownloadStatusResId(state = download.state)), + simDisplayName = simDisplayName, + ), + contentColor = contentColor, + supportingColor = supportingColor, + statusColor = mmsDownloadStatusColor( + state = download.state, + canDownloadMessage = canDownloadMessage, + isSelected = isSelected, + contentColor = contentColor, + supportingColor = supportingColor, + ), + ) +} + +@Composable +private fun mmsDownloadSupportingColor( + isSelected: Boolean, + contentColor: Color, +): Color { + return when { + isSelected -> contentColor.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +@Composable +private fun ConversationMmsDownloadBodyContent( + titleText: String, + infoText: String, + statusLineText: String, + contentColor: Color, + supportingColor: Color, + statusColor: Color, +) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + Text( + text = titleText, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + + Text( + text = infoText, + style = MaterialTheme.typography.bodyMedium, + color = supportingColor, + ) + + Text( + text = statusLineText, + style = MaterialTheme.typography.bodyLarge, + color = statusColor, + ) + } +} + +@Composable +private fun rememberMmsDownloadInfoText(download: MmsDownloadUiModel): String { + val context = LocalContext.current + val configuration = LocalConfiguration.current + + return remember( + context, + configuration, + download.sizeBytes, + download.expiryTimestamp, + ) { + buildMmsDownloadInfoText( + context = context, + download = download, + ) + } +} + +@Composable +private fun rememberMmsDownloadStatusLineText( + statusText: String, + simDisplayName: String?, +): String { + val resources = LocalResources.current + val configuration = LocalConfiguration.current + + return remember( + resources, + configuration, + statusText, + simDisplayName, + ) { + buildMmsDownloadStatusLineText( + resources = resources, + statusText = statusText, + simDisplayName = simDisplayName, + ) + } +} + +@Composable +private fun mmsDownloadStatusColor( + state: MmsDownloadUiModel.State, + canDownloadMessage: Boolean, + isSelected: Boolean, + contentColor: Color, + supportingColor: Color, +): Color { + return when { + isSelected -> contentColor + state == MmsDownloadUiModel.State.AwaitingManualDownload && canDownloadMessage -> { + MaterialTheme.colorScheme.primary + } + state == MmsDownloadUiModel.State.DownloadFailed && canDownloadMessage -> { + MaterialTheme.colorScheme.primary + } + state == MmsDownloadUiModel.State.DownloadFailed -> MaterialTheme.colorScheme.error + state == MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + MaterialTheme.colorScheme.error + } + else -> supportingColor + } +} + +@StringRes +private fun mmsDownloadTitleResId(state: MmsDownloadUiModel.State): Int { + return when (state) { + MmsDownloadUiModel.State.AwaitingManualDownload -> { + R.string.message_title_manual_download + } + MmsDownloadUiModel.State.Downloading -> R.string.message_title_downloading + MmsDownloadUiModel.State.DownloadFailed -> R.string.message_title_download_failed + MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + R.string.message_title_download_failed + } + } +} + +@StringRes +private fun mmsDownloadStatusResId(state: MmsDownloadUiModel.State): Int { + return when (state) { + MmsDownloadUiModel.State.AwaitingManualDownload -> { + R.string.message_status_download + } + MmsDownloadUiModel.State.Downloading -> R.string.message_status_downloading + MmsDownloadUiModel.State.DownloadFailed -> R.string.message_status_download + MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + R.string.message_status_download_error + } + } +} + +private fun buildMmsDownloadInfoText( + context: Context, + download: MmsDownloadUiModel, +): String { + val formattedSize = Formatter.formatFileSize(context, download.sizeBytes) + val formattedExpiry = DateUtils.formatDateTime( + context, + download.expiryTimestamp, + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_SHOW_TIME or + DateUtils.FORMAT_NUMERIC_DATE or + DateUtils.FORMAT_NO_YEAR, + ) + + return context.getString(R.string.mms_info, formattedSize, formattedExpiry) +} + +private fun buildMmsDownloadStatusLineText( + resources: Resources, + statusText: String, + simDisplayName: String?, +): String { + val simAnnotation = simDisplayName + ?.takeIf { displayName -> displayName.isNotBlank() } + ?.let { displayName -> + resources.getString(R.string.conversation_message_sim_annotation, displayName) + } + + return when { + simAnnotation == null -> statusText + else -> "$statusText$MMS_DOWNLOAD_STATUS_SEPARATOR$simAnnotation" + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt new file mode 100644 index 00000000..f53dabe8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt @@ -0,0 +1,312 @@ +package com.android.messaging.ui.conversation.messages.ui.text + +import android.os.SystemClock +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val LINK_CLICK_SUPPRESSION_AFTER_LONG_PRESS_MILLIS = 500L + +internal val LocalConversationMessageLinkColor: ProvidableCompositionLocal = + compositionLocalOf { + null + } + +@Composable +internal fun ConversationMessageText( + modifier: Modifier = Modifier, + text: String, + style: TextStyle, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val applicationContext = LocalContext.current.applicationContext + val currentOnExternalUriClick by rememberUpdatedState(newValue = onExternalUriClick) + val linkStyle = rememberConversationTextLinkStyle() + val suppressLinkClickUntilUptimeMillis = remember { mutableLongStateOf(0L) } + val textWithLinks by produceState( + initialValue = AnnotatedString(text = text), + applicationContext, + text, + linkStyle, + ) { + val links = withContext(Dispatchers.IO) { + extractConversationTextLinks( + context = applicationContext, + text = text, + ) + } + + value = buildConversationLinkedAnnotatedString( + text = text, + links = links, + linkStyle = linkStyle, + onExternalUriClick = { uri -> + if (shouldSuppressConversationTextLinkClick( + suppressUntilUptimeMillis = suppressLinkClickUntilUptimeMillis.longValue, + ) + ) { + suppressLinkClickUntilUptimeMillis.longValue = 0L + return@buildConversationLinkedAnnotatedString + } + + currentOnExternalUriClick(uri) + }, + ) + } + + ConversationMessageTextContent( + modifier = modifier, + text = textWithLinks, + style = style, + onLinkLongPress = onMessageLongClick, + suppressNextLinkClick = { + suppressLinkClickUntilUptimeMillis.longValue = + conversationTextLinkClickSuppressionDeadlineUptimeMillis() + }, + ) +} + +@Composable +private fun ConversationMessageTextContent( + modifier: Modifier = Modifier, + text: AnnotatedString, + style: TextStyle, + onLinkLongPress: () -> Unit, + suppressNextLinkClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + val currentOnLinkLongPress by rememberUpdatedState(newValue = onLinkLongPress) + val currentSuppressNextLinkClick by rememberUpdatedState(newValue = suppressNextLinkClick) + var textLayoutResult by remember { mutableStateOf(null) } + + val hasLinkAnnotations = text.hasLinkAnnotations( + start = 0, + end = text.length, + ) + + val textLongPressModifier = when { + hasLinkAnnotations -> { + Modifier.pointerInput(text, textLayoutResult) { + detectConversationTextLinkLongPresses( + text = text, + textLayoutResult = textLayoutResult, + onLongPress = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + currentOnLinkLongPress() + }, + suppressNextLinkClick = { + currentSuppressNextLinkClick() + }, + ) + } + } + + else -> Modifier + } + + Text( + text = text, + style = style, + modifier = modifier.then(other = textLongPressModifier), + onTextLayout = { result -> + textLayoutResult = result + }, + ) +} + +@Composable +private fun rememberConversationTextLinkStyle(): TextLinkStyles { + val linkColor = LocalConversationMessageLinkColor.current + ?: MaterialTheme.colorScheme.primary + + return remember(linkColor) { + TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) + } +} + +private fun conversationTextLinkClickSuppressionDeadlineUptimeMillis(): Long { + return SystemClock.uptimeMillis() + LINK_CLICK_SUPPRESSION_AFTER_LONG_PRESS_MILLIS +} + +private fun shouldSuppressConversationTextLinkClick(suppressUntilUptimeMillis: Long): Boolean { + return SystemClock.uptimeMillis() <= suppressUntilUptimeMillis +} + +private suspend fun PointerInputScope.detectConversationTextLinkLongPresses( + text: AnnotatedString, + textLayoutResult: TextLayoutResult?, + onLongPress: () -> Unit, + suppressNextLinkClick: () -> Unit, +) { + awaitEachGesture { + val down = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, + ) + + val hasConversationLinkAtPressPosition = textLayoutResult + ?.hasConversationLinkAtPosition(text = text, position = down.position) == true + + if (!hasConversationLinkAtPressPosition) { + return@awaitEachGesture + } + + val isLongPressConfirmed = awaitConversationTextLongPressConfirmation() + + if (isLongPressConfirmed) { + suppressNextLinkClick() + onLongPress() + consumeConversationTextGestureUntilUp() + suppressNextLinkClick() + } + } +} + +private fun TextLayoutResult.hasConversationLinkAtPosition( + text: AnnotatedString, + position: Offset, +): Boolean { + val offset = getOffsetForPosition(position = position) + val endOffset = (offset + 1).coerceAtMost(maximumValue = text.length) + + return when { + offset >= endOffset -> false + else -> { + text.hasLinkAnnotations( + start = offset, + end = endOffset, + ) + } + } +} + +private suspend fun AwaitPointerEventScope.consumeConversationTextGestureUntilUp() { + var isPointerActive = true + + while (isPointerActive) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + event.changes.forEach { change -> + change.consume() + } + + val allPointersUp = event.changes.all { change -> change.changedToUp() } + + if (allPointersUp) { + isPointerActive = false + } + } +} + +private suspend fun AwaitPointerEventScope.awaitConversationTextLongPressConfirmation(): Boolean { + try { + withTimeout(timeMillis = viewConfiguration.longPressTimeoutMillis) { + var isPointerActive = true + + while (isPointerActive) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val hasPointerLeftBounds = event.changes.any { change -> + change.isOutOfBounds( + size = size, + extendedTouchPadding = extendedTouchPadding, + ) + } + + val allPointersUp = event.changes.all { change -> change.changedToUp() } + + if (allPointersUp) { + isPointerActive = false + } + + if (hasPointerLeftBounds) { + isPointerActive = false + } + } + } + } catch (_: PointerEventTimeoutCancellationException) { + return true + } + + return false +} + +private fun buildConversationLinkedAnnotatedString( + text: String, + links: List, + linkStyle: TextLinkStyles, + onExternalUriClick: (String) -> Unit, +): AnnotatedString { + if (links.isEmpty()) { + return AnnotatedString(text) + } + + return buildAnnotatedString { + var currentIndex = 0 + + links.forEach { link -> + if (link.start > currentIndex) { + append(text.substring(currentIndex, link.start)) + } + + withLink( + link = LinkAnnotation.Url( + url = link.url, + styles = linkStyle, + linkInteractionListener = { _ -> + onExternalUriClick(link.url) + }, + ), + ) { + append(text.substring(link.start, link.end)) + } + + currentIndex = link.end + } + + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt new file mode 100644 index 00000000..8cbcf0f3 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt @@ -0,0 +1,110 @@ +package com.android.messaging.ui.conversation.messages.ui.text + +import android.content.Context +import android.net.Uri +import android.view.textclassifier.TextClassificationManager +import android.view.textclassifier.TextClassifier +import android.view.textclassifier.TextLinks +import android.webkit.URLUtil +import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink + +private data class ConversationLinkText( + val start: Int, + val end: Int, + val entityType: String, + val rawLinkText: String, +) + +internal fun extractConversationTextLinks( + context: Context, + text: String, +): List { + if (text.isBlank()) { + return emptyList() + } + + val request = TextLinks.Request.Builder(text) + .setEntityConfig(CONVERSATION_TEXT_LINK_ENTITY_CONFIG) + .build() + + val textClassifier = context + .getSystemService(TextClassificationManager::class.java) + ?.textClassifier + ?: TextClassifier.NO_OP + + return textClassifier.generateLinks(request) + .links + .asSequence() + .mapNotNull { textLink -> + textLink.toConversationTextLink(text = text) + } + .sortedBy { it.start } + .toList() +} + +private fun TextLinks.TextLink.toConversationTextLink( + text: String, +): ConversationTextLink? { + return toValidatedConversationLinkText(text = text) + ?.let { linkText -> + resolveConversationTextLinkUrl( + entityType = linkText.entityType, + rawLinkText = linkText.rawLinkText, + )?.let { url -> + ConversationTextLink( + start = linkText.start, + end = linkText.end, + url = url, + ) + } + } +} + +private fun TextLinks.TextLink.toValidatedConversationLinkText( + text: String, +): ConversationLinkText? { + val isValidLinkText = start in 0.. 0 + + return when { + isValidLinkText -> { + text + .substring(startIndex = start, endIndex = end) + .takeIf { it.isNotBlank() } + ?.let { rawLinkText -> + ConversationLinkText( + start = start, + end = end, + entityType = getEntity(0), + rawLinkText = rawLinkText, + ) + } + } + + else -> null + } +} + +private fun resolveConversationTextLinkUrl( + entityType: String, + rawLinkText: String, +): String? { + return when (entityType) { + TextClassifier.TYPE_ADDRESS -> "geo:0,0?q=${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_EMAIL -> "mailto:${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_PHONE -> "tel:${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_URL -> URLUtil.guessUrl(rawLinkText) + else -> null + } +} + +private val CONVERSATION_TEXT_LINK_ENTITY_CONFIG = TextClassifier.EntityConfig.Builder() + .setIncludedTypes( + listOf( + TextClassifier.TYPE_ADDRESS, + TextClassifier.TYPE_EMAIL, + TextClassifier.TYPE_PHONE, + TextClassifier.TYPE_URL, + ), + ) + .includeTypesFromTextClassifier(false) + .build() diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt new file mode 100644 index 00000000..638eab7e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -0,0 +1,203 @@ +package com.android.messaging.ui.conversation.metadata.delegate + +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +internal interface ConversationMetadataDelegate : + ConversationScreenDelegate { + val effects: Flow + val isDeleteConversationConfirmationVisible: StateFlow + + fun onArchiveConversationClick() + fun onUnarchiveConversationClick() + fun onAddContactClick() + fun onDeleteConversationClick() + fun confirmDeleteConversation() + fun dismissDeleteConversationConfirmation() +} + +internal class ConversationMetadataDelegateImpl @Inject constructor( + private val checkConversationActionRequirements: CheckConversationActionRequirements, + private val conversationsRepository: ConversationsRepository, + private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMetadataDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _state = MutableStateFlow( + value = ConversationMetadataUiState.Loading, + ) + private val _isDeleteConversationConfirmationVisible = MutableStateFlow(value = false) + + override val effects = _effects.asSharedFlow() + override val state = _state.asStateFlow() + override val isDeleteConversationConfirmationVisible = + _isDeleteConversationConfirmationVisible.asStateFlow() + + private var boundScope: CoroutineScope? = null + private var boundConversationIdFlow: StateFlow? = null + private var latestMetadata: ConversationMetadata? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + boundConversationIdFlow = conversationIdFlow + + scope.launch(defaultDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + _state.value = ConversationMetadataUiState.Loading + _isDeleteConversationConfirmationVisible.value = false + latestMetadata = null + + if (conversationId == null) { + return@collectLatest + } + + conversationsRepository + .getConversationMetadata(conversationId = conversationId) + .onEach { metadata -> latestMetadata = metadata } + .map { metadata -> + when { + metadata != null -> { + conversationMetadataUiStateMapper.map(metadata = metadata) + } + else -> ConversationMetadataUiState.Unavailable + } + } + .flowOn(defaultDispatcher) + .collect { currentMetadataState -> + _state.value = currentMetadataState + } + } + } + } + + override fun onArchiveConversationClick() { + val conversationId = currentConversationId ?: return + + boundScope?.launch(defaultDispatcher) { + conversationsRepository.archiveConversation(conversationId = conversationId) + _effects.emit(ConversationScreenEffect.CloseConversation) + } + } + + override fun onUnarchiveConversationClick() { + val conversationId = currentConversationId ?: return + + boundScope?.launch(defaultDispatcher) { + conversationsRepository.unarchiveConversation(conversationId = conversationId) + } + } + + override fun onAddContactClick() { + val destination = (_state.value as? ConversationMetadataUiState.Present) + ?.otherParticipantPhoneNumber + ?.takeIf { it.isNotBlank() } + ?: return + + boundScope?.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.LaunchAddContactFlow(destination = destination), + ) + } + } + + override fun onDeleteConversationClick() { + if (currentConversationId == null) { + return + } + + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + _isDeleteConversationConfirmationVisible.value = true + } + + ConversationActionRequirementsResult.SmsNotCapable -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + ) + } + + ConversationActionRequirementsResult.NoPreferredSmsSim -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + ) + } + + ConversationActionRequirementsResult.MissingDefaultSmsRole -> { + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = false, + ), + ) + } + } + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) + } + } + + override fun confirmDeleteConversation() { + val conversationId = currentConversationId ?: return + val cutoffTimestamp = latestMetadata?.sortTimestamp ?: System.currentTimeMillis() + + _isDeleteConversationConfirmationVisible.value = false + + boundScope?.launch(defaultDispatcher) { + conversationsRepository.deleteConversation( + conversationId = conversationId, + cutoffTimestamp = cutoffTimestamp, + ) + _effects.emit(ConversationScreenEffect.CloseConversation) + } + } + + override fun dismissDeleteConversationConfirmation() { + _isDeleteConversationConfirmationVisible.value = false + } + + private val currentConversationId: String? + get() { + return boundConversationIdFlow + ?.value + ?.takeIf { it.isNotBlank() } + } +} diff --git a/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt new file mode 100644 index 00000000..60d82f4a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -0,0 +1,40 @@ +package com.android.messaging.ui.conversation.metadata.mapper + +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import javax.inject.Inject + +internal interface ConversationMetadataUiStateMapper { + fun map(metadata: ConversationMetadata): ConversationMetadataUiState +} + +internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : + ConversationMetadataUiStateMapper { + + override fun map(metadata: ConversationMetadata): ConversationMetadataUiState { + val avatar = when { + metadata.isGroupConversation -> ConversationMetadataUiState.Avatar.Group + + else -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = metadata.otherParticipantPhotoUri, + ) + } + } + + return ConversationMetadataUiState.Present( + title = metadata.conversationName, + selfParticipantId = metadata.selfParticipantId, + avatar = avatar, + participantCount = metadata.participantCount, + otherParticipantDisplayDestination = metadata.otherParticipantDisplayDestination, + otherParticipantPhoneNumber = metadata + .otherParticipantNormalizedDestination + ?.takeIf(MmsSmsUtils::isPhoneNumber), + otherParticipantContactLookupKey = metadata.otherParticipantContactLookupKey, + isArchived = metadata.isArchived, + composerAvailability = metadata.composerAvailability, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt new file mode 100644 index 00000000..50d1e6c4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt @@ -0,0 +1,48 @@ +package com.android.messaging.ui.conversation.metadata.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason + +@Immutable +internal sealed interface ConversationMetadataUiState { + val composerAvailability: ConversationComposerAvailability + + @Immutable + sealed interface Avatar { + @Immutable + data object Group : Avatar + + @Immutable + data class Single( + val photoUri: String?, + ) : Avatar + } + + @Immutable + data object Loading : ConversationMetadataUiState { + override val composerAvailability = ConversationComposerAvailability.unavailable( + reason = ConversationComposerDisabledReason.CONVERSATION_UNAVAILABLE, + ) + } + + @Immutable + data class Present( + val title: String, + val selfParticipantId: String, + val avatar: Avatar, + val participantCount: Int, + val otherParticipantDisplayDestination: String?, + val otherParticipantPhoneNumber: String?, + val otherParticipantContactLookupKey: String?, + val isArchived: Boolean, + override val composerAvailability: ConversationComposerAvailability, + ) : ConversationMetadataUiState + + @Immutable + data object Unavailable : ConversationMetadataUiState { + override val composerAvailability = ConversationComposerAvailability.unavailable( + reason = ConversationComposerDisabledReason.CONVERSATION_UNAVAILABLE, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt new file mode 100644 index 00000000..75edcd58 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -0,0 +1,649 @@ +package com.android.messaging.ui.conversation.metadata.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Subject +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.GroupAdd +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.SimCard +import androidx.compose.material.icons.rounded.Unarchive +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.text.BidiFormatter +import androidx.core.text.TextDirectionHeuristicsCompat +import coil3.compose.AsyncImage +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.resolveDisplayName +import com.android.messaging.util.AccessibilityUtil + +private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp +private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp +private val CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE = 20.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationTopAppBar( + modifier: Modifier = Modifier, + metadata: ConversationMetadataUiState, + isAddPeopleVisible: Boolean = false, + isCallVisible: Boolean = false, + isArchiveVisible: Boolean = false, + isUnarchiveVisible: Boolean = false, + isAddContactVisible: Boolean = false, + isDeleteConversationVisible: Boolean = false, + isShowSubjectFieldVisible: Boolean = false, + simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), + onAddPeopleClick: () -> Unit, + onCallClick: () -> Unit = {}, + onArchiveClick: () -> Unit = {}, + onUnarchiveClick: () -> Unit = {}, + onAddContactClick: () -> Unit = {}, + onDeleteConversationClick: () -> Unit = {}, + onShowSubjectFieldClick: () -> Unit = {}, + onSimSelectorClick: () -> Unit = {}, + onTitleClick: () -> Unit, + onNavigateBack: () -> Unit, +) { + val presentation = rememberConversationTopAppBarPresentation( + metadata = metadata, + ) + val isTitleClickable = metadata is ConversationMetadataUiState.Present + val overflowVisibility = ConversationTopAppBarOverflowVisibility( + isAddPeopleVisible = isAddPeopleVisible, + isArchiveVisible = isArchiveVisible, + isUnarchiveVisible = isUnarchiveVisible, + isAddContactVisible = isAddContactVisible, + isDeleteConversationVisible = isDeleteConversationVisible, + isShowSubjectFieldVisible = isShowSubjectFieldVisible, + isSimSelectorVisible = simSelector.isAvailable, + ) + + TopAppBar( + modifier = modifier.fillMaxWidth(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + title = { + ConversationTopAppBarTitle( + isClickable = isTitleClickable, + onClick = onTitleClick, + presentation = presentation, + ) + }, + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + actions = { + ConversationTopAppBarActions( + isCallVisible = isCallVisible, + overflowVisibility = overflowVisibility, + simSelectorLabel = simSelector.selectedSubscription + ?.label + ?.resolveDisplayName() + .orEmpty(), + onCallClick = onCallClick, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, + onSimSelectorClick = onSimSelectorClick, + ) + }, + ) +} + +@Composable +private fun rememberConversationTopAppBarPresentation( + metadata: ConversationMetadataUiState, +): ConversationTopAppBarPresentation { + val title = conversationTitle(metadata) + val subtitle = conversationSubtitle(metadata) + val subtitleContentDescription = conversationSubtitleContentDescription( + metadata = metadata, + ) + + val avatar = conversationAvatar(metadata) + + return remember( + metadata, + title, + subtitle, + subtitleContentDescription, + avatar, + ) { + ConversationTopAppBarPresentation( + title = title, + subtitle = subtitle, + subtitleContentDescription = subtitleContentDescription, + avatar = avatar, + ) + } +} + +@Composable +private fun ConversationTopAppBarTitle( + isClickable: Boolean, + onClick: () -> Unit, + presentation: ConversationTopAppBarPresentation, +) { + Row( + modifier = Modifier + .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight) + .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .clickable( + enabled = isClickable, + onClick = onClick, + ), + horizontalArrangement = Arrangement.spacedBy( + space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationAvatar( + avatar = presentation.avatar, + ) + + ConversationTopAppBarText( + presentation = presentation, + ) + } +} + +@Composable +private fun ConversationTopAppBarText( + presentation: ConversationTopAppBarPresentation, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = presentation.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (presentation.subtitle != null) { + Text( + modifier = Modifier.semantics { + presentation.subtitleContentDescription?.let { subtitleContentDescription -> + contentDescription = subtitleContentDescription + } + }, + text = presentation.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ConversationTopAppBarActions( + isCallVisible: Boolean, + overflowVisibility: ConversationTopAppBarOverflowVisibility, + simSelectorLabel: String, + onCallClick: () -> Unit, + onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, + onSimSelectorClick: () -> Unit, +) { + if (isCallVisible) { + IconButton( + modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), + onClick = onCallClick, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = stringResource(id = R.string.action_call), + ) + } + } + + if (overflowVisibility.isOverflowVisible) { + ConversationTopAppBarOverflowMenu( + visibility = overflowVisibility, + simSelectorLabel = simSelectorLabel, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + +@Composable +private fun ConversationTopAppBarOverflowMenu( + visibility: ConversationTopAppBarOverflowVisibility, + simSelectorLabel: String, + onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, + onSimSelectorClick: () -> Unit, +) { + var isExpanded by remember { mutableStateOf(value = false) } + + IconButton( + modifier = Modifier.testTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG), + onClick = { isExpanded = true }, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(id = R.string.action_more_options), + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + ) { + ConversationTopAppBarOverflowMenuContent( + visibility = visibility, + simSelectorLabel = simSelectorLabel, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, + onSimSelectorClick = onSimSelectorClick, + onItemClick = { action -> + isExpanded = false + action() + }, + ) + } +} + +@Composable +private fun ConversationTopAppBarOverflowMenuContent( + visibility: ConversationTopAppBarOverflowVisibility, + simSelectorLabel: String, + onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, + onSimSelectorClick: () -> Unit, + onItemClick: (() -> Unit) -> Unit, +) { + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isSimSelectorVisible, + testTag = CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG, + label = stringResource(R.string.conversation_switch_sims), + secondaryLabel = simSelectorLabel, + icon = Icons.Rounded.SimCard, + onClick = { onItemClick(onSimSelectorClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isAddPeopleVisible, + testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.conversation_add_people), + icon = Icons.Rounded.GroupAdd, + onClick = { onItemClick(onAddPeopleClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isAddContactVisible, + testTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_add_contact), + icon = Icons.Rounded.PersonAdd, + onClick = { onItemClick(onAddContactClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isShowSubjectFieldVisible, + testTag = CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG, + label = stringResource(id = R.string.conversation_show_subject_field), + icon = Icons.AutoMirrored.Rounded.Subject, + onClick = { onItemClick(onShowSubjectFieldClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isArchiveVisible, + testTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_archive), + icon = Icons.Rounded.Archive, + onClick = { onItemClick(onArchiveClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isUnarchiveVisible, + testTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_unarchive), + icon = Icons.Rounded.Unarchive, + onClick = { onItemClick(onUnarchiveClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isDeleteConversationVisible, + testTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_delete), + icon = Icons.Rounded.Delete, + onClick = { onItemClick(onDeleteConversationClick) }, + ) +} + +@Composable +private fun ConversationTopAppBarOverflowMenuItem( + isVisible: Boolean, + testTag: String, + label: String, + icon: ImageVector, + onClick: () -> Unit, + secondaryLabel: String? = null, +) { + if (!isVisible) { + return + } + + DropdownMenuItem( + modifier = Modifier.testTag(tag = testTag), + text = { + when { + secondaryLabel.isNullOrEmpty() -> { + Text(text = label) + } + + else -> { + Column { + Text(text = label) + Text( + text = secondaryLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + onClick = onClick, + ) +} + +@Composable +private fun ConversationAvatar( + avatar: ConversationMetadataUiState.Avatar, +) { + when (avatar) { + ConversationMetadataUiState.Avatar.Group -> { + ConversationAvatarFallback( + icon = Icons.Rounded.Group, + ) + } + + is ConversationMetadataUiState.Avatar.Single -> { + when { + avatar.photoUri.isNullOrBlank() -> { + ConversationAvatarFallback( + icon = Icons.Rounded.Person, + ) + } + + else -> { + AsyncImage( + model = avatar.photoUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size = CONVERSATION_TOP_APP_BAR_AVATAR_SIZE) + .clip(shape = CircleShape), + ) + } + } + } + } +} + +@Composable +private fun ConversationAvatarFallback( + icon: ImageVector, +) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_AVATAR_SIZE), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE), + ) + } + } +} + +private fun conversationAvatar( + metadata: ConversationMetadataUiState, +): ConversationMetadataUiState.Avatar { + return when (metadata) { + ConversationMetadataUiState.Loading -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ) + } + + ConversationMetadataUiState.Unavailable -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ) + } + + is ConversationMetadataUiState.Present -> metadata.avatar + } +} + +@Composable +private fun conversationTitle( + metadata: ConversationMetadataUiState, +): String { + return when (metadata) { + ConversationMetadataUiState.Loading -> stringResource(id = R.string.app_name) + + ConversationMetadataUiState.Unavailable -> stringResource(id = R.string.app_name) + + is ConversationMetadataUiState.Present -> { + metadata + .title + .takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.app_name) + } + } +} + +@Composable +private fun conversationSubtitle( + metadata: ConversationMetadataUiState, +): String? { + return when (metadata) { + ConversationMetadataUiState.Loading -> stringResource(id = R.string.loading_messages) + + ConversationMetadataUiState.Unavailable -> null + + is ConversationMetadataUiState.Present -> { + when { + shouldShowOneOnOneSubtitle(metadata = metadata) -> { + BidiFormatter + .getInstance() + .unicodeWrap( + metadata.otherParticipantDisplayDestination, + TextDirectionHeuristicsCompat.LTR, + ) + } + + metadata.participantCount > 1 -> { + pluralStringResource( + id = R.plurals.wearable_participant_count, + count = metadata.participantCount, + metadata.participantCount, + ) + } + + else -> null + } + } + } +} + +@Composable +private fun conversationSubtitleContentDescription( + metadata: ConversationMetadataUiState, +): String? { + return when (metadata) { + ConversationMetadataUiState.Loading -> null + ConversationMetadataUiState.Unavailable -> null + is ConversationMetadataUiState.Present -> { + metadata.otherParticipantDisplayDestination + ?.takeIf { + shouldShowOneOnOneSubtitle(metadata = metadata) && + metadata.otherParticipantPhoneNumber != null + } + ?.let { displayDestination -> + AccessibilityUtil.getVocalizedPhoneNumber( + LocalResources.current, + displayDestination, + ) + } + ?.takeIf { it.isNotBlank() } + } + } +} + +private fun shouldShowOneOnOneSubtitle( + metadata: ConversationMetadataUiState.Present, +): Boolean { + val displayDestination = metadata.otherParticipantDisplayDestination + ?.takeIf { it.isNotBlank() } + + return when { + displayDestination == null -> false + !metadata.otherParticipantContactLookupKey.isNullOrBlank() -> false + displayDestination.equals(other = metadata.title, ignoreCase = false) -> false + else -> true + } +} + +@Immutable +private data class ConversationTopAppBarPresentation( + val title: String, + val subtitle: String?, + val subtitleContentDescription: String?, + val avatar: ConversationMetadataUiState.Avatar, +) + +@Immutable +private data class ConversationTopAppBarOverflowVisibility( + val isAddPeopleVisible: Boolean, + val isArchiveVisible: Boolean, + val isUnarchiveVisible: Boolean, + val isAddContactVisible: Boolean, + val isDeleteConversationVisible: Boolean, + val isShowSubjectFieldVisible: Boolean, + val isSimSelectorVisible: Boolean, +) { + val isOverflowVisible: Boolean + get() { + return isAddPeopleVisible || + isArchiveVisible || + isUnarchiveVisible || + isAddContactVisible || + isDeleteConversationVisible || + isShowSubjectFieldVisible || + isSimSelectorVisible + } +} diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt new file mode 100644 index 00000000..1653c549 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt @@ -0,0 +1,416 @@ +package com.android.messaging.ui.conversation.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.addparticipants.AddParticipantsScreen +import com.android.messaging.ui.conversation.entry.ConversationEntryScreenModel +import com.android.messaging.ui.conversation.entry.ConversationEntryViewModel +import com.android.messaging.ui.conversation.entry.NewChatScreen +import com.android.messaging.ui.conversation.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.entry.model.ConversationEntryUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerScreen +import com.android.messaging.ui.conversation.screen.ConversationScreen +import com.android.messaging.util.UiUtils + +@Composable +internal fun ConversationNavGraph( + launchRequest: ConversationEntryLaunchRequest?, + modifier: Modifier = Modifier, + onConversationDetailsClick: (String) -> Unit = {}, + onFinish: () -> Unit, + entryModel: ConversationEntryScreenModel = hiltViewModel(), + navigationReducer: ConversationNavigationReducer = defaultConversationNavReducer, +) { + val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() + val backStack = rememberNavBackStack(initialNavKey(launchRequest)) + val routeState = ConversationNavRouteState( + backStack = backStack, + entryModel = rememberUpdatedState(newValue = entryModel), + entryUiState = rememberUpdatedState(newValue = entryUiState), + isLaunchedFromBubble = rememberUpdatedState( + newValue = launchRequest?.isLaunchedFromBubble == true, + ), + navigationReducer = rememberUpdatedState(newValue = navigationReducer), + onConversationDetailsClick = rememberUpdatedState( + newValue = onConversationDetailsClick, + ), + onFinish = rememberUpdatedState(newValue = onFinish), + ) + val entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + val entryProvider = remember(backStack) { + conversationNavEntryProvider(routeState = routeState) + } + val effectState = remember(backStack, entryModel) { + conversationNavEffectState(routeState = routeState, entryModel = entryModel) + } + + ConversationNavGraphEffects( + launchRequest = launchRequest, + effectState = effectState, + ) + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { + handleNavBack( + backStack = backStack, + entryModel = entryModel, + entryUiState = entryUiState, + navigationReducer = navigationReducer, + onFinish = onFinish, + ) + }, + entryDecorators = entryDecorators, + entryProvider = entryProvider, + ) +} + +private fun conversationNavEntryProvider( + routeState: ConversationNavRouteState, +): (NavKey) -> NavEntry { + return entryProvider { + entry( + content = conversationScreenRouteContent(routeState = routeState), + ) + entry( + content = newChatRouteContent(routeState = routeState), + ) + entry( + content = addParticipantsRouteContent(routeState = routeState), + ) + entry { navKey -> + RecipientPickerScreen(mode = navKey.mode) + } + } +} + +private fun conversationScreenRouteContent( + routeState: ConversationNavRouteState, +): @Composable (ConversationNavKey) -> Unit { + return { navKey -> + val conversationId = navKey.conversationId + val entryModel = routeState.entryModel.value + val entryUiState = routeState.entryUiState.value + val navigationReducer = routeState.navigationReducer.value + val pendingPayload = pendingLaunchPayloadForConversation( + entryUiState = entryUiState, + conversationId = conversationId, + ) + + ConversationScreen( + conversationId = conversationId, + launchGeneration = entryUiState.launchGeneration, + cancelIncomingNotification = !routeState.isLaunchedFromBubble.value, + onAddPeopleClick = { + navigationReducer.navigateToAddParticipants( + backStack = routeState.backStack, + conversationId = conversationId, + ) + }, + onConversationDetailsClick = { + routeState.onConversationDetailsClick.value(conversationId) + }, + onNavigateBack = { + popBackStackOrFinish( + backStack = routeState.backStack, + navigationReducer = navigationReducer, + onFinish = routeState.onFinish.value, + ) + }, + pendingDraft = pendingPayload.draft, + pendingScrollPosition = pendingPayload.scrollPosition, + pendingSelfParticipantId = pendingPayload.selfParticipantId, + pendingStartupAttachment = pendingPayload.startupAttachment, + onPendingDraftConsumed = { + entryModel.onDraftPayloadConsumed(conversationId = conversationId) + }, + onPendingScrollPositionConsumed = { + entryModel.onScrollPositionConsumed(conversationId = conversationId) + }, + onPendingSelfParticipantIdConsumed = { + entryModel.onPendingSelfParticipantIdConsumed(conversationId = conversationId) + }, + onPendingStartupAttachmentConsumed = { + entryModel.onStartupAttachmentConsumed(conversationId = conversationId) + }, + ) + } +} + +private fun newChatRouteContent( + routeState: ConversationNavRouteState, +): @Composable (NewChatNavKey) -> Unit { + return { + val entryModel = routeState.entryModel.value + val entryUiState = routeState.entryUiState.value + + NewChatScreen( + isCreatingGroup = entryUiState.isCreatingGroup, + isResolvingConversation = entryUiState.isResolvingConversation, + isResolvingConversationIndicatorVisible = entryUiState + .isResolvingConversationIndicatorVisible, + onContactClick = entryModel::onNewChatRecipientSelected, + onContactLongClick = entryModel::onNewChatRecipientLongPressed, + onCreateGroupClick = entryModel::onCreateGroupRequested, + onCreateGroupConfirmed = entryModel::onCreateGroupConfirmed, + onCreateGroupRecipientClick = entryModel::onCreateGroupRecipientClicked, + onNavigateBack = { + handleNewChatBack( + entryModel = entryModel, + entryUiState = entryUiState, + backStack = routeState.backStack, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + onSimSelected = entryModel::onSimSelected, + resolvingRecipientDestination = entryUiState.resolvingRecipientDestination, + selectedGroupRecipientDestinations = entryUiState.selectedGroupRecipientDestinations, + simSelectorUiState = entryUiState.simSelectorState, + ) + } +} + +private fun addParticipantsRouteContent( + routeState: ConversationNavRouteState, +): @Composable (AddParticipantsNavKey) -> Unit { + return { navKey -> + AddParticipantsScreen( + conversationId = navKey.conversationId, + onNavigateBack = { + popBackStackOrFinish( + backStack = routeState.backStack, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + onNavigateToConversation = { resolvedConversationId -> + routeState.navigationReducer.value.replaceCurrentConversation( + backStack = routeState.backStack, + conversationId = resolvedConversationId, + ) + }, + ) + } +} + +@Composable +private fun ConversationNavGraphEffects( + launchRequest: ConversationEntryLaunchRequest?, + effectState: ConversationNavEffectState, +) { + val latestEffectState = rememberUpdatedState(newValue = effectState) + + LaunchedEffect(launchRequest) { + launchRequest?.let(latestEffectState.value.onLaunchRequest) + latestEffectState.value.onLaunchBackStackUpdate(launchRequest) + } + + LaunchedEffect(effectState.collectEntryEffects) { + effectState.collectEntryEffects { effect -> + latestEffectState.value.onEntryEffect(effect) + } + } +} + +private fun initialNavKey(launchRequest: ConversationEntryLaunchRequest?): NavKey { + return launchRequest + ?.conversationId + ?.let(::ConversationNavKey) + ?: NewChatNavKey +} + +private fun updateBackStackForLaunch( + backStack: MutableList, + launchRequest: ConversationEntryLaunchRequest?, + navigationReducer: ConversationNavigationReducer, +) { + val destination = initialNavKey(launchRequest = launchRequest) + navigationReducer.resetBackStack( + backStack = backStack, + destination = destination, + ) +} + +private fun popBackStackOrFinish( + backStack: MutableList, + navigationReducer: ConversationNavigationReducer, + onFinish: () -> Unit, +) { + if (navigationReducer.popBackStack(backStack = backStack)) { + return + } + + onFinish() +} + +private fun handleNavBack( + backStack: MutableList, + entryModel: ConversationEntryScreenModel, + entryUiState: ConversationEntryUiState, + navigationReducer: ConversationNavigationReducer, + onFinish: () -> Unit, +) { + if (backStack.lastOrNull() == NewChatNavKey && entryUiState.isCreatingGroup) { + entryModel.onCreateGroupCanceled() + return + } + + popBackStackOrFinish( + backStack = backStack, + navigationReducer = navigationReducer, + onFinish = onFinish, + ) +} + +private fun handleNewChatBack( + entryModel: ConversationEntryScreenModel, + entryUiState: ConversationEntryUiState, + backStack: MutableList, + navigationReducer: ConversationNavigationReducer, + onFinish: () -> Unit, +) { + if (entryUiState.isCreatingGroup) { + entryModel.onCreateGroupCanceled() + return + } + + popBackStackOrFinish( + backStack = backStack, + navigationReducer = navigationReducer, + onFinish = onFinish, + ) +} + +private fun pendingLaunchPayloadForConversation( + entryUiState: ConversationEntryUiState, + conversationId: String, +): ConversationPendingLaunchPayload { + if (entryUiState.conversationId != conversationId) { + return ConversationPendingLaunchPayload() + } + + return ConversationPendingLaunchPayload( + draft = entryUiState.pendingDraft, + scrollPosition = entryUiState.pendingScrollPosition, + selfParticipantId = entryUiState.pendingSelfParticipantId, + startupAttachment = entryUiState.pendingStartupAttachment, + ) +} + +private class ConversationNavRouteState( + val backStack: MutableList, + val entryModel: State, + val entryUiState: State, + val isLaunchedFromBubble: State, + val navigationReducer: State, + val onConversationDetailsClick: State<(String) -> Unit>, + val onFinish: State<() -> Unit>, +) + +private typealias ConversationEntryEffectCollector = + suspend ((ConversationEntryEffect) -> Unit) -> Unit + +@Immutable +private data class ConversationNavEffectState( + val onLaunchRequest: (ConversationEntryLaunchRequest) -> Unit, + val onLaunchBackStackUpdate: (ConversationEntryLaunchRequest?) -> Unit, + val collectEntryEffects: ConversationEntryEffectCollector, + val onEntryEffect: (ConversationEntryEffect) -> Unit, +) + +private data class ConversationPendingLaunchPayload( + val draft: ConversationDraft? = null, + val scrollPosition: Int? = null, + val selfParticipantId: String? = null, + val startupAttachment: ConversationEntryStartupAttachment? = null, +) + +private fun conversationNavEffectState( + routeState: ConversationNavRouteState, + entryModel: ConversationEntryScreenModel, +): ConversationNavEffectState { + return ConversationNavEffectState( + onLaunchRequest = entryModel::onLaunchRequest, + onLaunchBackStackUpdate = { launchRequest -> + updateBackStackForLaunch( + backStack = routeState.backStack, + launchRequest = launchRequest, + navigationReducer = routeState.navigationReducer.value, + ) + }, + collectEntryEffects = { onEffect -> + entryModel.effects.collect { effect -> + onEffect(effect) + } + }, + onEntryEffect = { effect -> + handleEntryEffect( + backStack = routeState.backStack, + effect = effect, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + ) +} + +private fun handleEntryEffect( + backStack: MutableList, + effect: ConversationEntryEffect, + navigationReducer: ConversationNavigationReducer, + onFinish: () -> Unit, +) { + when (effect) { + is ConversationEntryEffect.NavigateBack -> { + popBackStackOrFinish( + backStack = backStack, + navigationReducer = navigationReducer, + onFinish = onFinish, + ) + } + + is ConversationEntryEffect.NavigateToConversation -> { + navigationReducer.navigateToConversation( + backStack = backStack, + conversationId = effect.conversationId, + ) + } + + is ConversationEntryEffect.NavigateToRecipientPicker -> { + navigationReducer.navigateToRecipientPicker( + backStack = backStack, + mode = effect.mode, + ) + } + + is ConversationEntryEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + } +} + +private val defaultConversationNavReducer: ConversationNavigationReducer = + ConversationNavigationReducerImpl() diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt new file mode 100644 index 00000000..2900c53b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +internal data object NewChatNavKey : NavKey + +@Serializable +internal data class ConversationNavKey( + val conversationId: String, +) : NavKey + +@Serializable +internal data class RecipientPickerNavKey( + val mode: RecipientPickerMode, +) : NavKey + +@Serializable +internal data class AddParticipantsNavKey( + val conversationId: String, +) : NavKey + +@Serializable +internal enum class RecipientPickerMode { + CREATE_GROUP, + ADD_PARTICIPANTS, +} diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt new file mode 100644 index 00000000..901b1a08 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt @@ -0,0 +1,124 @@ +package com.android.messaging.ui.conversation.navigation + +import androidx.navigation3.runtime.NavKey + +internal interface ConversationNavigationReducer { + fun navigateToAddParticipants( + backStack: MutableList, + conversationId: String, + ) + + fun navigateToConversation( + backStack: MutableList, + conversationId: String, + ) + + fun navigateToRecipientPicker( + backStack: MutableList, + mode: RecipientPickerMode, + ) + + fun popBackStack(backStack: MutableList): Boolean + + fun replaceCurrentConversation( + backStack: MutableList, + conversationId: String, + ) + + fun resetBackStack( + backStack: MutableList, + destination: NavKey, + ) +} + +internal class ConversationNavigationReducerImpl : ConversationNavigationReducer { + + override fun navigateToAddParticipants( + backStack: MutableList, + conversationId: String, + ) { + AddParticipantsNavKey(conversationId = conversationId) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) + } + + override fun navigateToConversation( + backStack: MutableList, + conversationId: String, + ) { + removeTrailingConversationEntryDestinations(backStack = backStack) + + val destination = ConversationNavKey(conversationId = conversationId) + + if (destination == backStack.lastOrNull()) { + return + } + + backStack.add(destination) + } + + override fun navigateToRecipientPicker( + backStack: MutableList, + mode: RecipientPickerMode, + ) { + RecipientPickerNavKey(mode = mode) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) + } + + override fun popBackStack(backStack: MutableList): Boolean { + if (backStack.size <= 1) { + return false + } + + backStack.removeAt(backStack.lastIndex) + return true + } + + override fun replaceCurrentConversation( + backStack: MutableList, + conversationId: String, + ) { + if (backStack.lastOrNull() is AddParticipantsNavKey) { + backStack.removeAt(backStack.lastIndex) + } + + val updatedConversation = ConversationNavKey(conversationId = conversationId) + val currentConversationIndex = backStack.indexOfLast { navKey -> + navKey is ConversationNavKey + } + + if (currentConversationIndex >= 0) { + backStack[currentConversationIndex] = updatedConversation + return + } + + backStack.add(updatedConversation) + } + + override fun resetBackStack( + backStack: MutableList, + destination: NavKey, + ) { + if (backStack.size == 1 && backStack.firstOrNull() == destination) { + return + } + + backStack.clear() + backStack.add(destination) + } + + private fun removeTrailingConversationEntryDestinations(backStack: MutableList) { + while (backStack.lastOrNull().isConversationEntryDestination()) { + backStack.removeAt(backStack.lastIndex) + } + } + + private fun NavKey?.isConversationEntryDestination(): Boolean { + return when (this) { + NewChatNavKey -> true + is RecipientPickerNavKey -> true + else -> false + } + } +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt new file mode 100644 index 00000000..37c83156 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.android.messaging.ui.conversation.recipientpicker + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.ui.conversation.navigation.RecipientPickerMode + +@Composable +internal fun RecipientPickerScreen( + mode: RecipientPickerMode, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = recipientPickerTitle(mode = mode)) + }, + ) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "", + ) + } + } +} + +@Composable +private fun recipientPickerTitle( + mode: RecipientPickerMode, +): String { + return when (mode) { + RecipientPickerMode.ADD_PARTICIPANTS -> { + stringResource(id = R.string.conversation_add_people) + } + + RecipientPickerMode.CREATE_GROUP -> { + stringResource(id = R.string.conversation_new_group) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt new file mode 100644 index 00000000..8adb8e26 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt @@ -0,0 +1,44 @@ +package com.android.messaging.ui.conversation.recipientpicker + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow + +internal interface RecipientPickerModel { + val uiState: StateFlow + + fun onLoadMore() + + fun onExcludedDestinationsChanged(destinations: Set) + + fun onQueryChanged(query: String) +} + +@HiltViewModel +internal class RecipientPickerViewModel @Inject constructor( + private val recipientPickerDelegate: RecipientPickerDelegate, +) : ViewModel(), + RecipientPickerModel { + + override val uiState = recipientPickerDelegate.state + + init { + recipientPickerDelegate.bind(scope = viewModelScope) + } + + override fun onLoadMore() { + recipientPickerDelegate.onLoadMore() + } + + override fun onExcludedDestinationsChanged(destinations: Set) { + recipientPickerDelegate.onExcludedDestinationsChanged(destinations = destinations) + } + + override fun onQueryChanged(query: String) { + recipientPickerDelegate.onQueryChanged(query = query) + } +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt new file mode 100644 index 00000000..3a71ca47 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt @@ -0,0 +1,220 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.messaging.R +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem + +@Composable +internal fun RecipientSelectionContactAvatar( + item: RecipientPickerListItem, + isSelected: Boolean, +) { + val avatarScale by rememberRecipientSelectionContactAvatarScale( + isSelected = isSelected, + ) + + AnimatedContent( + targetState = isSelected, + transitionSpec = { + ( + fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ), + ) + }, + label = "recipientSelectionContactAvatar", + ) { isSelectedState -> + Box( + modifier = Modifier.graphicsLayer { + scaleX = avatarScale + scaleY = avatarScale + }, + ) { + when { + isSelectedState -> { + RecipientSelectionSelectedAvatar() + } + + recipientSelectionPhotoUri(item = item) == null -> { + RecipientSelectionTextAvatar(item = item) + } + + else -> { + AsyncImage( + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape), + model = recipientSelectionPhotoUri(item = item), + contentDescription = recipientSelectionItemDisplayName(item = item), + ) + } + } + } + } +} + +@Composable +private fun RecipientSelectionSelectedAvatar( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +private fun RecipientSelectionTextAvatar( + item: RecipientPickerListItem, + modifier: Modifier = Modifier, +) { + val displayName = recipientSelectionItemDisplayName(item = item) + val avatarSourceText = recipientSelectionAvatarSourceText(item = item) + val label = remember(displayName, avatarSourceText) { + recipientSelectionAvatarLabel( + displayName = displayName, + destination = avatarSourceText, + ) + } + + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +internal fun recipientSelectionItemDisplayName( + item: RecipientPickerListItem, +): String { + return when (item) { + is RecipientPickerListItem.Contact -> item.contact.displayName + is RecipientPickerListItem.SyntheticPhone -> { + stringResource( + id = R.string.contact_list_send_to_text, + item.rawQuery, + ) + } + } +} + +private fun recipientSelectionPhotoUri(item: RecipientPickerListItem): String? { + return when (item) { + is RecipientPickerListItem.Contact -> item.contact.photoUri + is RecipientPickerListItem.SyntheticPhone -> null + } +} + +private fun recipientSelectionAvatarSourceText(item: RecipientPickerListItem): String { + return when (item) { + is RecipientPickerListItem.Contact -> { + item.contact.destinations.firstOrNull()?.value.orEmpty() + } + + is RecipientPickerListItem.SyntheticPhone -> item.destination + } +} + +private fun recipientSelectionAvatarLabel( + displayName: String, + destination: String, +): String { + val labelSource = displayName.ifBlank { destination } + val firstCharacter = labelSource.firstOrNull() ?: '?' + + return firstCharacter.uppercaseChar().toString() +} + +@Composable +private fun rememberRecipientSelectionContactAvatarScale( + isSelected: Boolean, +): State { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactAvatarScale", + ) + + return selectionTransition.animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "recipientSelectionContactAvatarScaleValue", + targetValueByState = { isAvatarSelected -> + when { + isAvatarSelected -> 1f + else -> 0.9f + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt new file mode 100644 index 00000000..301f862f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt @@ -0,0 +1,635 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import kotlinx.collections.immutable.ImmutableSet + +private val contactCornerRadius = 18.dp +private val contactMiddleCornerRadius = 2.dp +private val avatarSize = 40.dp +private val avatarToTextSpacing = 14.dp +private val rowHorizontalPadding = 16.dp +private val rowVerticalPadding = 14.dp +private val destinationIndentation = avatarSize + avatarToTextSpacing +private val destinationVerticalPadding = 10.dp + +private val topContactShape = RoundedCornerShape( + topStart = contactCornerRadius, + topEnd = contactCornerRadius, + bottomStart = contactMiddleCornerRadius, + bottomEnd = contactMiddleCornerRadius, +) +private val bottomContactShape = RoundedCornerShape( + topStart = contactMiddleCornerRadius, + topEnd = contactMiddleCornerRadius, + bottomStart = contactCornerRadius, + bottomEnd = contactCornerRadius, +) +private val middleContactShape = RoundedCornerShape(size = contactMiddleCornerRadius) +private val singleContactShape = RoundedCornerShape(size = contactCornerRadius) + +@Composable +internal fun RecipientSelectionContactRow( + item: RecipientPickerListItem, + enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, + onDestinationLongClick: ((destination: String) -> Unit)? = null, +) { + when (item) { + is RecipientPickerListItem.Contact -> { + ContactRow( + modifier = modifier, + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = shape, + rowDecorators = rowDecorators, + ) + } + + is RecipientPickerListItem.SyntheticPhone -> { + SyntheticPhoneRow( + modifier = modifier, + item = item, + enabled = enabled, + isSelected = selectedDestinations.contains(item.normalizedDestination), + onClick = { onDestinationClick(item.normalizedDestination) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(item.normalizedDestination) } + }, + shape = shape, + rowDecorators = rowDecorators, + ) + } + } +} + +@Composable +private fun ContactRow( + item: RecipientPickerListItem.Contact, + enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + onDestinationLongClick: ((destination: String) -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + val destinations = item.destinations + val isSingleDestination = destinations.size <= 1 + val singleDestination = destinations.firstOrNull() + val isSingleSelected = singleDestination != null && + selectedDestinations.contains(singleDestination.normalizedValue) + + when { + isSingleDestination && singleDestination != null -> { + SingleDestinationContactRow( + modifier = modifier, + item = item, + destination = singleDestination, + enabled = enabled, + isSelected = isSingleSelected, + onClick = { onDestinationClick(singleDestination.normalizedValue) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(singleDestination.normalizedValue) } + }, + shape = shape, + rowDecorators = rowDecorators, + ) + } + + else -> { + MultiDestinationContactRow( + modifier = modifier, + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = shape, + rowDecorators = rowDecorators, + ) + } + } +} + +@Composable +private fun SingleDestinationContactRow( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + Row( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowDecorators.recipientRowTestTag(item)) + .semantics { selected = isSelected } + .background(color = containerColor, shape = shape) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding(horizontal = rowHorizontalPadding, vertical = rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + item = item, + isSelected = isSelected, + ) + + ContactPrimaryAndSecondaryText( + primaryText = item.contact.displayName, + secondaryText = destination.displayValue, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = rowDecorators.showRecipientTrailingIndicator( + item, + destination.normalizedValue, + ), + testTag = rowDecorators.trailingIndicatorTestTag, + ) + } +} + +@Composable +private fun SyntheticPhoneRow( + item: RecipientPickerListItem.SyntheticPhone, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + Row( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowDecorators.recipientRowTestTag(item)) + .semantics { selected = isSelected } + .background(color = containerColor, shape = shape) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding(horizontal = rowHorizontalPadding, vertical = rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + item = item, + isSelected = isSelected, + ) + + ContactPrimaryAndSecondaryText( + primaryText = recipientSelectionItemDisplayName(item = item), + secondaryText = item.secondaryText, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = rowDecorators.showRecipientTrailingIndicator( + item, + item.normalizedDestination, + ), + testTag = rowDecorators.trailingIndicatorTestTag, + ) + } +} + +@Composable +private fun MultiDestinationContactRow( + item: RecipientPickerListItem.Contact, + enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + onDestinationLongClick: ((destination: String) -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowDecorators.recipientRowTestTag(item)) + .background( + color = MaterialTheme.colorScheme.background, + shape = shape, + ) + .padding( + horizontal = rowHorizontalPadding, + vertical = rowVerticalPadding, + ), + ) { + MultiDestinationContactHeader(item = item) + + Column( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + item.destinations.forEach { destination -> + MultiDestinationMiniRow( + item = item, + destination = destination, + enabled = enabled, + isSelected = selectedDestinations.contains(destination.normalizedValue), + onClick = { onDestinationClick(destination.normalizedValue) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(destination.normalizedValue) } + }, + rowDecorators = rowDecorators, + ) + } + } + } +} + +@Composable +private fun MultiDestinationContactHeader( + item: RecipientPickerListItem.Contact, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + item = item, + isSelected = false, + ) + + Text( + modifier = Modifier + .padding(start = avatarToTextSpacing) + .weight(weight = 1f), + text = item.contact.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun MultiDestinationMiniRow( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + rowDecorators: RecipientSelectionRowDecorators, +) { + val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactDestinationSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + val label = rememberDestinationLabel(destination = destination) + + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(rowDecorators.destinationRowTestTag(item, destination.value)) + .semantics { selected = isSelected } + .background(color = containerColor) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding( + start = destinationIndentation, + end = 12.dp, + top = destinationVerticalPadding, + bottom = destinationVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + MultiDestinationMiniRowContent( + item = item, + destination = destination, + label = label, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + rowDecorators = rowDecorators, + ) + } +} + +@Composable +private fun RowScope.MultiDestinationMiniRowContent( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + label: String, + primaryTextColor: Color, + secondaryTextColor: Color, + rowDecorators: RecipientSelectionRowDecorators, +) { + Text( + modifier = Modifier.weight(weight = 1f), + text = destination.displayValue, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = primaryTextColor, + ) + + Text( + modifier = Modifier.padding(start = 12.dp), + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = rowDecorators.showRecipientTrailingIndicator( + item, + destination.normalizedValue, + ), + testTag = rowDecorators.trailingIndicatorTestTag, + ) +} + +@Composable +private fun rememberDestinationLabel( + destination: ContactDestination, +): String { + val resources = LocalResources.current + + return remember(destination.kind, destination.type, destination.customLabel) { + val label = when (destination.kind) { + ContactDestination.Kind.PHONE -> { + Phone.getTypeLabel(resources, destination.type, destination.customLabel) + } + + ContactDestination.Kind.EMAIL -> { + Email.getTypeLabel(resources, destination.type, destination.customLabel) + } + } + + label?.toString().orEmpty() + } +} + +@Composable +private fun RowScope.ContactPrimaryAndSecondaryText( + primaryText: String, + secondaryText: String?, + primaryTextColor: Color, + secondaryTextColor: Color, +) { + Column( + modifier = Modifier + .padding(start = avatarToTextSpacing) + .weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = primaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = primaryTextColor, + ) + + if (secondaryText != null) { + Text( + text = secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = secondaryTextColor, + ) + } + } +} + +@Composable +private fun RecipientSelectionTrailingIndicator( + visible: Boolean, + testTag: String?, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ), + exit = fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ), + ) { + CircularProgressIndicator( + modifier = when { + testTag != null -> { + Modifier + .size(size = 20.dp) + .testTag(testTag) + } + + else -> { + Modifier.size(size = 20.dp) + } + }, + strokeWidth = 2.dp, + ) + } +} + +internal fun recipientSelectionContactRowShape( + index: Int, + totalCount: Int, +): RoundedCornerShape { + return when { + totalCount <= 1 -> singleContactShape + index == 0 -> topContactShape + index == totalCount - 1 -> bottomContactShape + else -> middleContactShape + } +} + +@Composable +private fun Transition.animateContainerColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactContainerColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.background + } + }, + ) +} + +@Composable +private fun Transition.animatePrimaryTextColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactPrimaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + }, + ) +} + +@Composable +private fun Transition.animateSecondaryTextColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactSecondaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + } + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt new file mode 100644 index 00000000..043fb155 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt @@ -0,0 +1,340 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState + +private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 +private const val RECIPIENT_CONTACT_CONTENT_TYPE = "recipient_contact" + +@Composable +internal fun RecipientSelectionContactsContent( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onLoadMore: () -> Unit, + onPrimaryActionClick: () -> Unit, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, + modifier: Modifier = Modifier, + topListContent: (@Composable () -> Unit)? = null, +) { + val primaryAction = uiState.primaryAction + + Box(modifier = modifier) { + RecipientSelectionContactsList( + uiState = uiState, + rowDecorators = rowDecorators, + onLoadMore = onLoadMore, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + topListContent = topListContent, + ) + + AnimatedVisibility( + modifier = Modifier.align(alignment = Alignment.BottomEnd), + visible = primaryAction != null, + enter = recipientSelectionPrimaryActionEnterTransition(), + exit = recipientSelectionPrimaryActionExitTransition(), + ) { + RecipientSelectionPrimaryActionButton( + modifier = Modifier + .navigationBarsPadding() + .padding(end = 8.dp, bottom = 8.dp), + enabled = primaryAction?.isEnabled ?: false, + isLoading = primaryAction?.isLoading ?: false, + text = primaryAction?.text.orEmpty(), + testTag = primaryAction?.testTag, + onClick = onPrimaryActionClick, + ) + } + } +} + +@Composable +private fun RecipientSelectionContactsList( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onLoadMore: () -> Unit, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, + topListContent: (@Composable () -> Unit)?, +) { + val pickerUiState = uiState.picker + val listState = rememberLazyListState() + val animatedListBottomPadding by animateDpAsState( + targetValue = when { + uiState.primaryAction != null -> 100.dp + else -> 16.dp + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "recipientSelectionListBottomPadding", + ) + + RecipientSelectionLoadMoreEffect( + listState = listState, + pickerUiState = pickerUiState, + onLoadMore = onLoadMore, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(bottom = animatedListBottomPadding), + ) { + topListContent?.let { + item { + topListContent() + } + } + + recipientSelectionContactItems( + uiState = uiState, + rowDecorators = rowDecorators, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + ) + } +} + +private fun LazyListScope.recipientSelectionContactItems( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, +) { + val pickerUiState = uiState.picker + + when { + pickerUiState.isLoading -> { + item { + RecipientSelectionLoadingState() + } + } + + pickerUiState.items.isEmpty() -> { + item { + RecipientSelectionEmptyState() + } + } + + else -> { + itemsIndexed( + items = pickerUiState.items, + key = { _, item -> item.id }, + contentType = { _, _ -> RECIPIENT_CONTACT_CONTENT_TYPE }, + ) { index, item -> + RecipientSelectionContactItem( + item = item, + index = index, + uiState = uiState, + rowDecorators = rowDecorators, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + ) + } + } + } + + if (pickerUiState.isLoadingMore) { + item { + RecipientSelectionLoadingMoreState() + } + } +} + +@Composable +private fun RecipientSelectionContactItem( + item: RecipientPickerListItem, + index: Int, + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, +) { + val lastContactIndex = uiState.picker.items.lastIndex + val bottomPadding = when { + index == lastContactIndex -> 0.dp + else -> 2.dp + } + + RecipientSelectionContactRow( + modifier = Modifier.padding(bottom = bottomPadding), + item = item, + enabled = uiState.primaryAction?.isLoading != true, + selectedDestinations = uiState.selectedRecipientDestinations, + onDestinationClick = { destination -> + onRecipientDestinationClick(item, destination) + }, + onDestinationLongClick = onRecipientDestinationLongClick?.let { callback -> + { destination -> + callback(item, destination) + } + }, + rowDecorators = rowDecorators, + shape = recipientSelectionContactRowShape( + index = index, + totalCount = uiState.picker.items.size, + ), + ) +} + +@Composable +private fun RecipientSelectionLoadMoreEffect( + listState: LazyListState, + pickerUiState: RecipientPickerUiState, + onLoadMore: () -> Unit, +) { + LaunchedEffect( + listState, + pickerUiState.canLoadMore, + pickerUiState.isLoading, + pickerUiState.isLoadingMore, + pickerUiState.items.size, + ) { + snapshotFlow { + val lastVisibleIndex = listState + .layoutInfo + .visibleItemsInfo + .lastOrNull() + ?.index + ?: -1 + + lastVisibleIndex >= pickerUiState.items.lastIndex - CONTACTS_LOAD_MORE_THRESHOLD + }.collect { isNearEnd -> + if ( + shouldRequestRecipientSelectionLoadMore( + isNearEnd = isNearEnd, + pickerUiState = pickerUiState, + ) + ) { + onLoadMore() + } + } + } +} + +private fun shouldRequestRecipientSelectionLoadMore( + isNearEnd: Boolean, + pickerUiState: RecipientPickerUiState, +): Boolean { + return isNearEnd && + pickerUiState.canLoadMore && + !pickerUiState.isLoading && + !pickerUiState.isLoadingMore +} + +private fun recipientSelectionPrimaryActionEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = tween(durationMillis = 200), + ) + slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.9f, + ) +} + +private fun recipientSelectionPrimaryActionExitTransition(): ExitTransition { + return fadeOut( + animationSpec = tween(durationMillis = 150), + ) + slideOutVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.9f, + ) +} + +@Composable +private fun RecipientSelectionLoadingState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun RecipientSelectionLoadingMoreState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(size = 20.dp), + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun RecipientSelectionEmptyState() { + Text( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 4.dp), + text = stringResource(id = R.string.contact_list_empty_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt new file mode 100644 index 00000000..c4736f2a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt @@ -0,0 +1,166 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, +) + +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +private val searchCardShape = RoundedCornerShape(size = 22.dp) + +@Composable +internal fun RecipientSelectionContent( + uiState: RecipientSelectionContentUiState, + strings: RecipientSelectionStrings, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientDestinationClick: OnRecipientDestinationAction, + modifier: Modifier = Modifier, + autoFocusQuery: Boolean = false, + onLoadMore: () -> Unit = {}, + onPrimaryActionClick: () -> Unit = {}, + onQueryChanged: (String) -> Unit = {}, + onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, + simSelectorSlot: (@Composable () -> Unit)? = null, + topListContent: (@Composable () -> Unit)? = null, +) { + val queryFocusRequester = remember { FocusRequester() } + + if (autoFocusQuery) { + LaunchedEffect(Unit) { + queryFocusRequester.requestFocus() + } + } + + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(height = 16.dp)) + + RecipientSelectionQueryCard( + query = uiState.picker.query, + enabled = uiState.isQueryEnabled, + prefixText = strings.queryPrefixText, + placeholderText = strings.queryPlaceholderText, + onQueryChanged = onQueryChanged, + focusRequester = queryFocusRequester, + simSelectorSlot = simSelectorSlot, + ) + + Spacer(modifier = Modifier.height(height = 12.dp)) + + RecipientSelectionContactsContent( + modifier = Modifier.fillMaxSize(), + uiState = uiState, + rowDecorators = rowDecorators, + onLoadMore = onLoadMore, + onPrimaryActionClick = onPrimaryActionClick, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, + topListContent = topListContent, + ) + } + } +} + +@Composable +private fun RecipientSelectionQueryCard( + query: String, + enabled: Boolean, + prefixText: String, + placeholderText: String, + onQueryChanged: (String) -> Unit, + focusRequester: FocusRequester, + simSelectorSlot: (@Composable () -> Unit)?, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = searchCardShape, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + RecipientSelectionQueryField( + query = query, + enabled = enabled, + prefixText = prefixText, + placeholderText = placeholderText, + onQueryChanged = onQueryChanged, + focusRequester = focusRequester, + ) + + simSelectorSlot?.invoke() + } + } +} + +@Composable +private fun RecipientSelectionQueryField( + query: String, + enabled: Boolean, + prefixText: String, + placeholderText: String, + onQueryChanged: (String) -> Unit, + focusRequester: FocusRequester, +) { + TextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester = focusRequester), + value = query, + onValueChange = onQueryChanged, + enabled = enabled, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + prefix = { + Text( + modifier = Modifier.padding(end = 12.dp), + text = prefixText, + style = MaterialTheme.typography.bodyLarge, + ) + }, + placeholder = { + Text(text = placeholderText) + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt new file mode 100644 index 00000000..909e3072 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt @@ -0,0 +1,49 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +internal typealias OnRecipientDestinationAction = + (item: RecipientPickerListItem, destination: String) -> Unit + +internal typealias RecipientRowTestTagProvider = + (item: RecipientPickerListItem) -> String + +internal typealias RecipientDestinationTestTagProvider = + (item: RecipientPickerListItem, destination: String) -> String + +internal typealias ShouldShowRecipientTrailingIndicator = + (item: RecipientPickerListItem, destination: String) -> Boolean + +@Immutable +internal data class RecipientSelectionContentUiState( + val picker: RecipientPickerUiState = RecipientPickerUiState(), + val primaryAction: RecipientSelectionPrimaryActionUiState? = null, + val selectedRecipientDestinations: ImmutableSet = persistentSetOf(), + val isQueryEnabled: Boolean = true, +) + +@Immutable +internal data class RecipientSelectionPrimaryActionUiState( + val text: String, + val isEnabled: Boolean = false, + val isLoading: Boolean = false, + val testTag: String? = null, +) + +@Immutable +internal data class RecipientSelectionStrings( + val queryPrefixText: String, + val queryPlaceholderText: String, +) + +internal data class RecipientSelectionRowDecorators( + val recipientRowTestTag: RecipientRowTestTagProvider, + val destinationRowTestTag: RecipientDestinationTestTagProvider = + { item, _ -> recipientRowTestTag(item) }, + val showRecipientTrailingIndicator: ShouldShowRecipientTrailingIndicator = { _, _ -> false }, + val trailingIndicatorTestTag: String? = null, +) diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt new file mode 100644 index 00000000..de9add3a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt @@ -0,0 +1,114 @@ +package com.android.messaging.ui.conversation.recipientpicker.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp + +@Composable +internal fun RecipientSelectionPrimaryActionButton( + enabled: Boolean, + isLoading: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, +) { + val taggedModifier = when { + testTag != null -> modifier.testTag(testTag) + else -> modifier + } + + Button( + modifier = taggedModifier + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(size = 18.dp), + ) { + AnimatedContent( + targetState = isLoading, + transitionSpec = { + recipientSelectionPrimaryActionContentTransform() + }, + label = "recipientSelectionPrimaryActionButtonContent", + ) { isButtonLoading -> + when { + isButtonLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(size = 18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } + + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = text) + + Spacer(modifier = Modifier.size(size = 8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + ) + } + } + } + } + } +} + +private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { + return ( + fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.9f, + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.9f, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt new file mode 100644 index 00000000..cd61303b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt @@ -0,0 +1,231 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.simselector + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.composer.ui.ConversationSimAvatar +import com.android.messaging.ui.conversation.newChatSimSelectorItemTestTag +import com.android.messaging.ui.conversation.resolveDisplayName +import kotlinx.collections.immutable.ImmutableList + +private val ChipShape = RoundedCornerShape(size = 16.dp) +private val ChipAvatarSize = 24.dp +private val DropdownAvatarSize = 32.dp + +@Composable +internal fun NewChatSimSelectorRow( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (!uiState.isAvailable) { + return + } + + val selectedSubscription = uiState.selectedSubscription ?: return + + var isDropdownExpanded by rememberSaveable { mutableStateOf(value = false) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 4.dp, + bottom = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + Text( + text = stringResource(id = R.string.new_chat_sim_selector_prefix), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + NewChatSimSelectorChip( + subscription = selectedSubscription, + onClick = { isDropdownExpanded = true }, + ) + + NewChatSimSelectorDropdown( + expanded = isDropdownExpanded, + subscriptions = uiState.subscriptions, + selectedSubscription = selectedSubscription, + onSimSelected = onSimSelected, + onDismissRequest = { isDropdownExpanded = false }, + ) + } + } +} + +@Composable +private fun NewChatSimSelectorChip( + subscription: Subscription, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = subscription.label.resolveDisplayName() + val chipDescription = stringResource( + id = R.string.new_chat_sim_selector_chip_content_description, + label, + ) + + Row( + modifier = modifier + .clip(shape = ChipShape) + .background(color = MaterialTheme.colorScheme.surfaceVariant) + .clickable(role = Role.Button, onClick = onClick) + .testTag(tag = NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .semantics { contentDescription = chipDescription } + .padding( + start = 6.dp, + end = 8.dp, + top = 4.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + ConversationSimAvatar( + subscription = subscription, + size = ChipAvatarSize, + ) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun NewChatSimSelectorDropdown( + expanded: Boolean, + subscriptions: ImmutableList, + selectedSubscription: Subscription?, + onSimSelected: (String) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + DropdownMenu( + modifier = modifier + .testTag(tag = NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG), + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + subscriptions.forEach { subscription -> + val isSelected = subscription.selfParticipantId == + selectedSubscription?.selfParticipantId + + NewChatSimSelectorDropdownItem( + subscription = subscription, + isSelected = isSelected, + onClick = { + onSimSelected(subscription.selfParticipantId) + onDismissRequest() + }, + ) + } + } +} + +@Composable +private fun NewChatSimSelectorDropdownItem( + subscription: Subscription, + isSelected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = Modifier + .testTag( + tag = newChatSimSelectorItemTestTag( + selfParticipantId = subscription.selfParticipantId, + ), + ), + onClick = onClick, + leadingIcon = { + ConversationSimAvatar( + subscription = subscription, + size = DropdownAvatarSize, + ) + }, + text = { + Row( + modifier = Modifier + .size(width = 240.dp, height = DropdownAvatarSize), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + Column(modifier = Modifier.weight(weight = 1f)) { + Text( + text = subscription.label.resolveDisplayName(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + subscription.displayDestination?.let { destination -> + Text( + text = destination, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource( + id = R.string.sim_selector_item_selected, + ), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt new file mode 100644 index 00000000..70d8491d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -0,0 +1,583 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.data.contact.repository.ContactsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.util.PhoneUtils +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal interface RecipientPickerDelegate { + val state: StateFlow + + fun bind(scope: CoroutineScope) + + fun onLoadMore() + + fun onExcludedDestinationsChanged(destinations: Set) + + fun onQueryChanged(query: String) +} + +internal class RecipientPickerDelegateImpl @Inject constructor( + private val contactDestinationFormatter: ContactDestinationFormatter, + private val contactsRepository: ContactsRepository, + private val isReadContactsPermissionGranted: IsReadContactsPermissionGranted, + private val savedStateHandle: SavedStateHandle, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : RecipientPickerDelegate { + + private val queryFlow = MutableStateFlow( + value = savedStateHandle.get(SEARCH_QUERY_KEY).orEmpty(), + ) + private val excludedDestinationsFlow = MutableStateFlow>( + value = emptySet(), + ) + + private val _state = MutableStateFlow( + value = RecipientPickerUiState( + query = queryFlow.value, + isLoading = false, + ), + ) + + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + + private var searchSession = ContactSearchSession( + effectiveQuery = queryFlow.value, + hasCompletedInitialLoad = false, + nextPageOffset = null, + ) + + private val searchSessionMutex = Mutex() + + override fun bind(scope: CoroutineScope) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + combine( + queryFlow, + excludedDestinationsFlow, + ) { query, excludedDestinations -> + SearchInputs( + query = query, + excludedDestinations = excludedDestinations, + ) + }.collectLatest { searchInputs -> + handleSearchInputsChanged(searchInputs = searchInputs) + } + } + } + + override fun onLoadMore() { + boundScope?.launch(defaultDispatcher) { + val loadMoreRequest = createLoadMoreRequest() ?: return@launch + loadMore(request = loadMoreRequest) + } + } + + override fun onExcludedDestinationsChanged(destinations: Set) { + val canonicalDestinations = destinations + .asSequence() + .map { contactDestinationFormatter.canonicalize(value = it) } + .filter { it.isNotEmpty() } + .toSet() + + excludedDestinationsFlow.value = canonicalDestinations + } + + override fun onQueryChanged(query: String) { + updateQueryInState(query = query) + + if (query != queryFlow.value) { + queryFlow.value = query + savedStateHandle[SEARCH_QUERY_KEY] = query + } + } + + private suspend fun handleSearchInputsChanged(searchInputs: SearchInputs) { + if (!isReadContactsPermissionGranted()) { + applyPermissionDeniedState(query = searchInputs.query) + return + } + + startSearch(searchInputs = searchInputs) + } + + private fun mergeContacts( + existingContacts: List, + additionalContacts: List, + ): ImmutableList { + val seenContactIds = LinkedHashSet() + + return (existingContacts + additionalContacts) + .asSequence() + .filter { contact -> seenContactIds.add(contact.id) } + .toImmutableList() + } + + private suspend fun startSearch(searchInputs: SearchInputs) { + applySearchStartedState() + delay(searchDebounce) + + val initialSearchResult = resolveInitialSearch(searchInputs = searchInputs) + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = initialSearchResult.effectiveQuery, + hasCompletedInitialLoad = true, + nextPageOffset = initialSearchResult.page.nextOffset, + ) + } + + applyInitialSearchResult(result = initialSearchResult) + } + + private suspend fun applyPermissionDeniedState(query: String) { + val visibleItems = buildVisibleItems( + query = query, + contacts = persistentListOf(), + excludedDestinations = excludedDestinationsFlow.value, + ) + + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = query, + nextPageOffset = null, + ) + } + + _state.update { currentState -> + currentState.copy( + canLoadMore = false, + items = visibleItems, + hasContactsPermission = false, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun applySearchStartedState() { + val shouldShowInitialLoader = searchSessionMutex.withLock { + !searchSession.hasCompletedInitialLoad + } + + _state.update { currentState -> + currentState.copy( + canLoadMore = false, + hasContactsPermission = true, + isLoading = shouldShowInitialLoader, + isLoadingMore = false, + ) + } + } + + private suspend fun resolveInitialSearch( + searchInputs: SearchInputs, + ): InitialSearchResult { + val requestedPage = loadContactsPage( + query = searchInputs.query, + offset = 0, + excludedDestinations = searchInputs.excludedDestinations, + ) + + if (shouldUseRequestedPage(query = searchInputs.query, page = requestedPage)) { + return InitialSearchResult( + effectiveQuery = searchInputs.query, + page = requestedPage, + ) + } + + val defaultPage = loadContactsPage( + query = "", + offset = 0, + excludedDestinations = searchInputs.excludedDestinations, + ) + + return InitialSearchResult( + effectiveQuery = "", + page = defaultPage, + ) + } + + private fun shouldUseRequestedPage( + query: String, + page: ContactsPage, + ): Boolean { + return query.isBlank() || page.contacts.isNotEmpty() + } + + private suspend fun loadContactsPage( + query: String, + offset: Int, + excludedDestinations: Set, + ): ContactsPage { + var nextOffset: Int? = offset + val visibleContacts = mutableListOf() + + while (nextOffset != null) { + val rawPage = contactsRepository + .searchContacts( + query = query, + offset = nextOffset, + ) + .first() + + rawPage.contacts.forEach { contact -> + val filtered = filterExcludedDestinations( + contact = contact, + excludedDestinations = excludedDestinations, + ) + + if (filtered != null) { + visibleContacts.add(filtered) + } + } + + if (visibleContacts.isNotEmpty() || rawPage.nextOffset == null) { + return ContactsPage( + contacts = visibleContacts.toImmutableList(), + nextOffset = rawPage.nextOffset, + ) + } + + nextOffset = rawPage.nextOffset + } + + return ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + } + + private fun filterExcludedDestinations( + contact: Contact, + excludedDestinations: Set, + ): Contact? { + if (excludedDestinations.isEmpty()) { + return contact + } + + val remainingDestinations = contact.destinations + .filterNot { destination -> destination.normalizedValue in excludedDestinations } + .toPersistentList() + + return when { + remainingDestinations.isEmpty() -> null + remainingDestinations.size == contact.destinations.size -> contact + else -> { + contact.copy(destinations = remainingDestinations) + } + } + } + + private fun applyInitialSearchResult(result: InitialSearchResult) { + _state.update { currentState -> + currentState.copy( + items = buildVisibleItems( + query = currentState.query, + contacts = result.page.contacts, + excludedDestinations = excludedDestinationsFlow.value, + ), + canLoadMore = result.page.nextOffset != null, + hasContactsPermission = true, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun createLoadMoreRequest(): LoadMoreRequest? { + val currentState = _state.value + + return when { + currentState.isLoading || currentState.isLoadingMore -> null + !currentState.hasContactsPermission -> null + + else -> { + searchSessionMutex.withLock { + val nextPageOffset = searchSession.nextPageOffset ?: return@withLock null + + LoadMoreRequest( + effectiveQuery = searchSession.effectiveQuery, + inputQuery = currentState.query, + excludedDestinations = excludedDestinationsFlow.value, + offset = nextPageOffset, + ) + } + } + } + } + + private suspend fun loadMore(request: LoadMoreRequest) { + applyLoadMoreStartedState() + + val nextPage = loadContactsPage( + query = request.effectiveQuery, + offset = request.offset, + excludedDestinations = request.excludedDestinations, + ) + + if (!isLoadMoreRequestCurrent(request = request)) { + applyLoadMoreStoppedState() + return + } + + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + nextPageOffset = nextPage.nextOffset, + ) + } + + applyLoadMoreResult(page = nextPage) + } + + private fun applyLoadMoreStartedState() { + _state.update { currentState -> + currentState.copy( + isLoadingMore = true, + ) + } + } + + private suspend fun isLoadMoreRequestCurrent(request: LoadMoreRequest): Boolean { + val currentEffectiveQuery = searchSessionMutex.withLock { + searchSession.effectiveQuery + } + + return currentEffectiveQuery == request.effectiveQuery && + _state.value.query == request.inputQuery + } + + private fun applyLoadMoreStoppedState() { + _state.update { currentState -> + currentState.copy( + isLoadingMore = false, + ) + } + } + + private fun applyLoadMoreResult(page: ContactsPage) { + _state.update { currentState -> + val mergedContacts = mergeContacts( + existingContacts = currentState.items.mapNotNull { item -> + when (item) { + is RecipientPickerListItem.Contact -> item.contact + is RecipientPickerListItem.SyntheticPhone -> null + } + }, + additionalContacts = page.contacts, + ) + + val visibleItems = buildVisibleItems( + query = currentState.query, + contacts = mergedContacts, + excludedDestinations = excludedDestinationsFlow.value, + ) + + currentState.copy( + items = visibleItems, + canLoadMore = page.nextOffset != null, + isLoadingMore = false, + ) + } + } + + private fun updateQueryInState(query: String) { + _state.update { currentState -> + currentState.copy( + query = query, + ) + } + } + + private suspend fun updateSearchSession( + transform: (ContactSearchSession) -> ContactSearchSession, + ) { + searchSessionMutex.withLock { + searchSession = transform(searchSession) + } + } + + private fun buildVisibleItems( + query: String, + contacts: List, + excludedDestinations: Set, + ): ImmutableList { + val syntheticItem = createSyntheticItemOrNull( + query = query, + contacts = contacts, + excludedDestinations = excludedDestinations, + ) + + val contactItems = contacts + .map(RecipientPickerListItem::Contact) + .toImmutableList() + + return when { + syntheticItem == null -> contactItems + else -> { + persistentListOf(syntheticItem) + .addAll(contactItems) + } + } + } + + private fun createSyntheticItemOrNull( + query: String, + contacts: List, + excludedDestinations: Set, + ): RecipientPickerListItem.SyntheticPhone? { + val candidate = createSyntheticCandidateOrNull(query = query) ?: return null + + val isAlreadyAContactDestination = contacts.any { contact -> + contact.destinations.any { destination -> + candidate.matchesDestination(destination = destination) + } + } + + return when { + candidate.isExcludedBy(excludedDestinations) -> null + isAlreadyAContactDestination -> null + else -> candidate.toListItem() + } + } + + private fun createSyntheticCandidateOrNull( + query: String, + ): SyntheticCandidate? { + val trimmedQuery = query.trim() + + return when { + trimmedQuery.isEmpty() -> null + !PhoneUtils.isValidSmsMmsDestination(trimmedQuery) -> null + else -> { + SyntheticCandidate( + rawQuery = trimmedQuery, + destinationIdentity = createDestinationIdentity( + rawDestination = trimmedQuery, + ), + ) + } + } + } + + private fun createDestinationIdentity(rawDestination: String): DestinationIdentity { + val trimmedDestination = rawDestination.trim() + + return DestinationIdentity( + rawDestination = trimmedDestination, + normalizedDestination = normalizeDestination(rawDestination = trimmedDestination), + ) + } + + private fun SyntheticCandidate.matchesDestination( + destination: ContactDestination, + ): Boolean { + return destinationIdentity.matches( + other = createDestinationIdentity(rawDestination = destination.value), + ) + } + + private fun normalizeDestination(rawDestination: String): String { + return contactDestinationFormatter.canonicalize(value = rawDestination) + } + + private data class InitialSearchResult( + val effectiveQuery: String, + val page: ContactsPage, + ) + + private data class LoadMoreRequest( + val effectiveQuery: String, + val inputQuery: String, + val excludedDestinations: Set, + val offset: Int, + ) + + private data class ContactSearchSession( + val effectiveQuery: String, + val hasCompletedInitialLoad: Boolean, + val nextPageOffset: Int?, + ) + + private data class SearchInputs( + val query: String, + val excludedDestinations: Set, + ) + + private data class DestinationIdentity( + val rawDestination: String, + val normalizedDestination: String, + ) { + fun isExcludedBy(excludedDestinations: Set): Boolean { + return rawDestination in excludedDestinations || + normalizedDestination in excludedDestinations + } + + fun matches(other: DestinationIdentity): Boolean { + return matches(destination = other.rawDestination) || + matches(destination = other.normalizedDestination) + } + + private fun matches(destination: String): Boolean { + return destination.isNotEmpty() && + (rawDestination == destination || normalizedDestination == destination) + } + } + + private data class SyntheticCandidate( + val rawQuery: String, + val destinationIdentity: DestinationIdentity, + ) { + fun isExcludedBy(excludedDestinations: Set): Boolean { + return destinationIdentity.isExcludedBy(excludedDestinations = excludedDestinations) + } + + fun toListItem(): RecipientPickerListItem.SyntheticPhone { + return RecipientPickerListItem.SyntheticPhone( + id = "$SYNTHETIC_RECIPIENT_ID_PREFIX$rawQuery", + rawQuery = rawQuery, + destination = rawQuery, + normalizedDestination = destinationIdentity.normalizedDestination, + ) + } + } + + private companion object { + private val searchDebounce = 150L.milliseconds + private const val SEARCH_QUERY_KEY = "search_query" + private const val SYNTHETIC_RECIPIENT_ID_PREFIX = "synthetic:" + } +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt new file mode 100644 index 00000000..3f8550bb --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt @@ -0,0 +1,29 @@ +package com.android.messaging.ui.conversation.recipientpicker.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.contact.model.ContactDestination +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface RecipientPickerListItem { + val id: String + + @Immutable + data class Contact( + val contact: com.android.messaging.data.contact.model.Contact, + ) : RecipientPickerListItem { + override val id: String = "contact:${contact.id}" + + val destinations: ImmutableList + get() = contact.destinations + } + + @Immutable + data class SyntheticPhone( + override val id: String, + val rawQuery: String, + val destination: String, + val normalizedDestination: String, + val secondaryText: String = normalizedDestination, + ) : RecipientPickerListItem +} diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt new file mode 100644 index 00000000..63f7bf1e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.recipientpicker.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class RecipientPickerUiState( + val query: String = "", + val items: ImmutableList = persistentListOf(), + val canLoadMore: Boolean = false, + val hasContactsPermission: Boolean = true, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, +) diff --git a/src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt b/src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt new file mode 100644 index 00000000..e8905d01 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt @@ -0,0 +1,6 @@ +package com.android.messaging.ui.conversation.screen + +internal enum class AudioRecordingStartMode { + Unlocked, + Locked, +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt new file mode 100644 index 00000000..95f19144 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt @@ -0,0 +1,195 @@ +package com.android.messaging.ui.conversation.screen + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import androidx.compose.runtime.State +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.core.net.toUri +import com.android.messaging.R +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.ContentType +import com.android.messaging.util.UiUtils +import com.android.messaging.util.UriUtil +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +internal suspend fun openAttachmentPreviewEffect( + context: Context, + hostBoundsState: State, + effect: ConversationScreenEffect.OpenAttachmentPreview, +) { + openAttachmentPreview( + context = context, + hostBounds = hostBoundsState.value, + contentUri = effect.contentUri, + contentType = effect.contentType, + imageCollectionUri = effect.imageCollectionUri, + awaitHostBounds = { + snapshotFlow { hostBoundsState.value } + .filterNotNull() + .first() + }, + ) +} + +internal suspend fun openShareSheet( + context: Context, + attachmentContentType: String?, + attachmentContentUri: String?, + text: String?, +) { + val shareIntent = Intent(Intent.ACTION_SEND) + + if ( + !attachmentContentType.isNullOrBlank() && + !attachmentContentUri.isNullOrBlank() + ) { + val normalizedAttachmentUri = normalizeAttachmentUriForIntent( + attachmentUri = attachmentContentUri.toUri(), + ) + + shareIntent.putExtra( + Intent.EXTRA_STREAM, + normalizedAttachmentUri, + ) + shareIntent.setType(attachmentContentType) + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + shareIntent.putExtra( + Intent.EXTRA_TEXT, + text.orEmpty(), + ) + shareIntent.setType("text/plain") + } + + context.startActivity( + Intent.createChooser( + shareIntent, + context.getText(R.string.action_share), + ), + ) +} + +private suspend fun openAttachmentPreview( + context: Context, + hostBounds: ComposeRect?, + contentUri: String, + contentType: String, + imageCollectionUri: String?, + awaitHostBounds: suspend () -> ComposeRect, +) { + val attachmentUri = contentUri.toUri() + + when { + ContentType.isImageType(contentType) -> { + val resolvedHostBounds = hostBounds ?: awaitHostBounds() + val isOpenedInternally = openImageAttachmentPreview( + context = context, + hostBounds = resolvedHostBounds, + attachmentUri = attachmentUri, + imageCollectionUri = imageCollectionUri, + ) + + if (!isOpenedInternally) { + openGenericAttachmentPreview( + context = context, + attachmentUri = attachmentUri, + contentType = contentType, + ) + } + } + + ContentType.isVCardType(contentType) -> { + UIIntents.get().launchVCardDetailActivity( + context, + normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + ) + } + + ContentType.isVideoType(contentType) -> { + UIIntents.get().launchFullScreenVideoViewer( + context, + normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + ) + } + + else -> { + openGenericAttachmentPreview( + context = context, + attachmentUri = normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + contentType = contentType, + ) + } + } +} + +private fun openImageAttachmentPreview( + context: Context, + hostBounds: ComposeRect, + attachmentUri: Uri, + imageCollectionUri: String?, +): Boolean { + val activity = UiUtils.getActivity(context) + val imageCollection = imageCollectionUri?.toUri() + + if (activity == null || imageCollection == null) { + return false + } + + UIIntents.get().launchFullScreenPhotoViewer( + activity, + attachmentUri, + hostBounds.toAndroidRect(), + imageCollection, + ) + + return true +} + +private fun openGenericAttachmentPreview( + context: Context, + attachmentUri: Uri, + contentType: String, +) { + runCatching { + Intent(Intent.ACTION_VIEW) + .apply { + setDataAndType(attachmentUri, contentType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + .let(context::startActivity) + }.onFailure { + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } +} + +private suspend fun normalizeAttachmentUriForIntent( + attachmentUri: Uri, +): Uri { + return when { + attachmentUri.scheme != ContentResolver.SCHEME_FILE -> attachmentUri + + else -> { + withContext(context = Dispatchers.IO) { + UriUtil.persistContentToScratchSpace(attachmentUri) ?: attachmentUri + } + } + } +} + +private fun ComposeRect.toAndroidRect(): Rect { + return Rect( + left.roundToInt(), + top.roundToInt(), + right.roundToInt(), + bottom.roundToInt(), + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt b/src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt new file mode 100644 index 00000000..37dfe4d1 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.screen + +internal data class ConversationAutoScrollInput( + val previousLatestMessageId: String?, + val latestMessageId: String?, + val hasLatestMessage: Boolean, + val isLatestMessageIncoming: Boolean, + val wasScrolledToLatestMessage: Boolean, +) + +internal data class ConversationAutoScrollDecision( + val shouldScrollToLatestMessage: Boolean, + val shouldShowNewMessageSnackbar: Boolean, + val updatedLatestMessageId: String?, +) + +internal fun evaluateConversationAutoScroll( + input: ConversationAutoScrollInput, +): ConversationAutoScrollDecision { + return when { + input.latestMessageId == input.previousLatestMessageId -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = input.latestMessageId, + ) + + !input.hasLatestMessage -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = input.latestMessageId, + ) + + input.isLatestMessageIncoming && !input.wasScrolledToLatestMessage -> { + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = true, + updatedLatestMessageId = input.latestMessageId, + ) + } + + else -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = true, + shouldShowNewMessageSnackbar = false, + updatedLatestMessageId = input.latestMessageId, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt new file mode 100644 index 00000000..baee7d9a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -0,0 +1,592 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.CONVERSATION_LOADING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.composer.ui.ConversationComposerSection +import com.android.messaging.ui.conversation.composer.ui.ConversationSimSelectorSheet +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.mediapicker.rememberConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.mediapicker.rememberConversationMediaPickerState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.messages.ui.ConversationMessages +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.metadata.ui.ConversationTopAppBar +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import kotlinx.collections.immutable.ImmutableList + +private const val SMOOTH_SCROLL_JUMP_THRESHOLD = 15 +private val CONVERSATION_CONTENT_SHAPE = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp, +) + +@Composable +internal fun ConversationScreen( + modifier: Modifier = Modifier, + conversationId: String? = null, + launchGeneration: Int? = null, + cancelIncomingNotification: Boolean = true, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + pendingDraft: ConversationDraft? = null, + pendingScrollPosition: Int? = null, + pendingSelfParticipantId: String? = null, + pendingStartupAttachment: ConversationEntryStartupAttachment? = null, + onPendingDraftConsumed: () -> Unit = {}, + onPendingScrollPositionConsumed: () -> Unit = {}, + onPendingSelfParticipantIdConsumed: () -> Unit = {}, + onPendingStartupAttachmentConsumed: () -> Unit = {}, + screenModel: ConversationScreenModel = hiltViewModel(), +) { + val messageFieldFocusRequester = remember { FocusRequester() } + val mediaPickerState = rememberConversationMediaPickerState() + val scaffoldUiState by screenModel.scaffoldUiState.collectAsStateWithLifecycle() + val mediaPickerOverlayUiState by screenModel + .mediaPickerOverlayUiState + .collectAsStateWithLifecycle() + + val permissionState = rememberConversationMediaPickerPermissionState() + + val hostBoundsState = remember { mutableStateOf(value = null) } + val snackbarHostState = remember { SnackbarHostState() } + val onOpenContactPicker = rememberOpenContactPickerCallback(screenModel = screenModel) + val requestAudioRecordingStart = rememberAudioRecordingStartRequest( + screenModel = screenModel, + permissionState = permissionState, + ) + + ConversationScreenRouteEffects( + conversationId = conversationId, + launchGeneration = launchGeneration, + cancelIncomingNotification = cancelIncomingNotification, + pendingDraft = pendingDraft, + pendingSelfParticipantId = pendingSelfParticipantId, + pendingStartupAttachment = pendingStartupAttachment, + scaffoldUiState = scaffoldUiState, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + permissionState = permissionState, + screenModel = screenModel, + onNavigateBack = onNavigateBack, + onPendingDraftConsumed = onPendingDraftConsumed, + onPendingSelfParticipantIdConsumed = onPendingSelfParticipantIdConsumed, + onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, + ) + + ConversationScreenSurface( + modifier = modifier, + conversationId = conversationId, + scaffoldUiState = scaffoldUiState, + mediaPickerOverlayUiState = mediaPickerOverlayUiState, + mediaPickerState = mediaPickerState, + snackbarHostState = snackbarHostState, + messageFieldFocusRequester = messageFieldFocusRequester, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onHostBoundsChanged = { hostBounds -> + hostBoundsState.value = hostBounds + }, + onOpenContactPicker = onOpenContactPicker, + onAudioRecordingStartRequest = { + requestAudioRecordingStart(AudioRecordingStartMode.Unlocked) + }, + onLockedAudioRecordingStartRequest = { + requestAudioRecordingStart(AudioRecordingStartMode.Locked) + }, + screenModel = screenModel, + ) +} + +@Composable +internal fun ConversationScreenScaffold( + modifier: Modifier = Modifier, + conversationId: String?, + uiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, + isMediaPickerOpen: Boolean, + messageFieldFocusRequester: FocusRequester, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + onOpenContactPicker: () -> Unit, + onOpenMediaPicker: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + screenModel: ConversationScreenModel, +) { + val simSheetState = rememberConversationSimSheetState( + isAvailable = uiState.composer.simSelector.isAvailable, + ) + + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + ConversationScreenTopBar( + uiState = uiState, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onSimSelectorClick = simSheetState::show, + screenModel = screenModel, + ) + }, + bottomBar = { + ConversationScreenBottomBar( + uiState = uiState, + isMediaPickerOpen = isMediaPickerOpen, + messageFieldFocusRequester = messageFieldFocusRequester, + onOpenContactPicker = onOpenContactPicker, + onOpenMediaPicker = onOpenMediaPicker, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onSendActionLongClick = simSheetState::show, + screenModel = screenModel, + ) + }, + ) { contentPadding -> + ConversationScreenContent( + modifier = Modifier.fillMaxSize(), + conversationId = conversationId, + uiState = uiState, + snackbarHostState = snackbarHostState, + contentPadding = contentPadding, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + onAttachmentClick = screenModel::onMessageAttachmentClicked, + onExternalUriClick = screenModel::onExternalUriClicked, + onMessageClick = screenModel::onMessageClick, + onMessageAvatarClick = screenModel::onMessageAvatarClick, + onMessageDownloadClick = screenModel::onMessageDownloadClick, + onMessageLongClick = screenModel::onMessageLongClick, + onMessageResendClick = screenModel::onMessageResendClick, + onSimSelectorClick = simSheetState::show, + ) + } + + ConversationScreenDialogs(uiState = uiState, screenModel = screenModel) + + ConversationScreenSimSelectorSheet( + simSheetState = simSheetState, + uiState = uiState, + onSimSelected = screenModel::onSimSelected, + ) +} + +@Composable +private fun ConversationScreenTopBar( + uiState: ConversationScreenScaffoldUiState, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + onSimSelectorClick: () -> Unit, + screenModel: ConversationScreenModel, +) { + when { + uiState.selection.isSelectionMode -> { + ConversationSelectionTopAppBar( + selection = uiState.selection, + onActionClick = screenModel::onMessageSelectionActionClick, + onDismissSelection = screenModel::dismissMessageSelection, + ) + } + + else -> { + ConversationTopAppBar( + metadata = uiState.metadata, + isAddPeopleVisible = uiState.canAddPeople, + isCallVisible = uiState.canCall, + isArchiveVisible = uiState.canArchive, + isUnarchiveVisible = uiState.canUnarchive, + isAddContactVisible = uiState.canAddContact, + isDeleteConversationVisible = uiState.canDeleteConversation, + isShowSubjectFieldVisible = uiState.canEditSubject, + simSelector = uiState.composer.simSelector, + onAddPeopleClick = onAddPeopleClick, + onCallClick = screenModel::onCallClick, + onArchiveClick = screenModel::onArchiveConversationClick, + onUnarchiveClick = screenModel::onUnarchiveConversationClick, + onAddContactClick = screenModel::onAddContactClick, + onDeleteConversationClick = screenModel::onDeleteConversationClick, + onShowSubjectFieldClick = screenModel::onShowSubjectFieldClick, + onSimSelectorClick = onSimSelectorClick, + onTitleClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + ) + } + } +} + +@Composable +private fun ConversationScreenBottomBar( + uiState: ConversationScreenScaffoldUiState, + isMediaPickerOpen: Boolean, + messageFieldFocusRequester: FocusRequester, + onOpenContactPicker: () -> Unit, + onOpenMediaPicker: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onSendActionLongClick: () -> Unit, + screenModel: ConversationScreenModel, +) { + if (isMediaPickerOpen) { + return + } + + ConversationComposerSection( + audioRecording = uiState.composer.audioRecording, + attachments = uiState.composer.attachments, + messageText = uiState.composer.messageText, + subjectText = uiState.composer.subjectText, + sendProtocol = uiState.composer.sendProtocol, + segmentCounter = uiState.composer.segmentCounter, + isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, + isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, + isRecordActionEnabled = uiState.composer.isRecordActionEnabled, + isSendActionEnabled = uiState.composer.isSendEnabled, + shouldShowRecordAction = uiState.composer.shouldShowRecordAction, + messageFieldFocusRequester = messageFieldFocusRequester, + onContactAttachClick = onOpenContactPicker, + onMediaPickerClick = onOpenMediaPicker, + onMessageTextChange = screenModel::onMessageTextChanged, + onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, + onResolvedAttachmentClick = screenModel::onAttachmentClicked, + onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onAudioRecordingFinish = screenModel::onAudioRecordingFinish, + onAudioRecordingLock = screenModel::onAudioRecordingLock, + onAudioRecordingCancel = screenModel::onAudioRecordingCancel, + onSendClick = screenModel::onSendClick, + onSendActionLongClick = onSendActionLongClick, + onSubjectChipClick = screenModel::onShowSubjectFieldClick, + onSubjectChipClear = screenModel::onSubjectChipClear, + ) +} + +@Composable +private fun ConversationScreenSimSelectorSheet( + simSheetState: ConversationSimSheetState, + uiState: ConversationScreenScaffoldUiState, + onSimSelected: (String) -> Unit, +) { + if (!simSheetState.isVisible || !uiState.composer.simSelector.isAvailable) { + return + } + + ConversationSimSelectorSheet( + uiState = uiState.composer.simSelector, + onSimSelected = { selfParticipantId -> + onSimSelected(selfParticipantId) + simSheetState.dismiss() + }, + onDismissRequest = simSheetState::dismiss, + ) +} + +@Composable +private fun ConversationScreenContent( + modifier: Modifier = Modifier, + conversationId: String?, + uiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, + contentPadding: PaddingValues, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit, +) { + val contentBackdropColor = conversationScreenContentBackdropColor(uiState = uiState) + + when (val messagesState = uiState.messages) { + is ConversationMessagesUiState.Loading -> { + Box( + modifier = modifier.conversationScreenContentModifier( + contentPadding = contentPadding, + backdropColor = contentBackdropColor, + ), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.testTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG), + ) + } + } + + is ConversationMessagesUiState.Present -> { + val messagesListState = rememberMessagesListState( + conversationId = conversationId, + ) + + val showIncomingParticipantIdentity = shouldShowIncomingParticipantIdentity( + metadata = uiState.metadata, + ) + + AutoScrollToLatestMessage( + conversationId = conversationId, + messages = messagesState.messages, + listState = messagesListState, + snackbarHostState = snackbarHostState, + ) + + ScrollToTargetMessage( + conversationId = conversationId, + pendingScrollPosition = pendingScrollPosition, + messages = messagesState.messages, + listState = messagesListState, + onConsumed = onPendingScrollPositionConsumed, + ) + + ConversationMessages( + modifier = modifier.conversationScreenContentModifier( + contentPadding = contentPadding, + backdropColor = contentBackdropColor, + ), + messages = messagesState.messages, + listState = messagesListState, + selectedMessageIds = uiState.selection.selectedMessageIds, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + subscriptions = uiState.composer.simSelector.subscriptions, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } + } +} + +@Composable +private fun Modifier.conversationScreenContentModifier( + contentPadding: PaddingValues, + backdropColor: Color, +): Modifier { + return this + .padding(paddingValues = contentPadding) + .background(color = backdropColor) + .clip(shape = CONVERSATION_CONTENT_SHAPE) + .background(color = MaterialTheme.colorScheme.background) +} + +@Composable +private fun conversationScreenContentBackdropColor( + uiState: ConversationScreenScaffoldUiState, +): Color { + return when { + uiState.selection.isSelectionMode -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainer + } +} + +private fun shouldShowIncomingParticipantIdentity( + metadata: ConversationMetadataUiState, +): Boolean { + return when (metadata) { + is ConversationMetadataUiState.Present -> metadata.participantCount > 1 + + ConversationMetadataUiState.Loading, + ConversationMetadataUiState.Unavailable, + -> false + } +} + +@Composable +private fun AutoScrollToLatestMessage( + conversationId: String?, + messages: ImmutableList, + listState: LazyListState, + snackbarHostState: SnackbarHostState, +) { + val latestMessage = messages.lastOrNull() + val latestMessageId = latestMessage?.messageId + val newMessageText = stringResource(id = R.string.in_conversation_notify_new_message_text) + val viewActionLabel = stringResource(id = R.string.in_conversation_notify_new_message_action) + + var previousLatestMessageId by remember(conversationId) { + mutableStateOf(value = latestMessageId) + } + + var wasScrolledToLatestMessage by remember( + conversationId, + listState, + ) { + mutableStateOf( + value = isScrolledToLatestMessage(listState = listState), + ) + } + + LaunchedEffect( + conversationId, + listState, + ) { + snapshotFlow { + isScrolledToLatestMessage(listState = listState) + }.collect { isScrolledToLatestMessage -> + wasScrolledToLatestMessage = isScrolledToLatestMessage + if (isScrolledToLatestMessage) { + snackbarHostState.currentSnackbarData?.dismiss() + } + } + } + + LaunchedEffect( + conversationId, + latestMessageId, + ) { + val autoScrollDecision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = previousLatestMessageId, + latestMessageId = latestMessageId, + hasLatestMessage = latestMessage != null, + isLatestMessageIncoming = latestMessage?.isIncoming ?: false, + wasScrolledToLatestMessage = wasScrolledToLatestMessage, + ), + ) + + previousLatestMessageId = autoScrollDecision.updatedLatestMessageId + + if (autoScrollDecision.shouldShowNewMessageSnackbar) { + val snackbarResult = snackbarHostState.showSnackbar( + message = newMessageText, + actionLabel = viewActionLabel, + duration = SnackbarDuration.Indefinite, + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + listState.animateScrollToItem(index = 0) + } + + return@LaunchedEffect + } + + if (!autoScrollDecision.shouldScrollToLatestMessage) { + return@LaunchedEffect + } + + listState.animateScrollToItem(index = 0) + } +} + +private fun isScrolledToLatestMessage(listState: LazyListState): Boolean { + return listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 +} + +@Composable +private fun ScrollToTargetMessage( + conversationId: String?, + pendingScrollPosition: Int?, + messages: ImmutableList, + listState: LazyListState, + onConsumed: () -> Unit, +) { + LaunchedEffect( + conversationId, + pendingScrollPosition, + messages.size, + ) { + if (pendingScrollPosition == null || messages.isEmpty()) { + return@LaunchedEffect + } + + val displayIndex = messagePositionToDisplayIndex( + position = pendingScrollPosition, + size = messages.size, + ) + + val firstVisible = listState.firstVisibleItemIndex + val delta = displayIndex - firstVisible + + val intermediateIndex = when { + delta > SMOOTH_SCROLL_JUMP_THRESHOLD -> displayIndex - SMOOTH_SCROLL_JUMP_THRESHOLD + delta < -SMOOTH_SCROLL_JUMP_THRESHOLD -> displayIndex + SMOOTH_SCROLL_JUMP_THRESHOLD + else -> -1 + } + + if (intermediateIndex != -1) { + listState.scrollToItem(index = intermediateIndex.coerceIn(0, messages.size - 1)) + } + + listState.animateScrollToItem(index = displayIndex) + onConsumed() + } +} + +internal fun messagePositionToDisplayIndex(position: Int, size: Int): Int { + return when { + size <= 0 -> 0 + + else -> { + val lastIndex = size - 1 + (lastIndex - position).coerceIn(0, lastIndex) + } + } +} + +@Composable +private fun rememberMessagesListState( + conversationId: String?, +): LazyListState { + return rememberSaveable( + conversationId, + saver = LazyListState.Saver, + ) { + LazyListState( + firstVisibleItemIndex = 0, + firstVisibleItemScrollOffset = 0, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt new file mode 100644 index 00000000..c06814ea --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt @@ -0,0 +1,288 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +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.TextFieldValue +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState + +@Composable +internal fun ConversationScreenDialogs( + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel, +) { + uiState.attachmentLimitWarning?.let { warning -> + ConversationAttachmentLimitWarningDialog( + warning = warning, + onDismiss = screenModel::dismissAttachmentLimitWarning, + onSendAnyway = screenModel::sendAnywayAfterAttachmentLimitWarning, + ) + } + + uiState.selection.deleteConfirmation?.let { deleteConfirmation -> + ConversationDeleteMessagesDialog( + deleteConfirmation = deleteConfirmation, + onConfirm = screenModel::confirmDeleteSelectedMessages, + onDismiss = screenModel::dismissDeleteMessageConfirmation, + ) + } + + if (uiState.isDeleteConversationConfirmationVisible) { + ConversationDeleteConversationDialog( + onConfirm = screenModel::confirmDeleteConversation, + onDismiss = screenModel::dismissDeleteConversationConfirmation, + ) + } + + if (uiState.isSubjectDialogVisible) { + ConversationSubjectFieldDialog( + initialSubjectText = uiState.composer.subjectText, + onConfirm = screenModel::onSubjectDialogConfirm, + onDismiss = screenModel::onSubjectDialogDismiss, + ) + } +} + +@Composable +private fun ConversationAttachmentLimitWarningDialog( + warning: ConversationAttachmentLimitWarning, + onDismiss: () -> Unit, + onSendAnyway: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.mms_attachment_limit_reached), + ) + }, + text = { + Text( + text = stringResource( + id = when (warning) { + ConversationAttachmentLimitWarning.ComposingAttachmentLimitReached -> { + R.string.attachment_limit_reached_dialog_message_when_composing + } + + ConversationAttachmentLimitWarning.SendingMessageLimitReached -> { + R.string.attachment_limit_reached_dialog_message_when_sending + } + + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached -> { + R.string.video_attachment_limit_exceeded_when_sending + } + }, + ), + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(android.R.string.ok), + ) + } + }, + dismissButton = when (warning) { + ConversationAttachmentLimitWarning.SendingMessageLimitReached -> { + { + TextButton(onClick = onSendAnyway) { + Text( + text = stringResource(R.string.attachment_limit_reached_send_anyway), + ) + } + } + } + + ConversationAttachmentLimitWarning.ComposingAttachmentLimitReached, + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached, + -> null + }, + ) +} + +@Composable +private fun ConversationDeleteConversationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = pluralStringResource( + id = R.plurals.delete_conversations_confirmation_dialog_title, + count = 1, + 1, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.delete_conversation_confirmation_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.delete_conversation_decline_button)) + } + }, + ) +} + +@Composable +private fun ConversationSubjectFieldDialog( + initialSubjectText: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var fieldText by remember(initialSubjectText) { + mutableStateOf( + value = TextFieldValue( + text = initialSubjectText, + selection = TextRange(index = initialSubjectText.length), + ), + ) + } + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + modifier = Modifier.testTag(tag = CONVERSATION_SUBJECT_DIALOG_TEST_TAG), + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.subject_dialog_title)) + }, + text = { + ConversationSubjectFieldInput( + value = fieldText, + onValueChange = { newValue -> fieldText = newValue }, + focusRequester = focusRequester, + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(fieldText.text) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun ConversationSubjectFieldInput( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + focusRequester: FocusRequester, +) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester = focusRequester) + .testTag(CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG), + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + placeholder = { + Text( + text = stringResource(R.string.compose_message_view_subject_hint_text), + ) + }, + trailingIcon = { + if (value.text.isNotEmpty()) { + IconButton( + modifier = Modifier + .testTag(CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG), + onClick = { onValueChange(TextFieldValue(text = "")) }, + ) { + Icon( + imageVector = Icons.Rounded.Cancel, + contentDescription = stringResource( + id = R.string.delete_subject_content_description, + ), + ) + } + } + }, + ) +} + +@Composable +private fun ConversationDeleteMessagesDialog( + deleteConfirmation: ConversationMessageDeleteConfirmationUiState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = pluralStringResource( + id = R.plurals.delete_messages_confirmation_dialog_title, + count = deleteConfirmation.messageIds.size, + deleteConfirmation.messageIds.size, + ), + ) + }, + text = { + Text( + text = stringResource(R.string.delete_message_confirmation_dialog_text), + ) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + ) { + Text( + text = stringResource(R.string.delete_message_confirmation_button), + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + ) { + Text( + text = stringResource(android.R.string.cancel), + ) + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt new file mode 100644 index 00000000..cbbd57de --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt @@ -0,0 +1,346 @@ +package com.android.messaging.ui.conversation.screen + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.Point +import android.view.View +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.MessageDetailsDialog +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.util.BuglePrefs +import com.android.messaging.util.ContactUtil +import com.android.messaging.util.LogUtil +import com.android.messaging.util.MediaUtil +import com.android.messaging.util.UiUtils + +private const val LOG_TAG = "ConversationScreenEffects" + +@Composable +internal fun ConversationScreenEffects( + screenModel: ConversationScreenModel, + snackbarHostState: SnackbarHostState, + hostBoundsState: State, + onNavigateBack: () -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + val defaultSmsRoleLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + screenModel.onDefaultSmsRoleRequestResult(resultCode = result.resultCode) + } + val draftSentTick = remember { mutableIntStateOf(0) } + + LaunchedEffect( + screenModel, + context, + view, + snackbarHostState, + hostBoundsState, + onNavigateBack, + ) { + screenModel.effects.collect { effect -> + screenModel.handleConversationScreenEffect( + context = context, + view = view, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + effect = effect, + launchRoleRequest = defaultSmsRoleLauncher::launch, + onNavigateBack = onNavigateBack, + onDraftSent = { draftSentTick.intValue++ }, + ) + } + } + + SendingMessageAnnouncement(triggerKey = draftSentTick.intValue) +} + +private suspend fun ConversationScreenModel.handleConversationScreenEffect( + context: Context, + view: View, + snackbarHostState: SnackbarHostState, + hostBoundsState: State, + effect: ConversationScreenEffect, + launchRoleRequest: (Intent) -> Unit, + onNavigateBack: () -> Unit, + onDraftSent: () -> Unit, +) { + when (effect) { + ConversationScreenEffect.CloseConversation -> onNavigateBack() + is ConversationScreenEffect.RequestDefaultSmsRole -> { + requestDefaultSmsRole( + context = context, + snackbarHostState = snackbarHostState, + effect = effect, + onActionClick = ::onDefaultSmsRolePromptActionClick, + ) + } + + is ConversationScreenEffect.LaunchDefaultSmsRoleRequest -> { + launchDefaultSmsRoleRequest( + effect = effect, + launchRoleRequest = launchRoleRequest, + onLaunchFailed = ::onDefaultSmsRoleRequestLaunchFailed, + ) + } + + is ConversationScreenEffect.OpenAttachmentPreview -> { + openAttachmentPreviewEffect( + context = context, + hostBoundsState = hostBoundsState, + effect = effect, + ) + } + + is ConversationScreenEffect.ShareMessage -> { + openShareSheet( + context = context, + attachmentContentType = effect.attachmentContentType, + attachmentContentUri = effect.attachmentContentUri, + text = effect.text, + ) + } + + is ConversationScreenEffect.LaunchAddContactFlow, + is ConversationScreenEffect.LaunchForwardMessage, + ConversationScreenEffect.NotifyDraftSent, + is ConversationScreenEffect.OpenExternalUri, + is ConversationScreenEffect.PlacePhoneCall, + is ConversationScreenEffect.ShowMessage, + is ConversationScreenEffect.ShowMessageDetails, + is ConversationScreenEffect.ShowOrAddParticipantContact, + is ConversationScreenEffect.ShowSaveAttachmentsResult, + -> { + handleImmediateConversationScreenEffect( + context = context, + view = view, + effect = effect, + onDraftSent = onDraftSent, + ) + } + } +} + +private fun handleImmediateConversationScreenEffect( + context: Context, + view: View, + effect: ConversationScreenEffect, + onDraftSent: () -> Unit, +) { + when (effect) { + is ConversationScreenEffect.LaunchAddContactFlow -> { + UIIntents.get().launchAddContactActivity( + context, + effect.destination, + ) + } + + is ConversationScreenEffect.LaunchForwardMessage -> { + UIIntents.get().launchForwardMessageActivity( + context, + effect.message, + ) + } + + ConversationScreenEffect.NotifyDraftSent -> { + playDraftSentSound(context = context) + onDraftSent() + } + + is ConversationScreenEffect.OpenExternalUri -> { + openExternalUri( + context = context, + uri = effect.uri, + ) + } + + is ConversationScreenEffect.PlacePhoneCall -> { + placePhoneCall( + context = context, + phoneNumber = effect.phoneNumber, + ) + } + + is ConversationScreenEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + + is ConversationScreenEffect.ShowMessageDetails -> { + MessageDetailsDialog.show( + context, + effect.message, + effect.participants, + effect.selfParticipant, + ) + } + + is ConversationScreenEffect.ShowOrAddParticipantContact -> { + ContactUtil.showOrAddContact( + view, + effect.contactId, + effect.contactLookupKey, + effect.avatarUri, + effect.normalizedDestination, + ) + } + + is ConversationScreenEffect.ShowSaveAttachmentsResult -> { + showSaveAttachmentsResultToast( + context = context, + effect = effect, + ) + } + + else -> {} + } +} + +private suspend fun requestDefaultSmsRole( + context: Context, + snackbarHostState: SnackbarHostState, + effect: ConversationScreenEffect.RequestDefaultSmsRole, + onActionClick: () -> Unit, +) { + snackbarHostState.currentSnackbarData?.dismiss() + + val messageResId = when { + effect.isSending -> R.string.requires_default_sms_app_to_send + else -> R.string.requires_default_sms_app + } + + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString(messageResId), + actionLabel = context.getString(R.string.requires_default_sms_change_button), + duration = SnackbarDuration.Indefinite, + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + onActionClick() + } +} + +private fun launchDefaultSmsRoleRequest( + effect: ConversationScreenEffect.LaunchDefaultSmsRoleRequest, + launchRoleRequest: (Intent) -> Unit, + onLaunchFailed: () -> Unit, +) { + try { + launchRoleRequest(effect.intent) + } catch (exception: ActivityNotFoundException) { + LogUtil.w(LOG_TAG, "Couldn't find activity", exception) + onLaunchFailed() + } +} + +private fun openExternalUri( + context: Context, + uri: String, +) { + UIIntents.get().launchBrowserForUrl(context, uri) +} + +private fun placePhoneCall( + context: Context, + phoneNumber: String, +) { + UIIntents.get().launchPhoneCallActivity( + context, + phoneNumber, + Point(0, 0), + ) +} + +private fun showSaveAttachmentsResultToast( + context: Context, + effect: ConversationScreenEffect.ShowSaveAttachmentsResult, +) { + if (effect.failCount > 0) { + UiUtils.showToastAtBottom( + context.resources.getQuantityString( + R.plurals.attachment_save_error, + effect.failCount, + effect.failCount, + ), + ) + + return + } + + val total = effect.imageCount + effect.videoCount + effect.otherCount + if (total == 0) { + return + } + + val pluralResId = when { + effect.otherCount > 0 && effect.imageCount + effect.videoCount == 0 -> { + R.plurals.attachments_saved_to_downloads + } + + effect.otherCount > 0 -> R.plurals.attachments_saved + effect.videoCount == 0 -> R.plurals.photos_saved + effect.imageCount == 0 -> R.plurals.videos_saved + else -> R.plurals.attachments_saved + } + + UiUtils.showToastAtBottom( + context.resources.getQuantityString(pluralResId, total, total), + ) +} + +@Composable +private fun SendingMessageAnnouncement( + triggerKey: Int, +) { + if (triggerKey == 0) { + return + } + + val text = stringResource(R.string.sending_message) + + key(triggerKey) { + Box( + modifier = Modifier + .size(0.dp) + .clearAndSetSemantics { + liveRegion = LiveRegionMode.Polite + contentDescription = text + }, + ) + } +} + +private fun playDraftSentSound(context: Context) { + val prefs = BuglePrefs.getApplicationPrefs() + val prefKey = context.getString(R.string.send_sound_pref_key) + val default = context.resources.getBoolean(R.bool.send_sound_pref_default) + + if (prefs.getBoolean(prefKey, default)) { + MediaUtil.get().playSound(context, R.raw.message_sent, null) + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt new file mode 100644 index 00000000..34920902 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -0,0 +1,326 @@ +package com.android.messaging.ui.conversation.screen + +import android.Manifest +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresExtension +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.mediapicker.ConversationMediaPickerOverlay +import com.android.messaging.ui.conversation.mediapicker.ConversationMediaPickerState +import com.android.messaging.ui.conversation.mediapicker.RefreshConversationMediaPickerPermissionsEffect +import com.android.messaging.ui.conversation.mediapicker.model.ConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState + +@Composable +internal fun rememberOpenContactPickerCallback( + screenModel: ConversationScreenModel, +): () -> Unit { + val contactPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickContact(), + ) { contactUri -> + screenModel.onContactCardPicked(contactUri = contactUri?.toString()) + } + + return remember(screenModel, contactPickerLauncher) { + { + if (screenModel.tryStartAddingAttachment()) { + contactPickerLauncher.launch(input = null) + } + } + } +} + +@Composable +internal fun rememberAudioRecordingStartRequest( + screenModel: ConversationScreenModel, + permissionState: ConversationMediaPickerPermissionState, +): (AudioRecordingStartMode) -> Unit { + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.audioPermissionGranted = isGranted + } + + return remember(screenModel, permissionState, audioPermissionLauncher) { + { startMode -> + val canStartAddingAttachment = screenModel.tryStartAddingAttachment() + + when { + !canStartAddingAttachment -> Unit + + permissionState.audioPermissionGranted -> { + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } + + else -> { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + } +} + +@Composable +internal fun ConversationScreenRouteEffects( + conversationId: String?, + launchGeneration: Int?, + cancelIncomingNotification: Boolean, + pendingDraft: ConversationDraft?, + pendingSelfParticipantId: String?, + pendingStartupAttachment: ConversationEntryStartupAttachment?, + scaffoldUiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, + hostBoundsState: State, + permissionState: ConversationMediaPickerPermissionState, + screenModel: ConversationScreenModel, + onNavigateBack: () -> Unit, + onPendingDraftConsumed: () -> Unit, + onPendingSelfParticipantIdConsumed: () -> Unit, + onPendingStartupAttachmentConsumed: () -> Unit, +) { + ConversationPendingLaunchEffects( + conversationId = conversationId, + launchGeneration = launchGeneration, + pendingDraft = pendingDraft, + pendingSelfParticipantId = pendingSelfParticipantId, + pendingStartupAttachment = pendingStartupAttachment, + screenModel = screenModel, + onPendingDraftConsumed = onPendingDraftConsumed, + onPendingSelfParticipantIdConsumed = onPendingSelfParticipantIdConsumed, + onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, + ) + + RefreshConversationMediaPickerPermissionsEffect( + permissionState = permissionState, + ) + + ConversationScreenLifecycleEffects( + cancelIncomingNotification = cancelIncomingNotification, + uiState = scaffoldUiState, + screenModel = screenModel, + ) + + BackHandler(enabled = scaffoldUiState.selection.isSelectionMode) { + screenModel.dismissMessageSelection() + } + + ConversationScreenEffects( + screenModel = screenModel, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + onNavigateBack = onNavigateBack, + ) +} + +@Composable +private fun ConversationPendingLaunchEffects( + conversationId: String?, + launchGeneration: Int?, + pendingDraft: ConversationDraft?, + pendingSelfParticipantId: String?, + pendingStartupAttachment: ConversationEntryStartupAttachment?, + screenModel: ConversationScreenModel, + onPendingDraftConsumed: () -> Unit, + onPendingSelfParticipantIdConsumed: () -> Unit, + onPendingStartupAttachmentConsumed: () -> Unit, +) { + LaunchedEffect(conversationId, screenModel) { + screenModel.onConversationIdChanged(conversationId = conversationId) + } + + LaunchedEffect(conversationId, launchGeneration, pendingDraft, screenModel) { + if (conversationId != null && launchGeneration != null && pendingDraft != null) { + screenModel.onSeedDraft( + conversationId = conversationId, + draft = pendingDraft, + ) + onPendingDraftConsumed() + } + } + + LaunchedEffect( + conversationId, + launchGeneration, + pendingSelfParticipantId, + screenModel, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingSelfParticipantId != null + ) { + screenModel.onSimSelected(selfParticipantId = pendingSelfParticipantId) + onPendingSelfParticipantIdConsumed() + } + } + + LaunchedEffect( + conversationId, + launchGeneration, + pendingStartupAttachment, + screenModel, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingStartupAttachment != null + ) { + screenModel.onOpenStartupAttachment( + conversationId = conversationId, + startupAttachment = pendingStartupAttachment, + ) + onPendingStartupAttachmentConsumed() + } + } +} + +@Composable +private fun ConversationScreenLifecycleEffects( + cancelIncomingNotification: Boolean, + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel, +) { + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + screenModel.onScreenForegrounded(cancelNotification = cancelIncomingNotification) + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { + screenModel.onScreenBackgrounded() + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { + val isRecording = uiState.composer.audioRecording.phase == + ConversationAudioRecordingPhase.Recording + + if (isRecording) { + screenModel.onAudioRecordingCancel() + } + screenModel.persistDraft() + } +} + +@Composable +internal fun ConversationScreenSurface( + modifier: Modifier, + conversationId: String?, + scaffoldUiState: ConversationScreenScaffoldUiState, + mediaPickerOverlayUiState: ConversationMediaPickerOverlayUiState, + mediaPickerState: ConversationMediaPickerState, + snackbarHostState: SnackbarHostState, + messageFieldFocusRequester: FocusRequester, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + onHostBoundsChanged: (ComposeRect) -> Unit, + onOpenContactPicker: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + screenModel: ConversationScreenModel, +) { + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + onHostBoundsChanged(coordinates.boundsInWindow()) + }, + ) { + ConversationScreenScaffold( + modifier = Modifier.fillMaxSize(), + conversationId = conversationId, + uiState = scaffoldUiState, + snackbarHostState = snackbarHostState, + isMediaPickerOpen = mediaPickerState.isOpen, + messageFieldFocusRequester = messageFieldFocusRequester, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onOpenContactPicker = onOpenContactPicker, + onOpenMediaPicker = mediaPickerState::open, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + screenModel = screenModel, + ) + + // "Call requires version 15 of the U Extensions SDK" is OK in this case: all GrapheneOS + // users will have this version + ConversationMediaPickerOverlayHost( + modifier = Modifier.fillMaxSize(), + uiState = mediaPickerOverlayUiState, + state = mediaPickerState, + messageFieldFocusRequester = messageFieldFocusRequester, + screenModel = screenModel, + ) + } +} + +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@Composable +private fun ConversationMediaPickerOverlayHost( + modifier: Modifier, + uiState: ConversationMediaPickerOverlayUiState, + state: ConversationMediaPickerState, + messageFieldFocusRequester: FocusRequester, + screenModel: ConversationScreenModel, +) { + ConversationMediaPickerOverlay( + modifier = modifier, + state = state, + attachments = uiState.attachments, + conversationTitle = uiState.conversationTitle, + isSendActionEnabled = uiState.isSendActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + onAttachmentPreviewClick = { attachment -> + screenModel.onAttachmentClicked(attachment = attachment) + }, + onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, + onAttachmentRemove = screenModel::onRemoveResolvedAttachment, + photoPickerSourceContentUriByAttachmentContentUri = + uiState.photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = screenModel::onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = screenModel::onPhotoPickerMediaDeselected, + onAttachmentStartRequest = screenModel::tryStartAddingAttachment, + onCapturedMediaReady = screenModel::onCapturedMediaReady, + onSendClick = screenModel::onSendClick, + ) +} + +private fun startAudioRecording( + screenModel: ConversationScreenModel, + startMode: AudioRecordingStartMode, +) { + when (startMode) { + AudioRecordingStartMode.Unlocked -> { + screenModel.onAudioRecordingStart() + } + + AudioRecordingStartMode.Locked -> { + screenModel.onLockedAudioRecordingStart() + } + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt new file mode 100644 index 00000000..1bd0f6a9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt @@ -0,0 +1,285 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Forward +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +private val messageSelectionActions = persistentListOf( + ConversationMessageSelectionAction.Download, + ConversationMessageSelectionAction.Resend, + ConversationMessageSelectionAction.Copy, + ConversationMessageSelectionAction.Delete, +) + +private val conversationMessageSelectionActions = persistentListOf( + ConversationMessageSelectionAction.Share, + ConversationMessageSelectionAction.Forward, + ConversationMessageSelectionAction.SaveAttachment, + ConversationMessageSelectionAction.Details, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationSelectionTopAppBar( + selection: ConversationMessageSelectionUiState, + onActionClick: (ConversationMessageSelectionAction) -> Unit, + onDismissSelection: () -> Unit, +) { + var isOverflowExpanded by remember { + mutableStateOf(value = false) + } + + val availableActions = selection.availableActions + val overflowActions = remember(availableActions) { + selectionActionsInOrder( + availableActions = availableActions, + orderedActions = conversationMessageSelectionActions, + ) + } + + TopAppBar( + colors = conversationSelectionTopAppBarColors(), + title = { + ConversationSelectionTitle(selectedMessageCount = selection.selectedMessageCount) + }, + navigationIcon = { + ConversationSelectionNavigationIcon(onDismissSelection = onDismissSelection) + }, + actions = { + ConversationSelectionActions( + availableActions = availableActions, + overflowActions = overflowActions, + isOverflowExpanded = isOverflowExpanded, + onOverflowExpandedChange = { isExpanded -> + isOverflowExpanded = isExpanded + }, + onActionClick = onActionClick, + ) + }, + ) +} + +@Composable +private fun ConversationSelectionTitle(selectedMessageCount: Int) { + Text( + text = pluralStringResource( + id = R.plurals.conversation_message_selection_title, + count = selectedMessageCount, + selectedMessageCount, + ), + ) +} + +@Composable +private fun ConversationSelectionNavigationIcon(onDismissSelection: () -> Unit) { + IconButton( + onClick = onDismissSelection, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + id = R.string.close_selection, + ), + ) + } +} + +@Composable +private fun ConversationSelectionActions( + availableActions: ImmutableSet, + overflowActions: ImmutableList, + isOverflowExpanded: Boolean, + onOverflowExpandedChange: (Boolean) -> Unit, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + val primaryActions = remember(availableActions) { + selectionActionsInOrder( + availableActions = availableActions, + orderedActions = messageSelectionActions, + ) + } + + primaryActions.forEach { action -> + ConversationSelectionActionButton( + action = action, + onActionClick = onActionClick, + ) + } + + if (overflowActions.isNotEmpty()) { + ConversationSelectionOverflowButton( + onClick = { + onOverflowExpandedChange(true) + }, + ) + ConversationSelectionOverflowMenu( + actions = overflowActions, + expanded = isOverflowExpanded, + onDismissRequest = { + onOverflowExpandedChange(false) + }, + onActionClick = onActionClick, + ) + } +} + +@Composable +private fun ConversationSelectionOverflowButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource( + id = R.string.more_options, + ), + ) + } +} + +@Composable +private fun ConversationSelectionOverflowMenu( + actions: ImmutableList, + expanded: Boolean, + onDismissRequest: () -> Unit, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + actions.forEach { action -> + DropdownMenuItem( + text = { + Text(text = selectionActionLabel(action = action)) + }, + onClick = { + onDismissRequest() + onActionClick(action) + }, + leadingIcon = { + Icon( + imageVector = selectionActionIcon(action = action), + contentDescription = null, + ) + }, + ) + } + } +} + +@Composable +private fun conversationSelectionTopAppBarColors(): TopAppBarColors { + return TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) +} + +@Composable +private fun ConversationSelectionActionButton( + action: ConversationMessageSelectionAction, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + IconButton( + onClick = { + onActionClick(action) + }, + ) { + Icon( + imageVector = selectionActionIcon(action = action), + contentDescription = selectionActionLabel(action = action), + ) + } +} + +private fun selectionActionsInOrder( + availableActions: ImmutableSet, + orderedActions: ImmutableList, +): ImmutableList { + return orderedActions.filter { action -> + availableActions.contains(action) + }.toPersistentList() +} + +private fun selectionActionIcon( + action: ConversationMessageSelectionAction, +): ImageVector { + return when (action) { + ConversationMessageSelectionAction.Copy -> Icons.Rounded.ContentCopy + ConversationMessageSelectionAction.Delete -> Icons.Rounded.Delete + ConversationMessageSelectionAction.Details -> Icons.Rounded.Info + ConversationMessageSelectionAction.Download -> Icons.Rounded.FileDownload + ConversationMessageSelectionAction.Forward -> Icons.AutoMirrored.Rounded.Forward + ConversationMessageSelectionAction.Resend -> Icons.AutoMirrored.Rounded.Send + ConversationMessageSelectionAction.SaveAttachment -> Icons.Rounded.Save + ConversationMessageSelectionAction.Share -> Icons.Rounded.Share + } +} + +@Composable +private fun selectionActionLabel( + action: ConversationMessageSelectionAction, +): String { + return when (action) { + ConversationMessageSelectionAction.Copy -> { + stringResource(R.string.message_context_menu_copy_text) + } + ConversationMessageSelectionAction.Delete -> { + stringResource(R.string.action_delete_message) + } + ConversationMessageSelectionAction.Details -> { + stringResource(R.string.message_context_menu_view_details) + } + ConversationMessageSelectionAction.Download -> { + stringResource(R.string.action_download) + } + ConversationMessageSelectionAction.Forward -> { + stringResource(R.string.message_context_menu_forward_message) + } + ConversationMessageSelectionAction.Resend -> { + stringResource(R.string.action_send) + } + ConversationMessageSelectionAction.SaveAttachment -> { + stringResource(R.string.action_save_attachment) + } + ConversationMessageSelectionAction.Share -> { + stringResource(R.string.action_share) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt b/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt new file mode 100644 index 00000000..89800081 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt @@ -0,0 +1,58 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +@Stable +internal class ConversationSimSheetState { + var isVisible: Boolean by mutableStateOf(value = false) + private set + + internal var isAvailable: Boolean = false + + fun show() { + if (isAvailable) { + isVisible = true + } + } + + fun dismiss() { + isVisible = false + } + + companion object { + val Saver: Saver = Saver( + save = { state -> state.isVisible }, + restore = { restoredIsVisible -> + ConversationSimSheetState().apply { + isVisible = restoredIsVisible + } + }, + ) + } +} + +@Composable +internal fun rememberConversationSimSheetState( + isAvailable: Boolean, +): ConversationSimSheetState { + val state = rememberSaveable(saver = ConversationSimSheetState.Saver) { + ConversationSimSheetState() + } + + state.isAvailable = isAvailable + + LaunchedEffect(isAvailable) { + if (!isAvailable) { + state.dismiss() + } + } + + return state +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt new file mode 100644 index 00000000..e9aa4ae0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -0,0 +1,811 @@ +package com.android.messaging.ui.conversation.screen + +import android.app.Activity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +internal interface ConversationScreenModel { + val effects: Flow + val mediaPickerOverlayUiState: StateFlow + val scaffoldUiState: StateFlow + + fun onConversationIdChanged(conversationId: String?) + fun onOpenStartupAttachment( + conversationId: String, + startupAttachment: ConversationEntryStartupAttachment, + ) + + fun onSeedDraft( + conversationId: String, + draft: ConversationDraft, + ) + + fun onAttachmentClicked( + attachment: ComposerAttachmentUiModel.Resolved, + ) + + fun onMessageAttachmentClicked( + contentType: String, + contentUri: String, + ) + + fun onMessageClick(messageId: String) + fun onMessageAvatarClick(messageId: String) + fun onMessageDownloadClick(messageId: String) + fun onMessageLongClick(messageId: String) + fun onMessageResendClick(messageId: String) + fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) + + fun onCallClick() + + fun onSimSelected(selfParticipantId: String) + + fun onExternalUriClicked(uri: String) + + fun onPhotoPickerMediaSelected(contentUris: List) + fun onPhotoPickerMediaDeselected(contentUris: List) + fun onContactCardPicked(contactUri: String?) + fun onMessageTextChanged(text: String) + fun tryStartAddingAttachment(): Boolean + fun onAudioRecordingStart() + fun onLockedAudioRecordingStart() + fun onAudioRecordingLock(): Boolean + fun onAudioRecordingFinish() + fun onAudioRecordingCancel() + fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) + fun onRemovePendingAttachment(pendingAttachmentId: String) + fun onRemoveResolvedAttachment(contentUri: String) + fun onUpdateAttachmentCaption( + contentUri: String, + captionText: String, + ) + + fun dismissDeleteMessageConfirmation() + fun dismissMessageSelection() + fun confirmDeleteSelectedMessages() + fun onSendClick() + fun dismissAttachmentLimitWarning() + fun sendAnywayAfterAttachmentLimitWarning() + fun onDefaultSmsRolePromptActionClick() + fun onDefaultSmsRoleRequestResult(resultCode: Int) + fun onDefaultSmsRoleRequestLaunchFailed() + fun persistDraft() + + fun onArchiveConversationClick() + fun onUnarchiveConversationClick() + fun onAddContactClick() + fun onDeleteConversationClick() + fun confirmDeleteConversation() + fun dismissDeleteConversationConfirmation() + + fun onShowSubjectFieldClick() + fun onSubjectChipClear() + fun onSubjectDialogConfirm(subjectText: String) + fun onSubjectDialogDismiss() + + fun onScreenForegrounded(cancelNotification: Boolean) + fun onScreenBackgrounded() +} + +@HiltViewModel +internal class ConversationViewModel @Inject constructor( + private val conversationAudioRecordingDelegate: ConversationAudioRecordingDelegate, + private val conversationComposerAttachmentsDelegate: ConversationComposerAttachmentsDelegate, + private val conversationDraftDelegate: ConversationDraftDelegate, + private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val conversationMessageSelectionDelegate: ConversationMessageSelectionDelegate, + private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, + private val conversationMetadataDelegate: ConversationMetadataDelegate, + private val conversationFocusDelegate: ConversationFocusDelegate, + private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, + private val subscriptionsRepository: SubscriptionsRepository, + private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, + private val createDefaultSmsRoleRequest: CreateDefaultSmsRoleRequest, + private val isDeviceVoiceCapable: IsDeviceVoiceCapable, + private val isEmergencyPhoneNumber: IsEmergencyPhoneNumber, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, + private val savedStateHandle: SavedStateHandle, +) : ViewModel(), + ConversationScreenModel { + + private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( + key = CONVERSATION_ID_KEY, + initialValue = null, + ) + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + + override val effects = _effects.asSharedFlow() + + private val subscriptionsFlow = subscriptionsRepository + .observeActiveSubscriptions() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = persistentListOf(), + ) + + init { + initializeDelegates() + } + + private val composerUiState = combine( + conversationAudioRecordingDelegate.state, + conversationMetadataDelegate.state, + conversationDraftDelegate.state, + conversationComposerAttachmentsDelegate.state, + subscriptionsFlow, + ) { audioRecordingState, metadataState, draftState, attachments, subscriptions -> + conversationComposerUiStateMapper.map( + audioRecording = audioRecordingState, + draftState = draftState, + attachments = attachments, + composerAvailability = metadataState.composerAvailability, + subscriptions = subscriptions, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = conversationComposerUiStateMapper.map( + audioRecording = conversationAudioRecordingDelegate.state.value, + draftState = conversationDraftDelegate.state.value, + attachments = conversationComposerAttachmentsDelegate.state.value, + composerAvailability = conversationMetadataDelegate.state.value.composerAvailability, + subscriptions = subscriptionsFlow.value, + ), + ) + + private val dialogUiState = combine( + conversationDraftDelegate.attachmentLimitWarning, + conversationMetadataDelegate.isDeleteConversationConfirmationVisible, + conversationDraftDelegate.isSubjectDialogVisible, + ) { attachmentLimitWarning, isDeleteConversationConfirmationVisible, isSubjectDialogVisible -> + ConversationScreenDialogUiState( + attachmentLimitWarning = attachmentLimitWarning, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = isSubjectDialogVisible, + ) + } + + override val scaffoldUiState: StateFlow = combine( + conversationMetadataDelegate.state, + conversationMessagesDelegate.state, + composerUiState, + conversationMessageSelectionDelegate.state, + dialogUiState, + ) { metadataState, messagesUiState, composerUiState, selectionUiState, dialogUiState -> + buildScaffoldUiState( + metadataState = metadataState, + messagesUiState = messagesUiState, + composerUiState = composerUiState, + selectionUiState = selectionUiState, + attachmentLimitWarning = dialogUiState.attachmentLimitWarning, + isDeleteConversationConfirmationVisible = dialogUiState + .isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = dialogUiState.isSubjectDialogVisible, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = buildScaffoldUiState( + metadataState = conversationMetadataDelegate.state.value, + messagesUiState = conversationMessagesDelegate.state.value, + composerUiState = composerUiState.value, + selectionUiState = conversationMessageSelectionDelegate.state.value, + attachmentLimitWarning = conversationDraftDelegate.attachmentLimitWarning.value, + isDeleteConversationConfirmationVisible = + conversationMetadataDelegate.isDeleteConversationConfirmationVisible.value, + isSubjectDialogVisible = conversationDraftDelegate.isSubjectDialogVisible.value, + ), + ) + + private fun buildScaffoldUiState( + metadataState: ConversationMetadataUiState, + messagesUiState: ConversationMessagesUiState, + composerUiState: ConversationComposerUiState, + selectionUiState: ConversationMessageSelectionUiState, + attachmentLimitWarning: ConversationAttachmentLimitWarning?, + isDeleteConversationConfirmationVisible: Boolean, + isSubjectDialogVisible: Boolean, + ): ConversationScreenScaffoldUiState { + val isPresent = metadataState is ConversationMetadataUiState.Present + val presentMetadata = metadataState as? ConversationMetadataUiState.Present + + return ConversationScreenScaffoldUiState( + canAddPeople = canAddPeople(metadataState = metadataState), + canCall = canCall(metadataState = metadataState), + canArchive = isPresent && presentMetadata?.isArchived == false, + canUnarchive = isPresent && presentMetadata?.isArchived == true, + canAddContact = canAddContact(metadataState = metadataState), + canDeleteConversation = isPresent, + canEditSubject = isPresent, + attachmentLimitWarning = attachmentLimitWarning, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = isSubjectDialogVisible, + metadata = metadataState, + messages = messagesUiState, + composer = composerUiState, + selection = selectionUiState, + ) + } + + override val mediaPickerOverlayUiState = combine( + conversationMetadataDelegate.state, + composerUiState, + conversationMediaPickerDelegate.photoPickerSourceContentUriByAttachmentContentUri, + ) { metadataState, composerUiState, photoPickerSourceContentUriByAttachmentContentUri -> + val conversationTitle = when (metadataState) { + is ConversationMetadataUiState.Present -> metadataState.title + else -> null + } + + ConversationMediaPickerOverlayUiState( + attachments = composerUiState.attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = composerUiState.isSendEnabled, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = ConversationMediaPickerOverlayUiState( + attachments = composerUiState.value.attachments, + conversationTitle = null, + isSendActionEnabled = composerUiState.value.isSendEnabled, + photoPickerSourceContentUriByAttachmentContentUri = conversationMediaPickerDelegate + .photoPickerSourceContentUriByAttachmentContentUri.value, + ), + ) + + private fun initializeDelegates() { + conversationAudioRecordingDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationDraftDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationComposerAttachmentsDelegate.bind( + scope = viewModelScope, + draftStateFlow = conversationDraftDelegate.state, + ) + conversationMediaPickerDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationMessagesDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationMessageSelectionDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationMetadataDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationFocusDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + bindDelegateEffects() + } + + private fun bindDelegateEffects() { + viewModelScope.launch(defaultDispatcher) { + conversationDraftDelegate.effects.collect(_effects::emit) + } + viewModelScope.launch(defaultDispatcher) { + conversationMediaPickerDelegate.effects.collect(_effects::emit) + } + viewModelScope.launch(defaultDispatcher) { + conversationMessageSelectionDelegate.effects.collect(_effects::emit) + } + viewModelScope.launch(defaultDispatcher) { + conversationMetadataDelegate.effects.collect(_effects::emit) + } + } + + override fun onConversationIdChanged(conversationId: String?) { + updateConversationId(conversationId = conversationId) + } + + private fun updateConversationId(conversationId: String?) { + if (conversationId != conversationIdFlow.value) { + conversationMessageSelectionDelegate.dismissMessageSelection() + savedStateHandle[CONVERSATION_ID_KEY] = conversationId + } + } + + private fun canAddPeople( + metadataState: ConversationMetadataUiState, + ): Boolean { + return when { + metadataState !is ConversationMetadataUiState.Present -> false + canAddMoreConversationParticipants(metadataState.participantCount) -> true + else -> false + } + } + + private fun canCall( + metadataState: ConversationMetadataUiState, + ): Boolean { + return when { + metadataState !is ConversationMetadataUiState.Present -> false + metadataState.participantCount != 1 -> false + metadataState.otherParticipantPhoneNumber == null -> false + !isDeviceVoiceCapable() -> false + isEmergencyPhoneNumber(metadataState.otherParticipantPhoneNumber) -> false + else -> true + } + } + + private fun canAddContact( + metadataState: ConversationMetadataUiState, + ): Boolean { + return when { + metadataState !is ConversationMetadataUiState.Present -> false + metadataState.participantCount != 1 -> false + metadataState.otherParticipantPhoneNumber.isNullOrBlank() -> false + !metadataState.otherParticipantContactLookupKey.isNullOrBlank() -> false + else -> true + } + } + + override fun onSeedDraft( + conversationId: String, + draft: ConversationDraft, + ) { + conversationDraftDelegate.seedDraft( + conversationId = conversationId, + draft = draft, + ) + } + + override fun onOpenStartupAttachment( + conversationId: String, + startupAttachment: ConversationEntryStartupAttachment, + ) { + val imageCollectionUri = MessagingContentProvider + .buildConversationImagesUri(conversationId) + ?.toString() + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = startupAttachment.contentType, + contentUri = startupAttachment.contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + + override fun onAttachmentClicked(attachment: ComposerAttachmentUiModel.Resolved) { + val imageCollectionUri = conversationIdFlow + .value + ?.let(MessagingContentProvider::buildDraftImagesUri) + ?.toString() + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = attachment.contentType, + contentUri = attachment.contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + + override fun onMessageAttachmentClicked( + contentType: String, + contentUri: String, + ) { + val imageCollectionUri = conversationIdFlow + .value + ?.let(MessagingContentProvider::buildConversationImagesUri) + ?.toString() + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = contentType, + contentUri = contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + + override fun onMessageClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageClick(messageId = messageId) + } + + override fun onMessageAvatarClick(messageId: String) { + val message = when (val messagesState = conversationMessagesDelegate.state.value) { + is ConversationMessagesUiState.Present -> { + messagesState + .messages + .firstOrNull { candidate -> + candidate.messageId == messageId + } + ?.takeIf { it.canShowContactCard } + } + + else -> null + } + + if (message == null) { + return + } + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowOrAddParticipantContact( + contactId = message.senderContactId, + contactLookupKey = message.senderContactLookupKey, + avatarUri = message.senderAvatarUri, + normalizedDestination = message.senderNormalizedDestination, + ), + ) + } + } + + override fun onMessageDownloadClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageDownloadClick(messageId = messageId) + } + + override fun onMessageLongClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageLongClick(messageId = messageId) + } + + override fun onMessageResendClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageResendClick(messageId = messageId) + } + + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { + conversationMessageSelectionDelegate.onMessageSelectionActionClick(action = action) + } + + override fun onCallClick() { + val phoneNumber = ( + conversationMetadataDelegate.state.value as? + ConversationMetadataUiState.Present + ) + ?.otherParticipantPhoneNumber + ?.takeUnless(isEmergencyPhoneNumber::invoke) + ?: return + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.PlacePhoneCall( + phoneNumber = phoneNumber, + ), + ) + } + } + + override fun onSimSelected(selfParticipantId: String) { + conversationDraftDelegate.onSelfParticipantIdChanged( + selfParticipantId = selfParticipantId, + ) + } + + override fun onExternalUriClicked(uri: String) { + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenExternalUri( + uri = uri, + ), + ) + } + } + + override fun onPhotoPickerMediaSelected(contentUris: List) { + conversationMediaPickerDelegate.onPhotoPickerMediaSelected(contentUris = contentUris) + } + + override fun onPhotoPickerMediaDeselected(contentUris: List) { + conversationMediaPickerDelegate.onPhotoPickerMediaDeselected(contentUris = contentUris) + } + + override fun onContactCardPicked(contactUri: String?) { + conversationMediaPickerDelegate.onContactCardPicked(contactUri = contactUri) + } + + override fun onMessageTextChanged(text: String) { + conversationDraftDelegate.onMessageTextChanged(messageText = text) + } + + override fun tryStartAddingAttachment(): Boolean { + return conversationDraftDelegate.tryStartAddingAttachment() + } + + override fun onAudioRecordingStart() { + startAudioRecording(isLocked = false) + } + + override fun onLockedAudioRecordingStart() { + startAudioRecording(isLocked = true) + } + + private fun startAudioRecording(isLocked: Boolean) { + if (!conversationDraftDelegate.tryStartAddingAttachment()) { + return + } + + val effectiveSelfParticipantId = composerUiState.value + .simSelector + .selectedSubscription + ?.selfParticipantId + ?: conversationDraftDelegate.state.value.draft.selfParticipantId + + when { + isLocked -> { + conversationAudioRecordingDelegate.startLockedRecording( + selfParticipantId = effectiveSelfParticipantId, + ) + } + + else -> { + conversationAudioRecordingDelegate.startRecording( + selfParticipantId = effectiveSelfParticipantId, + ) + } + } + } + + override fun onAudioRecordingLock(): Boolean { + return conversationAudioRecordingDelegate.lockRecording() + } + + override fun onAudioRecordingFinish() { + conversationAudioRecordingDelegate.finishRecording() + } + + override fun onAudioRecordingCancel() { + conversationAudioRecordingDelegate.cancelRecording() + } + + override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { + conversationMediaPickerDelegate.onCapturedMediaReady(capturedMedia = capturedMedia) + } + + override fun onRemovePendingAttachment(pendingAttachmentId: String) { + conversationMediaPickerDelegate.onRemovePendingAttachment(pendingAttachmentId) + } + + override fun onRemoveResolvedAttachment(contentUri: String) { + conversationMediaPickerDelegate.onRemoveResolvedAttachment(contentUri = contentUri) + } + + override fun onUpdateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + conversationDraftDelegate.updateAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) + } + + override fun dismissDeleteMessageConfirmation() { + conversationMessageSelectionDelegate.dismissDeleteMessageConfirmation() + } + + override fun dismissMessageSelection() { + conversationMessageSelectionDelegate.dismissMessageSelection() + } + + override fun confirmDeleteSelectedMessages() { + conversationMessageSelectionDelegate.confirmDeleteSelectedMessages() + } + + override fun onSendClick() { + conversationDraftDelegate.onSendClick() + } + + override fun dismissAttachmentLimitWarning() { + conversationDraftDelegate.dismissAttachmentLimitWarning() + } + + override fun sendAnywayAfterAttachmentLimitWarning() { + conversationDraftDelegate.sendAnywayAfterAttachmentLimitWarning() + } + + override fun onDefaultSmsRolePromptActionClick() { + viewModelScope.launch(defaultDispatcher) { + when (val requestIntent = createDefaultSmsRoleRequest()) { + null -> { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + ) + } + + else -> { + _effects.emit( + ConversationScreenEffect.LaunchDefaultSmsRoleRequest( + intent = requestIntent, + ), + ) + } + } + } + } + + override fun onDefaultSmsRoleRequestResult(resultCode: Int) { + if (handlePendingDefaultSmsRoleRequestResult(resultCode = resultCode)) { + return + } + + if (resultCode != Activity.RESULT_OK) { + return + } + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.toast_after_setting_default_sms_app, + ), + ) + } + } + + private fun handlePendingDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val didHandleDraftSend = conversationDraftDelegate.onDefaultSmsRoleRequestResult( + resultCode = resultCode, + ) + + if (didHandleDraftSend) { + return true + } + + return conversationMessageSelectionDelegate.onDefaultSmsRoleRequestResult( + resultCode = resultCode, + ) + } + + override fun onDefaultSmsRoleRequestLaunchFailed() { + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + ) + } + } + + override fun persistDraft() { + conversationDraftDelegate.persistDraft() + } + + override fun onArchiveConversationClick() { + conversationMetadataDelegate.onArchiveConversationClick() + } + + override fun onUnarchiveConversationClick() { + conversationMetadataDelegate.onUnarchiveConversationClick() + } + + override fun onAddContactClick() { + conversationMetadataDelegate.onAddContactClick() + } + + override fun onDeleteConversationClick() { + conversationMetadataDelegate.onDeleteConversationClick() + } + + override fun confirmDeleteConversation() { + conversationMetadataDelegate.confirmDeleteConversation() + } + + override fun dismissDeleteConversationConfirmation() { + conversationMetadataDelegate.dismissDeleteConversationConfirmation() + } + + override fun onShowSubjectFieldClick() { + conversationDraftDelegate.showSubjectDialog() + } + + override fun onSubjectChipClear() { + conversationDraftDelegate.onSubjectTextChanged(subjectText = "") + } + + override fun onSubjectDialogConfirm(subjectText: String) { + conversationDraftDelegate.confirmSubjectDialog(subjectText = subjectText) + } + + override fun onSubjectDialogDismiss() { + conversationDraftDelegate.dismissSubjectDialog() + } + + override fun onScreenForegrounded(cancelNotification: Boolean) { + conversationFocusDelegate.setScreenFocused( + focused = true, + cancelNotification = cancelNotification, + ) + } + + override fun onScreenBackgrounded() { + conversationFocusDelegate.setScreenFocused(focused = false) + } + + override fun onCleared() { + conversationFocusDelegate.setScreenFocused(focused = false) + conversationAudioRecordingDelegate.onScreenCleared() + conversationMediaPickerDelegate.onScreenCleared() + conversationDraftDelegate.flushDraft() + + super.onCleared() + } + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L + } +} + +private data class ConversationScreenDialogUiState( + val attachmentLimitWarning: ConversationAttachmentLimitWarning?, + val isDeleteConversationConfirmationVisible: Boolean, + val isSubjectDialogVisible: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt new file mode 100644 index 00000000..b9cd19c0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.conversation.screen.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationAttachmentLimitWarning { + data object ComposingAttachmentLimitReached : ConversationAttachmentLimitWarning + + data object SendingMessageLimitReached : ConversationAttachmentLimitWarning + + data object SendingVideoAttachmentLimitReached : ConversationAttachmentLimitWarning +} diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt new file mode 100644 index 00000000..1a37037d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -0,0 +1,17 @@ +package com.android.messaging.ui.conversation.screen.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +@Immutable +internal data class ConversationMediaPickerOverlayUiState( + val attachments: ImmutableList = persistentListOf(), + val conversationTitle: String? = null, + val isSendActionEnabled: Boolean = false, + val photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap = + persistentMapOf(), +) diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt new file mode 100644 index 00000000..0a5e71a9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt @@ -0,0 +1,37 @@ +package com.android.messaging.ui.conversation.screen.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +@Immutable +internal data class ConversationMessageSelectionUiState( + val selectedMessageIds: ImmutableSet = persistentSetOf(), + val availableActions: ImmutableSet = persistentSetOf(), + val deleteConfirmation: ConversationMessageDeleteConfirmationUiState? = null, +) { + val isSelectionMode: Boolean + get() = selectedMessageIds.isNotEmpty() + + val isMultiSelect: Boolean + get() = selectedMessageIds.size > 1 + + val selectedMessageCount: Int + get() = selectedMessageIds.size +} + +@Immutable +internal data class ConversationMessageDeleteConfirmationUiState( + val messageIds: ImmutableSet = persistentSetOf(), +) + +internal enum class ConversationMessageSelectionAction { + Copy, + Delete, + Details, + Download, + Forward, + Resend, + SaveAttachment, + Share, +} diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt new file mode 100644 index 00000000..4440ddbb --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt @@ -0,0 +1,74 @@ +package com.android.messaging.ui.conversation.screen.model + +import android.content.Intent +import android.net.Uri +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData + +internal sealed interface ConversationScreenEffect { + data object CloseConversation : ConversationScreenEffect + + data class RequestDefaultSmsRole( + val isSending: Boolean, + ) : ConversationScreenEffect + + data class LaunchAddContactFlow( + val destination: String, + ) : ConversationScreenEffect + + data class LaunchDefaultSmsRoleRequest( + val intent: Intent, + ) : ConversationScreenEffect + + data class LaunchForwardMessage( + val message: MessageData, + ) : ConversationScreenEffect + + data object NotifyDraftSent : ConversationScreenEffect + + data class OpenAttachmentPreview( + val contentType: String, + val contentUri: String, + val imageCollectionUri: String?, + ) : ConversationScreenEffect + + data class OpenExternalUri( + val uri: String, + ) : ConversationScreenEffect + + data class PlacePhoneCall( + val phoneNumber: String, + ) : ConversationScreenEffect + + data class ShowSaveAttachmentsResult( + val imageCount: Int, + val videoCount: Int, + val otherCount: Int, + val failCount: Int, + ) : ConversationScreenEffect + + data class ShareMessage( + val attachmentContentType: String?, + val attachmentContentUri: String?, + val text: String?, + ) : ConversationScreenEffect + + data class ShowMessage( + val messageResId: Int, + ) : ConversationScreenEffect + + data class ShowOrAddParticipantContact( + val contactId: Long, + val contactLookupKey: String?, + val avatarUri: Uri?, + val normalizedDestination: String?, + ) : ConversationScreenEffect + + data class ShowMessageDetails( + val message: ConversationMessageData, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, + ) : ConversationScreenEffect +} diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt new file mode 100644 index 00000000..fa1497e6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt @@ -0,0 +1,24 @@ +package com.android.messaging.ui.conversation.screen.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState + +@Immutable +internal data class ConversationScreenScaffoldUiState( + val canAddPeople: Boolean = false, + val canCall: Boolean = false, + val canArchive: Boolean = false, + val canUnarchive: Boolean = false, + val canAddContact: Boolean = false, + val canDeleteConversation: Boolean = false, + val canEditSubject: Boolean = false, + val attachmentLimitWarning: ConversationAttachmentLimitWarning? = null, + val isDeleteConversationConfirmationVisible: Boolean = false, + val isSubjectDialogVisible: Boolean = false, + val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, + val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, + val composer: ConversationComposerUiState = ConversationComposerUiState(), + val selection: ConversationMessageSelectionUiState = ConversationMessageSelectionUiState(), +) diff --git a/src/com/android/messaging/ui/core/Theme.kt b/src/com/android/messaging/ui/core/Theme.kt index 55a23ac4..6592a8d2 100644 --- a/src/com/android/messaging/ui/core/Theme.kt +++ b/src/com/android/messaging/ui/core/Theme.kt @@ -1,11 +1,22 @@ package com.android.messaging.ui.core import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +private val AppShapes = Shapes( + extraSmall = RoundedCornerShape(size = 12.dp), + small = RoundedCornerShape(size = 16.dp), + medium = RoundedCornerShape(size = 20.dp), + large = RoundedCornerShape(size = 28.dp), + extraLarge = RoundedCornerShape(size = 36.dp), +) @Composable fun AppTheme( @@ -19,6 +30,7 @@ fun AppTheme( MaterialTheme( colorScheme = colorScheme, + shapes = AppShapes, content = content, ) } diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java deleted file mode 100644 index a211058b..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java +++ /dev/null @@ -1,73 +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.ui.mediapicker; - -import com.google.common.base.Preconditions; - -import javax.annotation.concurrent.ThreadSafe; - -/** - * Keeps track of the speech level as last observed by the recognition - * engine as microphone data flows through it. Can be polled by the UI to - * animate its views. - */ -@ThreadSafe -public class AudioLevelSource { - private volatile int mSpeechLevel; - private volatile Listener mListener; - - public static final int LEVEL_UNKNOWN = -1; - - public interface Listener { - void onSpeechLevel(int speechLevel); - } - - public void setSpeechLevel(int speechLevel) { - Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 || - speechLevel == LEVEL_UNKNOWN); - mSpeechLevel = speechLevel; - maybeNotify(); - } - - public int getSpeechLevel() { - return mSpeechLevel; - } - - public void reset() { - setSpeechLevel(LEVEL_UNKNOWN); - } - - public boolean isValid() { - return mSpeechLevel > 0; - } - - private void maybeNotify() { - final Listener l = mListener; - if (l != null) { - l.onSpeechLevel(mSpeechLevel); - } - } - - public synchronized void setListener(Listener listener) { - mListener = listener; - } - - public synchronized void clearListener(Listener listener) { - if (mListener == listener) { - mListener = null; - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java deleted file mode 100644 index 5d79293c..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java +++ /dev/null @@ -1,130 +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.ui.mediapicker; - -import android.Manifest; -import android.content.pm.PackageManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.util.OsUtil; - -/** - * Chooser which allows the user to record audio - */ -class AudioMediaChooser extends MediaChooser implements - AudioRecordView.HostInterface { - private View mEnabledView; - private View mMissingPermissionView; - - AudioMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - return MediaPicker.MEDIA_TYPE_AUDIO; - } - - @Override - public int getIconResource() { - return R.drawable.ic_audio_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_audioChooserDescription; - } - - @Override - public void onAudioRecorded(final MessagePartData item) { - mMediaPicker.dispatchItemsSelected(item, true); - } - - @Override - public void setThemeColor(final int color) { - if (mView != null) { - ((AudioRecordView) mView).setThemeColor(color); - } - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final AudioRecordView view = (AudioRecordView) inflater.inflate( - R.layout.mediapicker_audio_chooser, - container /* root */, - false /* attachToRoot */); - view.setHostInterface(this); - view.setThemeColor(mMediaPicker.getConversationThemeColor()); - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - return view; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_audio_title; - } - - @Override - public boolean isHandlingTouch() { - // Whenever the user is in the process of recording audio, we want to allow the user - // to move the finger within the panel without interpreting that as dragging the media - // picker panel. - return ((AudioRecordView) mView).shouldHandleTouch(); - } - - @Override - public void stopTouchHandling() { - ((AudioRecordView) mView).stopTouchHandling(); - } - - @Override - public void onPause() { - super.onPause(); - if (mView != null) { - ((AudioRecordView) mView).onPause(); - } - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !OsUtil.hasRecordAudioPermission()) { - requestRecordAudioPermission(); - } - } - - private void requestRecordAudioPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, - MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java deleted file mode 100644 index fba493f4..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java +++ /dev/null @@ -1,351 +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.ui.mediapicker; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.media.MediaRecorder; -import android.net.Uri; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.MediaUtil; -import com.android.messaging.util.MediaUtil.OnCompletionListener; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.ThreadUtil; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -/** - * Hosts an audio recorder with tap and hold to record functionality. - */ -public class AudioRecordView extends FrameLayout implements - MediaRecorder.OnErrorListener, - MediaRecorder.OnInfoListener { - /** - * An interface that communicates with the hosted AudioRecordView. - */ - public interface HostInterface extends DraftMessageSubscriptionDataProvider { - void onAudioRecorded(final MessagePartData item); - } - - /** The initial state, the user may press and hold to start recording */ - private static final int MODE_IDLE = 1; - - /** The user has pressed the record button and we are playing the sound indicating the - * start of recording session. Don't record yet since we don't want the beeping sound - * to get into the recording. */ - private static final int MODE_STARTING = 2; - - /** When the user is actively recording */ - private static final int MODE_RECORDING = 3; - - /** When the user has finished recording, we need to record for some additional time. */ - private static final int MODE_STOPPING = 4; - - // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the - // recorded audio by about half a second. To mitigate this issue, we continue the recording - // for some extra time before stopping it. - private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500; - - /** - * The minimum duration of any recording. Below this threshold, it will be treated as if the - * user clicked the record button and inform the user to tap and hold to record. - */ - private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300; - - // For accessibility, the touchable record button is bigger than the record button visual. - private ImageView mRecordButtonVisual; - private View mRecordButton; - private SoundLevels mSoundLevels; - private TextView mHintTextView; - private PausableChronometer mTimerTextView; - private LevelTrackingMediaRecorder mMediaRecorder; - private long mAudioRecordStartTimeMillis; - - private int mCurrentMode = MODE_IDLE; - private HostInterface mHostInterface; - private int mThemeColor; - - public AudioRecordView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mMediaRecorder = new LevelTrackingMediaRecorder(); - } - - public void setHostInterface(final HostInterface hostInterface) { - mHostInterface = hostInterface; - } - - @VisibleForTesting - public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) { - mMediaRecorder = recorder; - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels); - mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual); - mRecordButton = findViewById(R.id.record_button); - mHintTextView = (TextView) findViewById(R.id.hint_text); - mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text); - mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource()); - mRecordButton.setOnTouchListener(new OnTouchListener() { - @Override - public boolean onTouch(final View v, final MotionEvent event) { - final int action = event.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - onRecordButtonTouchDown(); - - // Don't let the record button handle the down event to let it fall through - // so that we can handle it for the entire panel in onTouchEvent(). This is - // done so that: 1) the user taps on the record button to start recording - // 2) the entire panel owns the touch event so we'd keep recording even - // if the user moves outside the button region. - return false; - } - return false; - } - }); - } - - @Override - public boolean onTouchEvent(final MotionEvent event) { - final int action = event.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - return shouldHandleTouch(); - - case MotionEvent.ACTION_MOVE: - return true; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - return onRecordButtonTouchUp(); - } - return super.onTouchEvent(event); - } - - public void onPause() { - // The conversation draft cannot take any updates when it's paused. Therefore, forcibly - // stop recording on pause. - stopRecording(); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - stopRecording(); - } - - private boolean isRecording() { - return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING; - } - - public boolean shouldHandleTouch() { - return mCurrentMode != MODE_IDLE; - } - - public void stopTouchHandling() { - setMode(MODE_IDLE); - stopRecording(); - } - - private void setMode(final int mode) { - if (mCurrentMode != mode) { - mCurrentMode = mode; - updateVisualState(); - } - } - - private void updateVisualState() { - switch (mCurrentMode) { - case MODE_IDLE: - mHintTextView.setVisibility(VISIBLE); - mHintTextView.setTypeface(null, Typeface.NORMAL); - mTimerTextView.setVisibility(GONE); - mSoundLevels.setEnabled(false); - mTimerTextView.stop(); - break; - - case MODE_RECORDING: - case MODE_STOPPING: - mHintTextView.setVisibility(GONE); - mTimerTextView.setVisibility(VISIBLE); - mSoundLevels.setEnabled(true); - mTimerTextView.restart(); - break; - - case MODE_STARTING: - break; // No-Op. - - default: - Assert.fail("invalid mode for AudioRecordView!"); - break; - } - updateRecordButtonAppearance(); - } - - public void setThemeColor(final int color) { - mThemeColor = color; - updateRecordButtonAppearance(); - } - - private void updateRecordButtonAppearance() { - final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic); - final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources() - .getDrawable(R.drawable.audio_record_control_button_background)); - if (isRecording()) { - foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP); - backgroundDrawable.setColor(mThemeColor); - } else { - foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP); - backgroundDrawable.setColor(Color.WHITE); - } - mRecordButtonVisual.setImageDrawable(foregroundDrawable); - mRecordButtonVisual.setBackground(backgroundDrawable); - } - - @VisibleForTesting - boolean onRecordButtonTouchDown() { - if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) { - setMode(MODE_STARTING); - playAudioStartSound(new OnCompletionListener() { - @Override - public void onCompletion() { - // Double-check the current mode before recording since the user may have - // lifted finger from the button before the beeping sound is played through. - final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId()) - .getMaxMessageSize(); - if (mCurrentMode == MODE_STARTING && - mMediaRecorder.startRecording(AudioRecordView.this, - AudioRecordView.this, maxSize)) { - setMode(MODE_RECORDING); - } - } - }); - mAudioRecordStartTimeMillis = System.currentTimeMillis(); - return true; - } - return false; - } - - @VisibleForTesting - boolean onRecordButtonTouchUp() { - if (System.currentTimeMillis() - mAudioRecordStartTimeMillis < - AUDIO_RECORD_MINIMUM_DURATION_MILLIS) { - // The recording is too short, bolden the hint text to instruct the user to - // "tap+hold" to record audio. - final Uri outputUri = stopRecording(); - if (outputUri != null) { - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - Factory.get().getApplicationContext().getContentResolver().delete( - outputUri, null, null); - } - }); - } - setMode(MODE_IDLE); - mHintTextView.setTypeface(null, Typeface.BOLD); - } else if (isRecording()) { - // Record for some extra time to ensure the ending part is saved. - setMode(MODE_STOPPING); - ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { - @Override - public void run() { - onFinishedRecording(); - } - }, AUDIO_RECORD_ENDING_BUFFER_MILLIS); - } else { - setMode(MODE_IDLE); - } - return true; - } - - private Uri stopRecording() { - if (mMediaRecorder.isRecording()) { - return mMediaRecorder.stopRecording(); - } - return null; - } - - @Override // From MediaRecorder.OnInfoListener - public void onInfo(final MediaRecorder mr, final int what, final int extra) { - if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { - // Max size reached. Finish recording immediately. - LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio"); - onFinishedRecording(); - } else { - // These are unknown errors. - onErrorWhileRecording(what, extra); - } - } - - @Override // From MediaRecorder.OnErrorListener - public void onError(final MediaRecorder mr, final int what, final int extra) { - onErrorWhileRecording(what, extra); - } - - private void onErrorWhileRecording(final int what, final int extra) { - LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what + - ", extra=" + extra); - UiUtils.showToastAtBottom(R.string.audio_recording_error); - setMode(MODE_IDLE); - stopRecording(); - } - - private void onFinishedRecording() { - final Uri outputUri = stopRecording(); - if (outputUri != null) { - final Rect startRect = new Rect(); - mRecordButtonVisual.getGlobalVisibleRect(startRect); - final MediaPickerMessagePartData audioItem = - new MediaPickerMessagePartData(startRect, - ContentType.AUDIO_3GPP, outputUri, 0, 0); - mHostInterface.onAudioRecorded(audioItem); - } - playAudioEndSound(); - setMode(MODE_IDLE); - } - - private void playAudioStartSound(final OnCompletionListener completionListener) { - MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener); - } - - private void playAudioEndSound() { - MediaUtil.get().playSound(getContext(), R.raw.audio_end, null); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java deleted file mode 100644 index fb5c9bfc..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraManager.java +++ /dev/null @@ -1,1201 +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.ui.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.pm.ActivityInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.media.MediaRecorder; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Looper; -import androidx.annotation.NonNull; -import android.text.TextUtils; -import android.util.DisplayMetrics; -import android.view.MotionEvent; -import android.view.OrientationEventListener; -import android.view.Surface; -import android.view.View; -import android.view.WindowManager; - -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.Factory; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.media.ImageRequest; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager; -import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; -import com.android.messaging.util.Assert; -import com.android.messaging.util.BugleGservices; -import com.android.messaging.util.BugleGservicesKeys; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -/** - * Class which manages interactions with the camera, but does not do any UI. This class is - * designed to be a singleton to ensure there is one component managing the camera and releasing - * the native resources. - * In order to acquire a camera, a caller must: - *
    - *
  • Call selectCamera to select front or back camera - *
  • Call setSurface to control where the preview is shown - *
  • Call openCamera to request the camera start preview - *
- * Callers should call onPause and onResume to ensure that the camera is release while the activity - * is not active. - * This class is not thread safe. It should only be called from one thread (the UI thread or test - * thread) - */ -class CameraManager implements FocusOverlayManager.Listener { - /** - * Wrapper around the framework camera API to allow mocking different hardware scenarios while - * unit testing - */ - interface CameraWrapper { - int getNumberOfCameras(); - void getCameraInfo(int index, CameraInfo cameraInfo); - Camera open(int cameraId); - /** Add a wrapper for release because a final method cannot be mocked */ - void release(Camera camera); - } - - /** - * Callbacks for the camera manager listener - */ - interface CameraManagerListener { - void onCameraError(int errorCode, Exception e); - void onCameraChanged(); - } - - /** - * Callback when taking image or video - */ - interface MediaCallback { - static final int MEDIA_CAMERA_CHANGED = 1; - static final int MEDIA_NO_DATA = 2; - - void onMediaReady(Uri uriToMedia, String contentType, int width, int height); - void onMediaFailed(Exception exception); - void onMediaInfo(int what); - } - - // Error codes - static final int ERROR_OPENING_CAMERA = 1; - static final int ERROR_SHOWING_PREVIEW = 2; - static final int ERROR_INITIALIZING_VIDEO = 3; - static final int ERROR_STORAGE_FAILURE = 4; - static final int ERROR_RECORDING_VIDEO = 5; - static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6; - static final int ERROR_TAKING_PICTURE = 7; - - private static final String TAG = LogUtil.BUGLE_TAG; - private static final int NO_CAMERA_SELECTED = -1; - - private static CameraManager sInstance; - - /** Default camera wrapper which directs calls to the framework APIs */ - private static CameraWrapper sCameraWrapper = new CameraWrapper() { - @Override - public int getNumberOfCameras() { - return Camera.getNumberOfCameras(); - } - - @Override - public void getCameraInfo(final int index, final CameraInfo cameraInfo) { - Camera.getCameraInfo(index, cameraInfo); - } - - @Override - public Camera open(final int cameraId) { - return Camera.open(cameraId); - } - - @Override - public void release(final Camera camera) { - camera.release(); - } - }; - - /** The CameraInfo for the currently selected camera */ - private final CameraInfo mCameraInfo; - - /** - * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet - */ - private int mCameraIndex; - - /** True if the device has front and back cameras */ - private final boolean mHasFrontAndBackCamera; - - /** True if the camera should be open (may not yet be actually open) */ - private boolean mOpenRequested; - - /** True if the camera is requested to be in video mode */ - private boolean mVideoModeRequested; - - /** The media recorder for video mode */ - private MmsVideoRecorder mMediaRecorder; - - /** Callback to call with video recording updates */ - private MediaCallback mVideoCallback; - - /** The preview view to show the preview on */ - private CameraPreview mCameraPreview; - - /** The helper classs to handle orientation changes */ - private OrientationHandler mOrientationHandler; - - /** Tracks whether the preview has hardware acceleration */ - private boolean mIsHardwareAccelerationSupported; - - /** - * The task for opening the camera, so it doesn't block the UI thread - * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't - * need to be on the UI thread - * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may - * need to create a dedicated thread, or synchronize the threads in the thread pool - */ - private AsyncTask mOpenCameraTask; - - /** - * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if - * no open task is pending - */ - private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - - /** The instance of the currently opened camera */ - private Camera mCamera; - - /** The rotation of the screen relative to the camera's natural orientation */ - private int mRotation; - - /** The callback to notify when errors or other events occur */ - private CameraManagerListener mListener; - - /** True if the camera is currently in the process of taking an image */ - private boolean mTakingPicture; - - /** Provides subscription-related data to access per-subscription configurations. */ - private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; - - /** Manages auto focus visual and behavior */ - private final FocusOverlayManager mFocusOverlayManager; - - private CameraManager() { - mCameraInfo = new CameraInfo(); - mCameraIndex = NO_CAMERA_SELECTED; - - // Check to see if a front and back camera exist - boolean hasFrontCamera = false; - boolean hasBackCamera = false; - final CameraInfo cameraInfo = new CameraInfo(); - final int cameraCount = sCameraWrapper.getNumberOfCameras(); - try { - for (int i = 0; i < cameraCount; i++) { - sCameraWrapper.getCameraInfo(i, cameraInfo); - if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { - hasFrontCamera = true; - } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) { - hasBackCamera = true; - } - if (hasFrontCamera && hasBackCamera) { - break; - } - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "Unable to load camera info", e); - } - mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera; - mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper()); - - // Assume the best until we are proven otherwise - mIsHardwareAccelerationSupported = true; - } - - /** Gets the singleton instance */ - static CameraManager get() { - if (sInstance == null) { - sInstance = new CameraManager(); - } - return sInstance; - } - - /** Allows tests to inject a custom camera wrapper */ - @VisibleForTesting - static void setCameraWrapper(final CameraWrapper cameraWrapper) { - sCameraWrapper = cameraWrapper; - sInstance = null; - } - - /** - * Sets the surface to use to display the preview - * This must only be called AFTER the CameraPreview has a texture ready - * @param preview The preview surface view - */ - void setSurface(final CameraPreview preview) { - if (preview == mCameraPreview) { - return; - } - - if (preview != null) { - Assert.isTrue(preview.isValid()); - preview.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) == - MotionEvent.ACTION_UP) { - mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight()); - mFocusOverlayManager.onSingleTapUp( - (int) motionEvent.getX() + view.getLeft(), - (int) motionEvent.getY() + view.getTop()); - } - return true; - } - }); - } - mCameraPreview = preview; - tryShowPreview(); - } - - void setRenderOverlay(final RenderOverlay renderOverlay) { - mFocusOverlayManager.setFocusRenderer(renderOverlay != null ? - renderOverlay.getPieRenderer() : null); - } - - /** Convenience function to swap between front and back facing cameras */ - void swapCamera() { - Assert.isTrue(mCameraIndex >= 0); - selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ? - CameraInfo.CAMERA_FACING_BACK : - CameraInfo.CAMERA_FACING_FRONT); - } - - /** - * Selects the first camera facing the desired direction, or the first camera if there is no - * camera in the desired direction - * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants - * @return True if a camera was selected, or false if selecting a camera failed - */ - boolean selectCamera(final int desiredFacing) { - try { - // We already selected a camera facing that direction - if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) { - return true; - } - - final int cameraCount = sCameraWrapper.getNumberOfCameras(); - Assert.isTrue(cameraCount > 0); - - mCameraIndex = NO_CAMERA_SELECTED; - setCamera(null); - final CameraInfo cameraInfo = new CameraInfo(); - for (int i = 0; i < cameraCount; i++) { - sCameraWrapper.getCameraInfo(i, cameraInfo); - if (cameraInfo.facing == desiredFacing) { - mCameraIndex = i; - sCameraWrapper.getCameraInfo(i, mCameraInfo); - break; - } - } - - // There's no camera in the desired facing direction, just select the first camera - // regardless of direction - if (mCameraIndex < 0) { - mCameraIndex = 0; - sCameraWrapper.getCameraInfo(0, mCameraInfo); - } - - if (mOpenRequested) { - // The camera is open, so reopen with the newly selected camera - openCamera(); - } - return true; - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - return false; - } - } - - int getCameraIndex() { - return mCameraIndex; - } - - void selectCameraByIndex(final int cameraIndex) { - if (mCameraIndex == cameraIndex) { - return; - } - - try { - mCameraIndex = cameraIndex; - sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo); - if (mOpenRequested) { - openCamera(); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - - @VisibleForTesting - CameraInfo getCameraInfo() { - if (mCameraIndex == NO_CAMERA_SELECTED) { - return null; - } - return mCameraInfo; - } - - /** @return True if this device has camera capabilities */ - boolean hasAnyCamera() { - return sCameraWrapper.getNumberOfCameras() > 0; - } - - /** @return True if the device has both a front and back camera */ - boolean hasFrontAndBackCamera() { - return mHasFrontAndBackCamera; - } - - /** - * Opens the camera on a separate thread and initiates the preview if one is available - */ - void openCamera() { - if (mCameraIndex == NO_CAMERA_SELECTED) { - // Ensure a selected camera if none is currently selected. This may happen if the - // camera chooser is not the default media chooser. - selectCamera(CameraInfo.CAMERA_FACING_BACK); - } - mOpenRequested = true; - // We're already opening the camera or already have the camera handle, nothing more to do - if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) { - return; - } - - // True if the task to open the camera has to be delayed until the current one completes - boolean delayTask = false; - - // Cancel any previous open camera tasks - if (mOpenCameraTask != null) { - mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - delayTask = true; - } - - mPendingOpenCameraIndex = mCameraIndex; - mOpenCameraTask = new AsyncTask() { - private Exception mException; - - @Override - protected Camera doInBackground(final Integer... params) { - try { - final int cameraIndex = params[0]; - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Opening camera " + mCameraIndex); - } - return sCameraWrapper.open(cameraIndex); - } catch (final Exception e) { - LogUtil.e(TAG, "Exception while opening camera", e); - mException = e; - return null; - } - } - - @Override - protected void onPostExecute(final Camera camera) { - // If we completed, but no longer want this camera, then release the camera - if (mOpenCameraTask != this || !mOpenRequested) { - releaseCamera(camera); - cleanup(); - return; - } - - cleanup(); - - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null)); - } - - setCamera(camera); - if (camera == null) { - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, mException); - } - LogUtil.e(TAG, "Error opening camera"); - } - } - - @Override - protected void onCancelled() { - super.onCancelled(); - cleanup(); - } - - private void cleanup() { - mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) { - // If there's another task waiting on this one to complete, start it now - mOpenCameraTask.execute(mCameraIndex); - } else { - mOpenCameraTask = null; - } - - } - }; - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Start opening camera " + mCameraIndex); - } - - if (!delayTask) { - mOpenCameraTask.execute(mCameraIndex); - } - } - - boolean isVideoMode() { - return mVideoModeRequested; - } - - boolean isRecording() { - return mVideoModeRequested && mVideoCallback != null; - } - - void setVideoMode(final boolean videoMode) { - if (mVideoModeRequested == videoMode) { - return; - } - mVideoModeRequested = videoMode; - tryInitOrCleanupVideoMode(); - } - - /** Closes the camera releasing the resources it uses */ - void closeCamera() { - mOpenRequested = false; - setCamera(null); - } - - /** Temporarily closes the camera if it is open */ - void onPause() { - setCamera(null); - } - - /** Reopens the camera if it was opened when onPause was called */ - void onResume() { - if (mOpenRequested) { - openCamera(); - } - } - - /** - * Sets the listener which will be notified of errors or other events in the camera - * @param listener The listener to notify - */ - void setListener(final CameraManagerListener listener) { - Assert.isMainThread(); - mListener = listener; - if (!mIsHardwareAccelerationSupported && mListener != null) { - mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); - } - } - - void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { - mSubscriptionDataProvider = provider; - } - - void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { - Assert.isTrue(!mVideoModeRequested); - Assert.isTrue(!mTakingPicture); - Assert.notNull(callback); - if (mCamera == null) { - // The caller should have checked isCameraAvailable first, but just in case, protect - // against a null camera by notifying the callback that taking the picture didn't work - callback.onMediaFailed(null); - return; - } - final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() { - @Override - public void onPictureTaken(final byte[] bytes, final Camera camera) { - mTakingPicture = false; - if (mCamera != camera) { - // This may happen if the camera was changed between front/back while the - // picture is being taken. - callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED); - return; - } - - if (bytes == null) { - callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA); - return; - } - - final Camera.Size size = camera.getParameters().getPictureSize(); - int width; - int height; - if (mRotation == 90 || mRotation == 270) { - width = size.height; - height = size.width; - } else { - width = size.width; - height = size.height; - } - new ImagePersistTask( - width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) - .executeOnThreadPool(); - } - }; - - mTakingPicture = true; - try { - mCamera.takePicture( - null /* shutter */, - null /* raw */, - null /* postView */, - jpegCallback); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e); - mTakingPicture = false; - if (mListener != null) { - mListener.onCameraError(ERROR_TAKING_PICTURE, e); - } - } - } - - void startVideo(final MediaCallback callback) { - Assert.notNull(callback); - Assert.isTrue(!isRecording()); - mVideoCallback = callback; - tryStartVideoCapture(); - } - - /** - * Asynchronously releases a camera - * @param camera The camera to release - */ - private void releaseCamera(final Camera camera) { - if (camera == null) { - return; - } - - mFocusOverlayManager.onCameraReleased(); - - new AsyncTask() { - @Override - protected Void doInBackground(final Void... params) { - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Releasing camera " + mCameraIndex); - } - sCameraWrapper.release(camera); - return null; - } - }.execute(); - } - - private void releaseMediaRecorder(final boolean cleanupFile) { - if (mMediaRecorder == null) { - return; - } - mVideoModeRequested = false; - - if (cleanupFile) { - mMediaRecorder.cleanupTempFile(); - if (mVideoCallback != null) { - final MediaCallback callback = mVideoCallback; - mVideoCallback = null; - // Notify the callback that we've stopped recording - callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/, - 0 /*height*/); - } - } - - mMediaRecorder.closeVideoFileDescriptor(); - mMediaRecorder.release(); - mMediaRecorder = null; - - if (mCamera != null) { - try { - mCamera.reconnect(); - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - restoreRequestedOrientation(); - } - - /** Updates the orientation of the camera to match the orientation of the device */ - private void updateCameraOrientation() { - if (mCamera == null || mCameraPreview == null || mTakingPicture) { - return; - } - - final WindowManager windowManager = - (WindowManager) mCameraPreview.getContext().getSystemService( - Context.WINDOW_SERVICE); - - int degrees = 0; - switch (windowManager.getDefaultDisplay().getRotation()) { - case Surface.ROTATION_0: degrees = 0; break; - case Surface.ROTATION_90: degrees = 90; break; - case Surface.ROTATION_180: degrees = 180; break; - case Surface.ROTATION_270: degrees = 270; break; - } - - // The display orientation of the camera (this controls the preview image). - int orientation; - - // The clockwise rotation angle relative to the orientation of the camera. This affects - // pictures returned by the camera in Camera.PictureCallback. - int rotation; - if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - orientation = (mCameraInfo.orientation + degrees) % 360; - rotation = orientation; - // compensate the mirror but only for orientation - orientation = (360 - orientation) % 360; - } else { // back-facing - orientation = (mCameraInfo.orientation - degrees + 360) % 360; - rotation = orientation; - } - mRotation = rotation; - if (mMediaRecorder == null) { - try { - mCamera.setDisplayOrientation(orientation); - final Camera.Parameters params = mCamera.getParameters(); - params.setRotation(rotation); - mCamera.setParameters(params); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - } - - /** Sets the current camera, releasing any previously opened camera */ - private void setCamera(final Camera camera) { - if (mCamera == camera) { - return; - } - - releaseMediaRecorder(true /* cleanupFile */); - releaseCamera(mCamera); - mCamera = camera; - tryShowPreview(); - if (mListener != null) { - mListener.onCameraChanged(); - } - } - - /** Shows the preview if the camera is open and the preview is loaded */ - private void tryShowPreview() { - if (mCameraPreview == null || mCamera == null) { - if (mOrientationHandler != null) { - mOrientationHandler.disable(); - mOrientationHandler = null; - } - releaseMediaRecorder(true /* cleanupFile */); - mFocusOverlayManager.onPreviewStopped(); - return; - } - try { - mCamera.stopPreview(); - updateCameraOrientation(); - - final Camera.Parameters params = mCamera.getParameters(); - final Camera.Size pictureSize = chooseBestPictureSize(); - final Camera.Size previewSize = chooseBestPreviewSize(pictureSize); - params.setPreviewSize(previewSize.width, previewSize.height); - params.setPictureSize(pictureSize.width, pictureSize.height); - logCameraSize("Setting preview size: ", previewSize); - logCameraSize("Setting picture size: ", pictureSize); - mCameraPreview.setSize(previewSize, mCameraInfo.orientation); - for (final String focusMode : params.getSupportedFocusModes()) { - if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { - // Use continuous focus if available - params.setFocusMode(focusMode); - break; - } - } - - mCamera.setParameters(params); - mCameraPreview.startPreview(mCamera); - mCamera.startPreview(); - mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() { - @Override - public void onAutoFocusMoving(final boolean start, final Camera camera) { - mFocusOverlayManager.onAutoFocusMoving(start); - } - }); - mFocusOverlayManager.setParameters(mCamera.getParameters()); - mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK); - mFocusOverlayManager.onPreviewStarted(); - tryInitOrCleanupVideoMode(); - if (mOrientationHandler == null) { - mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); - mOrientationHandler.enable(); - } - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e); - if (mListener != null) { - mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e); - if (mListener != null) { - mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); - } - } - } - - private void tryInitOrCleanupVideoMode() { - if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) { - releaseMediaRecorder(true /* cleanupFile */); - return; - } - - if (mMediaRecorder != null) { - return; - } - - try { - mCamera.unlock(); - final int maxMessageSize = getMmsConfig().getMaxMessageSize(); - mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize); - mMediaRecorder.prepare(); - } catch (final FileNotFoundException e) { - LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_STORAGE_FAILURE, e); - } - setVideoMode(false); - return; - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); - } - setVideoMode(false); - return; - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); - } - setVideoMode(false); - return; - } - - tryStartVideoCapture(); - } - - private void tryStartVideoCapture() { - if (mMediaRecorder == null || mVideoCallback == null) { - return; - } - - mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() { - @Override - public void onError(final MediaRecorder mediaRecorder, final int what, - final int extra) { - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, null); - } - restoreRequestedOrientation(); - } - }); - - mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() { - @Override - public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) { - if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED || - what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { - stopVideo(); - } - } - }); - - try { - mMediaRecorder.start(); - final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - lockOrientation(); - } catch (final IllegalStateException e) { - LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e); - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, e); - } - setVideoMode(false); - restoreRequestedOrientation(); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e); - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, e); - } - setVideoMode(false); - restoreRequestedOrientation(); - } - } - - void stopVideo() { - int width = ImageRequest.UNSPECIFIED_SIZE; - int height = ImageRequest.UNSPECIFIED_SIZE; - Uri uri = null; - String contentType = null; - try { - final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - mMediaRecorder.stop(); - width = mMediaRecorder.getVideoWidth(); - height = mMediaRecorder.getVideoHeight(); - uri = mMediaRecorder.getVideoUri(); - contentType = mMediaRecorder.getContentType(); - } catch (final RuntimeException e) { - // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the - // finally clause call the callback with null uri and handle cleanup - LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e); - } finally { - final MediaCallback videoCallback = mVideoCallback; - mVideoCallback = null; - releaseMediaRecorder(false /* cleanupFile */); - if (uri == null) { - tryInitOrCleanupVideoMode(); - } - videoCallback.onMediaReady(uri, contentType, width, height); - } - } - - boolean isCameraAvailable() { - return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; - } - - /** - * External components call into this to report if hardware acceleration is supported. When - * hardware acceleration isn't supported, we need to report an error through the listener - * interface - * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware - * accelerated view. - */ - void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) { - Assert.isMainThread(); - if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) { - // If the value hasn't changed nothing more to do - return; - } - - mIsHardwareAccelerationSupported = isHardwareAccelerationSupported; - if (!isHardwareAccelerationSupported) { - LogUtil.e(TAG, "Software rendering - cannot open camera"); - if (mListener != null) { - mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); - } - } - } - - /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */ - private float getScaleFactorForMaxAllowedSize(final int width, final int height, - final int maxWidth, final int maxHeight) { - if (maxWidth <= 0 || maxHeight <= 0) { - // MmsConfig initialization runs asynchronously on application startup, so there's a - // chance (albeit a very slight one) that we don't have it yet. - LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig"); - return 1.0f; - } - - if (width <= maxWidth && height <= maxHeight) { - // Already meeting requirements. - return 1.0f; - } - - return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height); - } - - private MmsConfig getMmsConfig() { - final int subId = mSubscriptionDataProvider != null ? - mSubscriptionDataProvider.getConversationSelfSubId() : - ParticipantData.DEFAULT_SELF_SUB_ID; - return MmsConfig.get(subId); - } - - /** - * Choose the best picture size by trying to find a size close to the MmsConfig's max size, - * which is closest to the screen aspect ratio - */ - private Camera.Size chooseBestPictureSize() { - final Context context = mCameraPreview.getContext(); - final Resources resources = context.getResources(); - final DisplayMetrics displayMetrics = resources.getDisplayMetrics(); - final int displayOrientation = resources.getConfiguration().orientation; - int cameraOrientation = mCameraInfo.orientation; - - int screenWidth; - int screenHeight; - if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { - // Rotate the camera orientation 90 degrees to compensate for the rotated display - // metrics. Direction doesn't matter because we're just using it for width/height - cameraOrientation += 90; - } - - // Check the camera orientation relative to the display. - // For 0, 180, 360, the screen width/height are the display width/height - // For 90, 270, the screen width/height are inverted from the display - if (cameraOrientation % 180 == 0) { - screenWidth = displayMetrics.widthPixels; - screenHeight = displayMetrics.heightPixels; - } else { - screenWidth = displayMetrics.heightPixels; - screenHeight = displayMetrics.widthPixels; - } - - final MmsConfig mmsConfig = getMmsConfig(); - final int maxWidth = mmsConfig.getMaxImageWidth(); - final int maxHeight = mmsConfig.getMaxImageHeight(); - - // Constrain the size within the max width/height defined by MmsConfig. - final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight, - maxWidth, maxHeight); - screenWidth *= scaleFactor; - screenHeight *= scaleFactor; - - final float aspectRatio = BugleGservices.get().getFloat( - BugleGservicesKeys.CAMERA_ASPECT_RATIO, - screenWidth / (float) screenHeight); - final List sizes = new ArrayList( - mCamera.getParameters().getSupportedPictureSizes()); - final int maxPixels = maxWidth * maxHeight; - - // Sort the sizes so the best size is first - Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels)); - - return sizes.get(0); - } - - /** - * Chose the best preview size based on the picture size. Try to find a size with the same - * aspect ratio and size as the picture if possible - */ - private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) { - final List sizes = new ArrayList( - mCamera.getParameters().getSupportedPreviewSizes()); - final float aspectRatio = pictureSize.width / (float) pictureSize.height; - final int capturePixels = pictureSize.width * pictureSize.height; - - // Sort the sizes so the best size is first - Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE, - aspectRatio, capturePixels)); - - return sizes.get(0); - } - - private class OrientationHandler extends OrientationEventListener { - OrientationHandler(final Context context) { - super(context); - } - - @Override - public void onOrientationChanged(final int orientation) { - updateCameraOrientation(); - } - } - - private static class SizeComparator implements Comparator { - private static final int PREFER_LEFT = -1; - private static final int PREFER_RIGHT = 1; - - // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit - private final int mMaxWidth; - private final int mMaxHeight; - - // The desired aspect ratio - private final float mTargetAspectRatio; - - // The desired size (width x height) to try to match - private final int mTargetPixels; - - public SizeComparator(final int maxWidth, final int maxHeight, - final float targetAspectRatio, final int targetPixels) { - mMaxWidth = maxWidth; - mMaxHeight = maxHeight; - mTargetAspectRatio = targetAspectRatio; - mTargetPixels = targetPixels; - } - - /** - * Returns a negative value if left is a better choice than right, or a positive value if - * right is a better choice is better than left. 0 if they are equal - */ - @Override - public int compare(final Camera.Size left, final Camera.Size right) { - // If one size is less than the max size prefer it over the other - if ((left.width <= mMaxWidth && left.height <= mMaxHeight) != - (right.width <= mMaxWidth && right.height <= mMaxHeight)) { - return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT; - } - - // If one is closer to the target aspect ratio, prefer it. - final float leftAspectRatio = left.width / (float) left.height; - final float rightAspectRatio = right.width / (float) right.height; - final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio); - final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio); - if (leftAspectRatioDiff != rightAspectRatioDiff) { - return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ? - PREFER_LEFT : PREFER_RIGHT; - } - - // At this point they have the same aspect ratio diff and are either both bigger - // than the max size or both smaller than the max size, so prefer the one closest - // to target size - final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels); - final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels); - return leftDiff - rightDiff; - } - } - - @Override // From FocusOverlayManager.Listener - public void autoFocus() { - if (mCamera == null) { - return; - } - - try { - mCamera.autoFocus(new Camera.AutoFocusCallback() { - @Override - public void onAutoFocus(final boolean success, final Camera camera) { - mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */); - } - }); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e); - // If autofocus fails, the camera should have called the callback with success=false, - // but some throw an exception here - mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/); - } - } - - @Override // From FocusOverlayManager.Listener - public void cancelAutoFocus() { - if (mCamera == null) { - return; - } - try { - mCamera.cancelAutoFocus(); - } catch (final RuntimeException e) { - // Ignore - LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e); - } - } - - @Override // From FocusOverlayManager.Listener - public boolean capture() { - return false; - } - - @Override // From FocusOverlayManager.Listener - public void setFocusParameters() { - if (mCamera == null) { - return; - } - try { - final Camera.Parameters parameters = mCamera.getParameters(); - parameters.setFocusMode(mFocusOverlayManager.getFocusMode()); - if (parameters.getMaxNumFocusAreas() > 0) { - // Don't set focus areas (even to null) if focus areas aren't supported, camera may - // crash - parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas()); - } - parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas()); - mCamera.setParameters(parameters); - } catch (final RuntimeException e) { - // This occurs when the device is out of space or when the camera is locked - LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters"); - } - } - - private void logCameraSize(final String prefix, final Camera.Size size) { - // Log the camera size and aspect ratio for help when examining bug reports for camera - // failures - LogUtil.i(TAG, prefix + size.width + "x" + size.height + - " (" + (size.width / (float) size.height) + ")"); - } - - - private Integer mSavedOrientation = null; - - private void lockOrientation() { - // when we start recording, lock our orientation - final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); - final WindowManager windowManager = - (WindowManager) a.getSystemService(Context.WINDOW_SERVICE); - final int rotation = windowManager.getDefaultDisplay().getRotation(); - - mSavedOrientation = a.getRequestedOrientation(); - switch (rotation) { - case Surface.ROTATION_0: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - break; - case Surface.ROTATION_90: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - break; - case Surface.ROTATION_180: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); - break; - case Surface.ROTATION_270: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); - break; - } - - } - - private void restoreRequestedOrientation() { - if (mSavedOrientation != null) { - final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); - if (a != null) { - a.setRequestedOrientation(mSavedOrientation); - } - mSavedOrientation = null; - } - } - - static boolean hasCameraPermission() { - return OsUtil.hasPermission(Manifest.permission.CAMERA); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java deleted file mode 100644 index 2c7a7f2f..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java +++ /dev/null @@ -1,481 +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.ui.mediapicker; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.graphics.Rect; -import android.hardware.Camera; -import android.net.Uri; -import android.os.SystemClock; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.widget.Chronometer; -import android.widget.ImageButton; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback; -import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; -import com.android.messaging.util.Assert; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.UiUtils; - -/** - * Chooser which allows the user to take pictures or video without leaving the current app/activity - */ -class CameraMediaChooser extends MediaChooser implements - CameraManager.CameraManagerListener { - private CameraPreview.CameraPreviewHost mCameraPreviewHost; - private ImageButton mFullScreenButton; - private ImageButton mSwapCameraButton; - private ImageButton mSwapModeButton; - private ImageButton mCaptureButton; - private ImageButton mCancelVideoButton; - private Chronometer mVideoCounter; - private boolean mVideoCancelled; - private int mErrorToast; - private View mEnabledView; - private View mMissingPermissionView; - - CameraMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - if (CameraManager.get().hasAnyCamera()) { - return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; - } else { - return MediaPicker.MEDIA_TYPE_NONE; - } - } - - @Override - public View destroyView() { - CameraManager.get().closeCamera(); - CameraManager.get().setListener(null); - CameraManager.get().setSubscriptionDataProvider(null); - return super.destroyView(); - } - - @Override - protected View createView(final ViewGroup container) { - CameraManager.get().setListener(this); - CameraManager.get().setSubscriptionDataProvider(this); - CameraManager.get().setVideoMode(false); - final LayoutInflater inflater = getLayoutInflater(); - final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate( - R.layout.mediapicker_camera_chooser, - container /* root */, - false /* attachToRoot */); - mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById( - R.id.camera_preview); - mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - if (CameraManager.get().isVideoMode()) { - // Prevent the swipe down in video mode because video is always captured in - // full screen - return true; - } - - return false; - } - }); - - final View shutterVisual = view.findViewById(R.id.camera_shutter_visual); - - mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button); - mFullScreenButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mMediaPicker.setFullScreen(true); - } - }); - - mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button); - mSwapCameraButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - CameraManager.get().swapCamera(); - } - }); - - mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button); - mCaptureButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() / - (float) mCameraPreviewHost.getView().getHeight(), 1); - - if (CameraManager.get().isRecording()) { - CameraManager.get().stopVideo(); - } else { - final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() { - @Override - public void onMediaReady( - final Uri uriToVideo, final String contentType, - final int width, final int height) { - mVideoCounter.stop(); - if (mVideoCancelled || uriToVideo == null) { - mVideoCancelled = false; - } else { - final Rect startRect = new Rect(); - // It's possible to throw out the chooser while taking the - // picture/video. In that case, still use the attachment, just - // skip the startRect - if (mView != null) { - mView.getGlobalVisibleRect(startRect); - } - mMediaPicker.dispatchItemsSelected( - new MediaPickerMessagePartData(startRect, contentType, - uriToVideo, width, height), - true /* dismissMediaPicker */); - } - updateViewState(); - } - - @Override - public void onMediaFailed(final Exception exception) { - UiUtils.showToastAtBottom(R.string.camera_media_failure); - updateViewState(); - } - - @Override - public void onMediaInfo(final int what) { - if (what == MediaCallback.MEDIA_NO_DATA) { - UiUtils.showToastAtBottom(R.string.camera_media_failure); - } - updateViewState(); - } - }; - if (CameraManager.get().isVideoMode()) { - CameraManager.get().startVideo(callback); - mVideoCounter.setBase(SystemClock.elapsedRealtime()); - mVideoCounter.start(); - updateViewState(); - } else { - showShutterEffect(shutterVisual); - CameraManager.get().takePicture(heightPercent, callback); - updateViewState(); - } - } - } - }); - - mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button); - mSwapModeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode(); - if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) { - requestRecordAudioPermission(); - } else { - onSwapMode(); - } - } - }); - - mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button); - mCancelVideoButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mVideoCancelled = true; - CameraManager.get().stopVideo(); - mMediaPicker.dismiss(true); - } - }); - - mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter); - - CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual)); - - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - - // Must set mView before calling updateViewState because it operates on mView - mView = view; - updateViewState(); - updateForPermissionState(CameraManager.hasCameraPermission()); - return view; - } - - @Override - public int getIconResource() { - return R.drawable.ic_camera_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_cameraChooserDescription; - } - - /** - * Updates the view when entering or leaving full-screen camera mode - * @param fullScreen - */ - @Override - void onFullScreenChanged(final boolean fullScreen) { - super.onFullScreenChanged(fullScreen); - if (!fullScreen && CameraManager.get().isVideoMode()) { - CameraManager.get().setVideoMode(false); - } - updateViewState(); - } - - /** - * Initializes the control to a default state when it is opened / closed - * @param open True if the control is opened - */ - @Override - void onOpenedChanged(final boolean open) { - super.onOpenedChanged(open); - updateViewState(); - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected) { - if (CameraManager.hasCameraPermission()) { - // If an error occurred before the chooser was selected, show it now - showErrorToastIfNeeded(); - } else { - requestCameraPermission(); - } - } - } - - private void requestCameraPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA }, - MediaPicker.CAMERA_PERMISSION_REQUEST_CODE); - } - - private void requestRecordAudioPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, - MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - updateForPermissionState(permissionGranted); - if (permissionGranted) { - mCameraPreviewHost.onCameraPermissionGranted(); - } - } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { - Assert.isFalse(CameraManager.get().isVideoMode()); - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Switch to video mode - onSwapMode(); - } else { - // Stay in still-photo mode - } - } - } - - private void updateForPermissionState(final boolean granted) { - // onRequestPermissionsResult can sometimes get called before createView(). - if (mEnabledView == null) { - return; - } - - mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); - } - - @Override - public boolean canSwipeDown() { - if (CameraManager.get().isVideoMode()) { - return true; - } - return super.canSwipeDown(); - } - - /** - * Handles an error from the camera manager by showing the appropriate error message to the user - * @param errorCode One of the CameraManager.ERROR_* constants - * @param e The exception which caused the error, if any - */ - @Override - public void onCameraError(final int errorCode, final Exception e) { - switch (errorCode) { - case CameraManager.ERROR_OPENING_CAMERA: - case CameraManager.ERROR_SHOWING_PREVIEW: - mErrorToast = R.string.camera_error_opening; - break; - case CameraManager.ERROR_INITIALIZING_VIDEO: - mErrorToast = R.string.camera_error_video_init_fail; - updateViewState(); - break; - case CameraManager.ERROR_STORAGE_FAILURE: - mErrorToast = R.string.camera_error_storage_fail; - updateViewState(); - break; - case CameraManager.ERROR_TAKING_PICTURE: - mErrorToast = R.string.camera_error_failure_taking_picture; - break; - default: - mErrorToast = R.string.camera_error_unknown; - LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode); - break; - } - showErrorToastIfNeeded(); - } - - private void showErrorToastIfNeeded() { - if (mErrorToast != 0 && mSelected) { - UiUtils.showToastAtBottom(mErrorToast); - mErrorToast = 0; - } - } - - @Override - public void onCameraChanged() { - updateViewState(); - } - - private void onSwapMode() { - CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode()); - if (CameraManager.get().isVideoMode()) { - mMediaPicker.setFullScreen(true); - - // For now we start recording immediately - mCaptureButton.performClick(); - } - updateViewState(); - } - - private void showShutterEffect(final View shutterVisual) { - final float maxAlpha = getContext().getResources().getFraction( - R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */); - - // Divide by 2 so each half of the animation adds up to the full duration - final int animationDuration = getContext().getResources().getInteger( - R.integer.camera_shutter_duration) / 2; - - final AnimationSet animation = new AnimationSet(false /* shareInterpolator */); - final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha); - alphaInAnimation.setDuration(animationDuration); - animation.addAnimation(alphaInAnimation); - - final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f); - alphaOutAnimation.setStartOffset(animationDuration); - alphaOutAnimation.setDuration(animationDuration); - animation.addAnimation(alphaOutAnimation); - - animation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(final Animation animation) { - shutterVisual.setVisibility(View.VISIBLE); - } - - @Override - public void onAnimationEnd(final Animation animation) { - shutterVisual.setVisibility(View.GONE); - } - - @Override - public void onAnimationRepeat(final Animation animation) { - } - }); - shutterVisual.startAnimation(animation); - } - - /** Updates the state of the buttons and overlays based on the current state of the view */ - private void updateViewState() { - if (mView == null) { - return; - } - - final Context context = getContext(); - if (context == null) { - // Context is null if the fragment was already removed from the activity - return; - } - final boolean fullScreen = mMediaPicker.isFullScreen(); - final boolean videoMode = CameraManager.get().isVideoMode(); - final boolean isRecording = CameraManager.get().isRecording(); - final boolean isCameraAvailable = isCameraAvailable(); - final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo(); - final boolean frontCamera = cameraInfo != null && cameraInfo.facing == - Camera.CameraInfo.CAMERA_FACING_FRONT; - - mView.setSystemUiVisibility( - fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE : - View.SYSTEM_UI_FLAG_VISIBLE); - - mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE); - mFullScreenButton.setEnabled(isCameraAvailable); - mSwapCameraButton.setVisibility( - fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ? - View.VISIBLE : View.GONE); - mSwapCameraButton.setImageResource(frontCamera ? - R.drawable.ic_camera_front_light : - R.drawable.ic_camera_rear_light); - mSwapCameraButton.setEnabled(isCameraAvailable); - - mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE); - mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE); - - mSwapModeButton.setImageResource(videoMode ? - R.drawable.ic_mp_camera_small_light : - R.drawable.ic_mp_video_small_light); - mSwapModeButton.setContentDescription(context.getString(videoMode ? - R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode)); - mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE); - mSwapModeButton.setEnabled(isCameraAvailable); - - if (isRecording) { - mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_stop_recording)); - } else if (videoMode) { - mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_start_recording)); - } else { - mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_take_picture)); - } - mCaptureButton.setEnabled(isCameraAvailable); - } - - @Override - int getActionBarTitleResId() { - return 0; - } - - /** - * Returns if the camera is currently ready camera is loaded and not taking a picture. - * otherwise we should avoid taking another picture, swapping camera or recording video. - */ - private boolean isCameraAvailable() { - return CameraManager.get().isCameraAvailable(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java deleted file mode 100644 index b29b2bf8..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java +++ /dev/null @@ -1,105 +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.ui.mediapicker; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Canvas; -import android.hardware.Camera; -import android.os.Bundle; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import com.android.messaging.R; -import com.android.messaging.ui.PersistentInstanceState; -import com.android.messaging.util.ThreadUtil; - -public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState { - private static final String KEY_CAMERA_INDEX = "camera_index"; - - // True if we have at least queued an update to the view tree to support software rendering - // fallback - private boolean mIsSoftwareFallbackActive; - - public CameraMediaChooserView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - @SuppressLint("MissingSuperCall") - @Override - protected Parcelable onSaveInstanceState() { - final Bundle bundle = new Bundle(); - bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex()); - return bundle; - } - - @SuppressLint("MissingSuperCall") - @Override - protected void onRestoreInstanceState(final Parcelable state) { - if (!(state instanceof Bundle)) { - return; - } - - final Bundle bundle = (Bundle) state; - CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX)); - } - - @Override - public Parcelable saveState() { - return onSaveInstanceState(); - } - - @Override - public void restoreState(final Parcelable restoredState) { - onRestoreInstanceState(restoredState); - } - - @Override - public void resetState() { - CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK); - } - - @Override - protected void onDraw(final Canvas canvas) { - super.onDraw(canvas); - // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview - // with a SoftwareCameraPreview which supports software rendering - if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) { - mIsSoftwareFallbackActive = true; - // Post modifying the tree since we can't modify the view tree during a draw pass - ThreadUtil.getMainThreadHandler().post(new Runnable() { - @Override - public void run() { - final HardwareCameraPreview cameraPreview = - (HardwareCameraPreview) findViewById(R.id.camera_preview); - if (cameraPreview == null) { - return; - } - final ViewGroup parent = ((ViewGroup) cameraPreview.getParent()); - final int index = parent.indexOfChild(cameraPreview); - final SoftwareCameraPreview softwareCameraPreview = - new SoftwareCameraPreview(getContext()); - // Be sure to remove the hardware view before adding the software view to - // prevent having 2 camera previews active at the same time - parent.removeView(cameraPreview); - parent.addView(softwareCameraPreview, index); - } - }); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java deleted file mode 100644 index ecac978a..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraPreview.java +++ /dev/null @@ -1,152 +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.ui.mediapicker; - -import android.content.Context; -import android.content.res.Configuration; -import android.hardware.Camera; -import android.view.View; -import android.view.View.MeasureSpec; -import com.android.messaging.util.Assert; - -import java.io.IOException; - -/** - * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance - * because those classes must inherit from separate Views, so those classes delegate calls to this - * helper class. Specifics for each implementation are in CameraPreviewHost - */ -public class CameraPreview { - public interface CameraPreviewHost { - View getView(); - boolean isValid(); - void startPreview(final Camera camera) throws IOException; - void onCameraPermissionGranted(); - - } - - private int mCameraWidth = -1; - private int mCameraHeight = -1; - - private final CameraPreviewHost mHost; - - public CameraPreview(final CameraPreviewHost host) { - Assert.notNull(host); - Assert.notNull(host.getView()); - mHost = host; - } - - public void setSize(final Camera.Size size, final int orientation) { - switch (orientation) { - case 0: - case 180: - mCameraWidth = size.width; - mCameraHeight = size.height; - break; - case 90: - case 270: - default: - mCameraWidth = size.height; - mCameraHeight = size.width; - } - mHost.getView().requestLayout(); - } - - public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { - if (mCameraHeight >= 0) { - final int width = View.MeasureSpec.getSize(widthMeasureSpec); - return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); - } else { - return widthMeasureSpec; - } - } - - public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { - if (mCameraHeight >= 0) { - final int orientation = getContext().getResources().getConfiguration().orientation; - final int width = View.MeasureSpec.getSize(widthMeasureSpec); - final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight; - int height; - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - height = (int) (width * aspectRatio); - } else { - height = (int) (width / aspectRatio); - } - return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); - } else { - return heightMeasureSpec; - } - } - - public void onVisibilityChanged(final int visibility) { - if (CameraManager.hasCameraPermission()) { - if (visibility == View.VISIBLE) { - CameraManager.get().openCamera(); - } else { - CameraManager.get().closeCamera(); - } - } - } - - public Context getContext() { - return mHost.getView().getContext(); - } - - public void setOnTouchListener(final View.OnTouchListener listener) { - mHost.getView().setOnTouchListener(listener); - } - - public int getHeight() { - return mHost.getView().getHeight(); - } - - public void onAttachedToWindow() { - if (CameraManager.hasCameraPermission()) { - CameraManager.get().openCamera(); - } - } - - public void onDetachedFromWindow() { - CameraManager.get().closeCamera(); - } - - public void onRestoreInstanceState() { - if (CameraManager.hasCameraPermission()) { - CameraManager.get().openCamera(); - } - } - - public void onCameraPermissionGranted() { - CameraManager.get().openCamera(); - } - - /** - * @return True if the view is valid and prepared for the camera to start showing the preview - */ - public boolean isValid() { - return mHost.isValid(); - } - - /** - * Starts the camera preview on the current surface. Abstracts out the differences in API - * from the CameraManager - * @throws IOException Which is caught by the CameraManager to display an error - */ - public void startPreview(final Camera camera) throws IOException { - mHost.startPreview(camera); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java b/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java deleted file mode 100644 index a81ed080..00000000 --- a/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (C) 2020 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.ui.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.net.Uri; -import android.provider.ContactsContract.Contacts; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.ContactUtil; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.SafeAsyncTask; - -/** - * Chooser which allows the user to select an existing contact from contacts apps on this device. - * Note that this chooser requires the Manifest.permission.READ_CONTACTS which is one of the miminum - * set of permissions for this app. Thus no case to request READ_CONTACTS permission on it actually. - */ -class ContactMediaChooser extends MediaChooser { - private View mEnabledView; - private View mMissingPermissionView; - - ContactMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - return MediaPicker.MEDIA_TYPE_VCARD; - } - - @Override - public int getIconResource() { - return R.drawable.ic_person_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_contactChooserDescription; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_contact_title; - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final View view = - inflater.inflate( - R.layout.mediapicker_contact_chooser, - container /* root */, - false /* attachToRoot */); - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - mEnabledView.setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(final View v) { - // Launch an external picker to pick a contact as attachment. - UIIntents.get().launchContactCardPicker(mMediaPicker); - } - }); - return view; - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !ContactUtil.hasReadContactsPermission()) { - mMediaPicker.requestPermissions( - new String[] {Manifest.permission.READ_CONTACTS}, - MediaPicker.READ_CONTACT_PERMISSION_REQUEST_CODE); - } - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.READ_CONTACT_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == UIIntents.REQUEST_PICK_CONTACT_CARD - && resultCode == Activity.RESULT_OK) { - Uri contactUri = data.getData(); - if (contactUri != null) { - String lookupKey = null; - try (final Cursor c = getContext().getContentResolver().query( - contactUri, - new String[] {Contacts.LOOKUP_KEY}, - null, - null, - null)) { - if (c != null) { - c.moveToFirst(); - lookupKey = c.getString(0); - } - } - final Uri vCardUri = Uri.withAppendedPath(Contacts.CONTENT_VCARD_URI, lookupKey); - if (vCardUri != null) { - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - final PendingAttachmentData pendingItem = - PendingAttachmentData.createPendingAttachmentData( - ContentType.TEXT_VCARD.toLowerCase(), vCardUri); - mMediaPicker.dispatchPendingItemAdded(pendingItem); - } - }); - } - } - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java deleted file mode 100644 index e40722f5..00000000 --- a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java +++ /dev/null @@ -1,138 +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.ui.mediapicker; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; - -import androidx.fragment.app.Fragment; - -import com.android.messaging.Factory; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.FileUtil; -import com.android.messaging.util.ImageUtils; -import com.android.messaging.util.SafeAsyncTask; - -/** - * Wraps around the functionalities to allow the user to pick an image/video/audio from the document - * picker. Instances of this class must be tied to a Fragment which is able to delegate activity - * result callbacks. - */ -public class DocumentImagePicker { - - /** - * An interface for a listener that listens for when a document has been picked. - */ - public interface SelectionListener { - /** - * Called when an document is selected from picker. At this point, the file hasn't been - * actually loaded and staged in the temp directory, so we are passing in a pending - * MessagePartData, which the consumer should use to display a placeholder image. - * @param pendingItem a temporary attachment data for showing the placeholder state. - */ - void onDocumentSelected(PendingAttachmentData pendingItem); - } - - // The owning fragment. - private final Fragment mFragment; - - // The listener on the picker events. - private final SelectionListener mListener; - - private static final String EXTRA_PHOTO_URL = "photo_url"; - - /** - * Creates a new instance of DocumentImagePicker. - * @param activity The activity that owns the picker, or the activity that hosts the owning - * fragment. - */ - public DocumentImagePicker(final Fragment fragment, - final SelectionListener listener) { - mFragment = fragment; - mListener = listener; - } - - /** - * Intent out to open an image/video from document picker. - */ - public void launchPicker() { - UIIntents.get().launchDocumentImagePicker(mFragment); - } - - /** - * Must be called from the fragment/activity's onActivityResult(). - */ - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - // Sometimes called after media item has been picked from the document picker. - String url = data.getStringExtra(EXTRA_PHOTO_URL); - if (url == null) { - // we're using the builtin photo picker which supplies the return - // url as it's "data" - url = data.getDataString(); - if (url == null) { - final Bundle extras = data.getExtras(); - if (extras != null) { - final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); - if (uri != null) { - url = uri.toString(); - } - } - } - } - - // Guard against null uri cases for when the activity returns a null/invalid intent. - if (url != null) { - final Uri uri = Uri.parse(url); - prepareDocumentForAttachment(uri); - } - } - - private void prepareDocumentForAttachment(final Uri documentUri) { - // Notify our listener with a PendingAttachmentData containing the metadata. - // Asynchronously get the content type for the picked image since - // ImageUtils.getContentType() potentially involves I/O and can be expensive. - new SafeAsyncTask() { - @Override - protected String doInBackgroundTimed(final Void... params) { - if (FileUtil.isInPrivateDir(documentUri)) { - // hacker sending private app data. Bail out - if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.ERROR)) { - LogUtil.e(LogUtil.BUGLE_TAG, "Aborting attach of private app data (" - + documentUri + ")"); - } - return null; - } - return ImageUtils.getContentType( - Factory.get().getApplicationContext().getContentResolver(), documentUri); - } - - @Override - protected void onPostExecute(final String contentType) { - if (contentType == null) { - return; // bad uri on input - } - // Ask the listener to create a temporary placeholder item to show the progress. - final PendingAttachmentData pendingItem = - PendingAttachmentData.createPendingAttachmentData(contentType, - documentUri); - mListener.onDocumentSelected(pendingItem); - } - }.executeOnThreadPool(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java deleted file mode 100644 index fda3b190..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java +++ /dev/null @@ -1,62 +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.ui.mediapicker; - -import android.content.Context; -import android.database.Cursor; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CursorAdapter; - -import com.android.messaging.R; -import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface; -import com.android.messaging.util.Assert; - -/** - * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView. - */ -public class GalleryGridAdapter extends CursorAdapter { - private GalleryGridItemView.HostInterface mGgivHostInterface; - - public GalleryGridAdapter(final Context context, final Cursor cursor) { - super(context, cursor, 0); - } - - public void setHostInterface(final HostInterface ggivHostInterface) { - mGgivHostInterface = ggivHostInterface; - } - - /** - * {@inheritDoc} - */ - @Override - public void bindView(final View view, final Context context, final Cursor cursor) { - Assert.isTrue(view instanceof GalleryGridItemView); - final GalleryGridItemView galleryImageView = (GalleryGridItemView) view; - galleryImageView.bind(cursor, mGgivHostInterface); - } - - /** - * {@inheritDoc} - */ - @Override - public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { - final LayoutInflater layoutInflater = LayoutInflater.from(context); - return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java deleted file mode 100644 index 6cf509b9..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java +++ /dev/null @@ -1,203 +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.ui.mediapicker; - -import android.content.Context; -import android.database.Cursor; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.TouchDelegate; -import android.view.View; -import android.widget.CheckBox; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.util.ContentType; -import com.google.common.annotations.VisibleForTesting; - -import java.util.concurrent.TimeUnit; - -/** - * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox. - */ -public class GalleryGridItemView extends FrameLayout { - /** - * Implemented by the owner of this GalleryGridItemView instance to communicate on media - * picking and selection events. - */ - public interface HostInterface { - void onItemClicked(View view, GalleryGridItemData data, boolean longClick); - boolean isItemSelected(GalleryGridItemData data); - boolean isMultiSelectEnabled(); - } - - @VisibleForTesting - GalleryGridItemData mData; - private AsyncImageView mImageView; - private CheckBox mCheckBox; - private RelativeLayout mAdditionalInfo; - private ImageView mIcon; - private LinearLayout mFileInfo; - private TextView mFileName; - private TextView mFileType; - private HostInterface mHostInterface; - private final OnClickListener mOnClickListener = new OnClickListener() { - @Override - public void onClick(final View v) { - mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/); - } - }; - - public GalleryGridItemView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mData = DataModel.get().createGalleryGridItemData(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mImageView = (AsyncImageView) findViewById(R.id.thumbnail); - mCheckBox = (CheckBox) findViewById(R.id.checkbox); - mCheckBox.setOnClickListener(mOnClickListener); - mAdditionalInfo = (RelativeLayout) findViewById(R.id.additional_info); - mIcon = (ImageView) findViewById(R.id.icon); - mFileInfo = (LinearLayout) findViewById(R.id.file_info); - mFileName = (TextView) findViewById(R.id.file_name); - mFileType = (TextView) findViewById(R.id.file_type); - setOnClickListener(mOnClickListener); - final OnLongClickListener longClickListener = new OnLongClickListener() { - @Override - public boolean onLongClick(final View v) { - mHostInterface.onItemClicked(v, mData, true /* longClick */); - return true; - } - }; - setOnLongClickListener(longClickListener); - mCheckBox.setOnLongClickListener(longClickListener); - addOnLayoutChangeListener(new OnLayoutChangeListener() { - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - // Enlarge the clickable region for the checkbox to fill the entire view. - final Rect region = new Rect(0, 0, getWidth(), getHeight()); - setTouchDelegate(new TouchDelegate(region, mCheckBox) { - @Override - public boolean onTouchEvent(MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - setPressed(true); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - setPressed(false); - break; - } - return super.onTouchEvent(event); - } - }); - } - }); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - // The grid view auto-fit the columns, so we want to let the height match the width - // to make the image square. - super.onMeasure(widthMeasureSpec, widthMeasureSpec); - } - - public void bind(final Cursor cursor, final HostInterface hostInterface) { - final int desiredSize = getResources() - .getDimensionPixelSize(R.dimen.gallery_image_cell_size); - mData.bind(cursor, desiredSize, desiredSize); - mHostInterface = hostInterface; - updateViewState(); - } - - private void updateViewState() { - updateImageView(); - if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) { - mCheckBox.setVisibility(VISIBLE); - mCheckBox.setClickable(true); - mCheckBox.setChecked(mHostInterface.isItemSelected(mData)); - } else { - mCheckBox.setVisibility(GONE); - mCheckBox.setClickable(false); - } - } - - private void updateImageView() { - if (mData.isDocumentPickerItem()) { - setBackgroundColor(ConversationDrawables.get().getConversationThemeColor()); - mIcon.setImageResource(R.drawable.ic_photo_library_light); - mIcon.clearColorFilter(); - mImageView.setVisibility(GONE); - mIcon.setVisibility(VISIBLE); - mFileInfo.setVisibility(GONE); - mAdditionalInfo.setVisibility(VISIBLE); - } else { - final String contentType = mData.getContentType(); - if (ContentType.isAudioType(contentType)) { - setBackgroundColor( - getResources().getColor(R.color.gallery_image_default_background)); - mIcon.setImageResource(R.drawable.ic_music); - mIcon.setColorFilter( - ConversationDrawables.get().getConversationThemeColor(), - PorterDuff.Mode.SRC_IN); - mFileName.setText(mData.getFileName()); - String[] type = contentType.split("/"); - mFileType.setText(type[1].toUpperCase() + " " + type[0]); - mImageView.setVisibility(GONE); - mIcon.setVisibility(VISIBLE); - mFileInfo.setVisibility(VISIBLE); - mAdditionalInfo.setVisibility(VISIBLE); - } else { // For image and video types - mImageView.setScaleType(ImageView.ScaleType.CENTER_CROP); - setBackgroundColor( - getResources().getColor(R.color.gallery_image_default_background)); - mImageView.setImageResourceId(mData.getImageRequestDescriptor()); - mImageView.setVisibility(VISIBLE); - if (ContentType.isVideoType(mData.getContentType())) { - mIcon.setImageResource(R.drawable.ic_video_play_light); - mIcon.clearColorFilter(); - mIcon.setVisibility(VISIBLE); - } else { - mIcon.setVisibility(GONE); - } - mFileInfo.setVisibility(GONE); - mAdditionalInfo.setVisibility(VISIBLE); - final long dateSeconds = mData.getDateSeconds(); - final boolean isValidDate = (dateSeconds > 0); - final int templateId = isValidDate ? - R.string.mediapicker_gallery_image_item_description : - R.string.mediapicker_gallery_image_item_description_no_date; - String contentDescription = String.format(getResources().getString(templateId), - dateSeconds * TimeUnit.SECONDS.toMillis(1)); - mImageView.setContentDescription(contentDescription); - } - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java deleted file mode 100644 index 2ee0fd06..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java +++ /dev/null @@ -1,316 +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.ui.mediapicker; - -import android.content.Context; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; - -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.ui.PersistentInstanceState; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; - -import java.util.Iterator; -import java.util.Map; - -import androidx.collection.ArrayMap; - -/** - * Shows a list of galley mediae from external storage in a GridView with multi-select capabilities, - * and with the option to intent out to a standalone media picker. - */ -public class GalleryGridView extends MediaPickerGridView implements - GalleryGridItemView.HostInterface, - PersistentInstanceState, - DraftMessageDataListener { - /** - * Implemented by the owner of this GalleryGridView instance to communicate on media picking and - * multi-media selection events. - */ - public interface GalleryGridViewListener { - void onDocumentPickerItemClicked(); - void onItemSelected(MessagePartData item); - void onItemUnselected(MessagePartData item); - void onConfirmSelection(); - void onUpdate(); - } - - private GalleryGridViewListener mListener; - - // TODO: Consider putting this into the data model object if we add more states. - private final ArrayMap mSelectedImages; - private boolean mIsMultiSelectMode = false; - private ImmutableBindingRef mDraftMessageDataModel; - - public GalleryGridView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mSelectedImages = new ArrayMap(); - } - - public void setHostInterface(final GalleryGridViewListener hostInterface) { - mListener = hostInterface; - } - - public void setDraftMessageDataModel(final BindingBase dataModel) { - mDraftMessageDataModel = BindingBase.createBindingReference(dataModel); - mDraftMessageDataModel.getData().addListener(this); - } - - @Override - public void onItemClicked(final View view, final GalleryGridItemData data, - final boolean longClick) { - if (data.isDocumentPickerItem()) { - mListener.onDocumentPickerItemClicked(); - } else if (ContentType.isMediaType(data.getContentType())) { - if (longClick) { - // Turn on multi-select mode when an item is long-pressed. - setMultiSelectEnabled(true); - } - - final Rect startRect = new Rect(); - view.getGlobalVisibleRect(startRect); - if (isMultiSelectEnabled()) { - toggleItemSelection(startRect, data); - } else { - mListener.onItemSelected(data.constructMessagePartData(startRect)); - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, - "Selected item has invalid contentType " + data.getContentType()); - } - } - - @Override - public boolean isItemSelected(final GalleryGridItemData data) { - return mSelectedImages.containsKey(data.getImageUri()); - } - - int getSelectionCount() { - return mSelectedImages.size(); - } - - @Override - public boolean isMultiSelectEnabled() { - return mIsMultiSelectMode; - } - - private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) { - Assert.isTrue(isMultiSelectEnabled()); - if (isItemSelected(data)) { - final MessagePartData item = mSelectedImages.remove(data.getImageUri()); - mListener.onItemUnselected(item); - if (mSelectedImages.size() == 0) { - // No media is selected any more, turn off multi-select mode. - setMultiSelectEnabled(false); - } - } else { - final MessagePartData item = data.constructMessagePartData(startRect); - mSelectedImages.put(data.getImageUri(), item); - mListener.onItemSelected(item); - } - invalidateViews(); - } - - private void toggleMultiSelect() { - mIsMultiSelectMode = !mIsMultiSelectMode; - invalidateViews(); - } - - private void setMultiSelectEnabled(final boolean enabled) { - if (mIsMultiSelectMode != enabled) { - toggleMultiSelect(); - } - } - - private boolean canToggleMultiSelect() { - // We allow the user to toggle multi-select mode only when nothing has selected. If - // something has been selected, we show a confirm button instead. - return mSelectedImages.size() == 0; - } - - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - inflater.inflate(R.menu.gallery_picker_menu, menu); - final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect); - final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect); - final boolean canToggleMultiSelect = canToggleMultiSelect(); - toggleMultiSelect.setVisible(canToggleMultiSelect); - confirmMultiSelect.setVisible(!canToggleMultiSelect); - } - - public boolean onOptionsItemSelected(final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.action_multiselect) { - Assert.isTrue(canToggleMultiSelect()); - toggleMultiSelect(); - return true; - } - if (itemId == R.id.action_confirm_multiselect) { - Assert.isTrue(! canToggleMultiSelect()); - mListener.onConfirmSelection(); - return true; - } - return false; - } - - - @Override - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - mDraftMessageDataModel.ensureBound(data); - // Whenever attachment changed, refresh selection state to remove those that are not - // selected. - if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == - DraftMessageData.ATTACHMENTS_CHANGED) { - refreshImageSelectionStateOnAttachmentChange(); - } - } - - @Override - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - mDraftMessageDataModel.ensureBound(data); - // Whenever draft attachment limit is reach, refresh selection state to remove those - // not actually added to draft. - refreshImageSelectionStateOnAttachmentChange(); - } - - @Override - public void onDraftAttachmentLoadFailed() { - // Nothing to do since the failed attachment gets removed automatically. - } - - private void refreshImageSelectionStateOnAttachmentChange() { - boolean changed = false; - final Iterator> iterator = - mSelectedImages.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) { - iterator.remove(); - changed = true; - } - } - - if (changed) { - mListener.onUpdate(); - invalidateViews(); - } - } - - @Override // PersistentInstanceState - public Parcelable saveState() { - return onSaveInstanceState(); - } - - @Override // PersistentInstanceState - public void restoreState(final Parcelable restoredState) { - onRestoreInstanceState(restoredState); - invalidateViews(); - } - - @Override - public Parcelable onSaveInstanceState() { - final Parcelable superState = super.onSaveInstanceState(); - final SavedState savedState = new SavedState(superState); - savedState.isMultiSelectMode = mIsMultiSelectMode; - savedState.selectedImages = mSelectedImages.values() - .toArray(new MessagePartData[mSelectedImages.size()]); - return savedState; - } - - @Override - public void onRestoreInstanceState(final Parcelable state) { - if (!(state instanceof SavedState)) { - super.onRestoreInstanceState(state); - return; - } - - final SavedState savedState = (SavedState) state; - super.onRestoreInstanceState(savedState.getSuperState()); - mIsMultiSelectMode = savedState.isMultiSelectMode; - mSelectedImages.clear(); - for (int i = 0; i < savedState.selectedImages.length; i++) { - final MessagePartData selectedImage = savedState.selectedImages[i]; - mSelectedImages.put(selectedImage.getContentUri(), selectedImage); - } - } - - @Override // PersistentInstanceState - public void resetState() { - mSelectedImages.clear(); - mIsMultiSelectMode = false; - invalidateViews(); - } - - public static class SavedState extends BaseSavedState { - boolean isMultiSelectMode; - MessagePartData[] selectedImages; - - SavedState(final Parcelable superState) { - super(superState); - } - - private SavedState(final Parcel in) { - super(in); - isMultiSelectMode = in.readInt() == 1 ? true : false; - - // Read parts - final int partCount = in.readInt(); - selectedImages = new MessagePartData[partCount]; - for (int i = 0; i < partCount; i++) { - selectedImages[i] = ((MessagePartData) in.readParcelable( - MessagePartData.class.getClassLoader())); - } - } - - @Override - public void writeToParcel(final Parcel out, final int flags) { - super.writeToParcel(out, flags); - out.writeInt(isMultiSelectMode ? 1 : 0); - - // Write parts - out.writeInt(selectedImages.length); - for (final MessagePartData image : selectedImages) { - out.writeParcelable(image, flags); - } - } - - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public SavedState createFromParcel(final Parcel in) { - return new SavedState(in); - } - @Override - public SavedState[] newArray(final int size) { - return new SavedState[size]; - } - }; - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java deleted file mode 100644 index c5d14a52..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java +++ /dev/null @@ -1,264 +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.ui.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.database.MergeCursor; -import androidx.appcompat.app.ActionBar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener; -import com.android.messaging.util.Assert; -import com.android.messaging.util.OsUtil; - -/** - * Chooser which allows the user to select one or more existing images or videos or audios. - */ -class GalleryMediaChooser extends MediaChooser implements - GalleryGridView.GalleryGridViewListener, MediaPickerDataListener { - private final GalleryGridAdapter mAdapter; - private GalleryGridView mGalleryGridView; - private View mMissingPermissionView; - - /** Handles picking a media from the document picker. */ - private DocumentImagePicker mDocumentImagePicker; - - GalleryMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null); - mDocumentImagePicker = new DocumentImagePicker(mMediaPicker, - new SelectionListener() { - @Override - public void onDocumentSelected(final PendingAttachmentData data) { - if (mBindingRef.isBound()) { - mMediaPicker.dispatchPendingItemAdded(data); - } - } - }); - } - - @Override - public int getSupportedMediaTypes() { - return (MediaPicker.MEDIA_TYPE_IMAGE - | MediaPicker.MEDIA_TYPE_VIDEO - | MediaPicker.MEDIA_TYPE_AUDIO); - } - - @Override - public View destroyView() { - mGalleryGridView.setAdapter(null); - mAdapter.setHostInterface(null); - // The loader is started only if startMediaPickerDataLoader() is called - if (hasStoragePermissions()) { - mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_MEDIA_LOADER); - } - return super.destroyView(); - } - - @Override - public int getIconResource() { - return R.drawable.ic_image_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_galleryChooserDescription; - } - - @Override - public boolean canSwipeDown() { - return mGalleryGridView.canSwipeDown(); - } - - @Override - public void onItemSelected(final MessagePartData item) { - mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled()); - } - - @Override - public void onItemUnselected(final MessagePartData item) { - mMediaPicker.dispatchItemUnselected(item); - } - - @Override - public void onConfirmSelection() { - // The user may only confirm if multiselect is enabled. - Assert.isTrue(mGalleryGridView.isMultiSelectEnabled()); - mMediaPicker.dispatchConfirmItemSelection(); - } - - @Override - public void onUpdate() { - mMediaPicker.invalidateOptionsMenu(); - } - - @Override - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - if (mView != null) { - mGalleryGridView.onCreateOptionsMenu(inflater, menu); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false; - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final View view = inflater.inflate( - R.layout.mediapicker_gallery_chooser, - container /* root */, - false /* attachToRoot */); - - mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view); - mAdapter.setHostInterface(mGalleryGridView); - mGalleryGridView.setAdapter(mAdapter); - mGalleryGridView.setHostInterface(this); - mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel()); - if (hasStoragePermissions()) { - startMediaPickerDataLoader(); - } - - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - updateForPermissionState(hasStoragePermissions()); - return view; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_gallery_title; - } - - @Override - public void onDocumentPickerItemClicked() { - // Launch an external picker to pick item from document picker as attachment. - mDocumentImagePicker.launchPicker(); - } - - @Override - void updateActionBar(final ActionBar actionBar) { - super.updateActionBar(actionBar); - if (mGalleryGridView == null) { - return; - } - final int selectionCount = mGalleryGridView.getSelectionCount(); - if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) { - actionBar.setTitle(getContext().getResources().getString( - R.string.mediapicker_gallery_title_selection, - selectionCount)); - } - } - - @Override - public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data, - final int loaderId) { - mBindingRef.ensureBound(mediaPickerData); - Assert.equals(MediaPickerData.GALLERY_MEDIA_LOADER, loaderId); - Cursor rawCursor = null; - if (data instanceof Cursor) { - rawCursor = (Cursor) data; - } - // Before delivering the cursor, wrap around the local gallery cursor - // with an extra item for document picker integration in the front. - final MatrixCursor specialItemsCursor = - new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS); - specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM }); - final MergeCursor cursor = - new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor }); - mAdapter.swapCursor(cursor); - } - - @Override - public void onResume() { - if (hasStoragePermissions()) { - // Work around a bug in MediaStore where cursors querying the Files provider don't get - // updated for changes to Images.Media or Video.Media. - startMediaPickerDataLoader(); - } - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !hasStoragePermissions()) { - mMediaPicker.requestPermissions( - new String[] { Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_AUDIO }, - MediaPicker.GALLERY_PERMISSION_REQUEST_CODE); - } - } - - private void startMediaPickerDataLoader() { - mBindingRef - .getData() - .startLoader(MediaPickerData.GALLERY_MEDIA_LOADER, mBindingRef, null, this); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - if (permissionGranted) { - startMediaPickerDataLoader(); - } - updateForPermissionState(permissionGranted); - } - } - - private void updateForPermissionState(final boolean granted) { - // onRequestPermissionsResult can sometimes get called before createView(). - if (mGalleryGridView == null) { - return; - } - - mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); - } - - @Override - protected void onActivityResult( - final int requestCode, final int resultCode, final Intent data) { - if (requestCode == UIIntents.REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER - && resultCode == Activity.RESULT_OK) { - mDocumentImagePicker.onActivityResult(requestCode, resultCode, data); - } - } - - private boolean hasStoragePermissions() { - return OsUtil.hasReadImagesPermission() || OsUtil.hasReadVideoPermission() || OsUtil.hasReadAudioPermission(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java deleted file mode 100644 index 45d9579c..00000000 --- a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java +++ /dev/null @@ -1,118 +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.ui.mediapicker; - -import android.content.Context; -import android.graphics.SurfaceTexture; -import android.hardware.Camera; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.TextureView; -import android.view.View; - -import java.io.IOException; - -/** - * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview - * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview - * is used. - * - * There is a significant amount of duplication between HardwareCameraPreview and - * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The - * implementations of the shared methods are delegated to CameraPreview - */ -public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost { - private CameraPreview mPreview; - - public HardwareCameraPreview(final Context context, final AttributeSet attrs) { - super(context, attrs); - mPreview = new CameraPreview(this); - setSurfaceTextureListener(new SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int i, final int i2) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, final int i, final int i2) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { - CameraManager.get().setSurface(null); - return true; - } - - @Override - public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { - CameraManager.get().setSurface(mPreview); - } - }); - } - - @Override - protected void onVisibilityChanged(final View changedView, final int visibility) { - super.onVisibilityChanged(changedView, visibility); - mPreview.onVisibilityChanged(visibility); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mPreview.onDetachedFromWindow(); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mPreview.onAttachedToWindow(); - } - - @Override - protected void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(state); - mPreview.onRestoreInstanceState(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); - heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public View getView() { - return this; - } - - @Override - public boolean isValid() { - return getSurfaceTexture() != null; - } - - @Override - public void startPreview(final Camera camera) throws IOException { - camera.setPreviewTexture(getSurfaceTexture()); - } - - @Override - public void onCameraPermissionGranted() { - mPreview.onCameraPermissionGranted(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java deleted file mode 100644 index 637eb845..00000000 --- a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java +++ /dev/null @@ -1,172 +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.ui.mediapicker; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.net.Uri; - -import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.exif.ExifInterface; -import com.android.messaging.util.exif.ExifTag; - -import java.io.IOException; -import java.io.OutputStream; - -public class ImagePersistTask extends SafeAsyncTask { - private static final String JPEG_EXTENSION = "jpg"; - private static final String TAG = LogUtil.BUGLE_TAG; - - private int mWidth; - private int mHeight; - private final float mHeightPercent; - private final byte[] mBytes; - private final Context mContext; - private final CameraManager.MediaCallback mCallback; - private Uri mOutputUri; - private Exception mException; - - public ImagePersistTask( - final int width, - final int height, - final float heightPercent, - final byte[] bytes, - final Context context, - final CameraManager.MediaCallback callback) { - Assert.isTrue(heightPercent >= 0 && heightPercent <= 1); - Assert.notNull(bytes); - Assert.notNull(context); - Assert.notNull(callback); - mWidth = width; - mHeight = height; - mHeightPercent = heightPercent; - mBytes = bytes; - mContext = context; - mCallback = callback; - // TODO: We probably want to store directly in MMS storage to prevent this - // intermediate step - mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION); - } - - @Override - protected Void doInBackgroundTimed(final Void... params) { - OutputStream outputStream = null; - Bitmap bitmap = null; - Bitmap clippedBitmap = null; - try { - outputStream = - mContext.getContentResolver().openOutputStream(mOutputUri); - if (mHeightPercent != 1.0f) { - int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; - final ExifInterface exifInterface = new ExifInterface(); - try { - exifInterface.readExif(mBytes); - final Integer orientationValue = - exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); - if (orientationValue != null) { - orientation = orientationValue.intValue(); - } - // The thumbnail is of the full image, but we're cropping it, so just clear - // the thumbnail - exifInterface.setCompressedThumbnail((byte[]) null); - } catch (IOException e) { - // Couldn't get exif tags, not the end of the world - } - bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); - final int clippedWidth; - final int clippedHeight; - if (ExifInterface.getOrientationParams(orientation).invertDimensions) { - Assert.equals(mWidth, bitmap.getHeight()); - Assert.equals(mHeight, bitmap.getWidth()); - clippedWidth = (int) (mHeight * mHeightPercent); - clippedHeight = mWidth; - } else { - Assert.equals(mWidth, bitmap.getWidth()); - Assert.equals(mHeight, bitmap.getHeight()); - clippedWidth = mWidth; - clippedHeight = (int) (mHeight * mHeightPercent); - } - final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2; - final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2; - mWidth = clippedWidth; - mHeight = clippedHeight; - clippedBitmap = Bitmap.createBitmap(clippedWidth, clippedHeight, - Bitmap.Config.ARGB_8888); - clippedBitmap.setDensity(bitmap.getDensity()); - final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap); - final Matrix matrix = new Matrix(); - matrix.postTranslate(-offsetLeft, -offsetTop); - clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */); - clippedBitmapCanvas.save(); - // EXIF data can take a big chunk of the file size and is often cleared by the - // carrier, only store orientation since that's critical - ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); - exifInterface.clearExif(); - exifInterface.setTag(orientationTag); - exifInterface.writeExif(clippedBitmap, outputStream); - } else { - outputStream.write(mBytes); - } - } catch (final IOException e) { - mOutputUri = null; - mException = e; - LogUtil.e(TAG, "Unable to persist image to temp storage " + e); - } finally { - if (bitmap != null) { - bitmap.recycle(); - } - - if (clippedBitmap != null) { - clippedBitmap.recycle(); - } - - if (outputStream != null) { - try { - outputStream.flush(); - } catch (final IOException e) { - mOutputUri = null; - mException = e; - LogUtil.e(TAG, "error trying to flush and close the outputStream" + e); - } finally { - try { - outputStream.close(); - } catch (final IOException e) { - // Do nothing. - } - } - } - } - return null; - } - - @Override - protected void onPostExecute(final Void aVoid) { - if (mOutputUri != null) { - mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight); - } else { - Assert.notNull(mException); - mCallback.onMediaFailed(mException); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java index 06730a3b..b8a44247 100644 --- a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java +++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java @@ -32,26 +32,9 @@ /** * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording - * and updates the audio level to be displayed in UI. - * - * During the start and end of a recording session, we kick off a thread that polls for audio - * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the - * sound level by either polling from the level source, or register for a level change callback - * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level - * on the UI thread by using animation ticks and invalidating itself. - * - * Aside from tracking sound levels, this also encapsulates the functionality to save the file - * to the scratch space. The saved file is returned by calling stopRecording(). + * and saves the file to scratch space. The saved file is returned by calling stopRecording(). */ public class LevelTrackingMediaRecorder { - // We refresh sound level every 100ms during a recording session. - private static final int REFRESH_INTERVAL_MILLIS = 100; - - // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this - // is not a constant that's defined anywhere, but the framework's Recorder app is using the - // same hard-coded number). Therefore, a constant is needed in order to make it 0~100. - private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100; - // We want to limit the max audio file size by the max message size allowed by MmsConfig, // plus multiplied by this fudge ratio to guarantee that we don't go over limit. private static final float MAX_SIZE_RATIO = 0.8f; @@ -62,20 +45,10 @@ public class LevelTrackingMediaRecorder { private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP; private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB; - private final AudioLevelSource mLevelSource; - private Thread mRefreshLevelThread; private MediaRecorder mRecorder; private Uri mOutputUri; private ParcelFileDescriptor mOutputFD; - public LevelTrackingMediaRecorder() { - mLevelSource = new AudioLevelSource(); - } - - public AudioLevelSource getLevelSource() { - return mLevelSource; - } - /** * @return if we are currently in a recording session. */ @@ -113,7 +86,6 @@ public boolean startRecording(final MediaRecorder.OnErrorListener errorListener, mRecorder.setOnInfoListener(infoListener); mRecorder.prepare(); mRecorder.start(); - startTrackingSoundLevel(); return true; } catch (final Exception e) { // There may be a device failure or I/O failure, record the error but @@ -174,50 +146,6 @@ public void run() { mOutputFD = null; } - stopTrackingSoundLevel(); return mOutputUri; } - - private int getAmplitude() { - synchronized (LevelTrackingMediaRecorder.class) { - if (mRecorder != null) { - final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR; - return Math.min(maxAmplitude, 100); - } else { - return 0; - } - } - } - - private void startTrackingSoundLevel() { - stopTrackingSoundLevel(); - mRefreshLevelThread = new Thread() { - @Override - public void run() { - try { - while (true) { - synchronized (LevelTrackingMediaRecorder.class) { - if (mRecorder != null) { - mLevelSource.setSpeechLevel(getAmplitude()); - } else { - // The recording session is over, finish the thread. - return; - } - } - Thread.sleep(REFRESH_INTERVAL_MILLIS); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - }; - mRefreshLevelThread.start(); - } - - private void stopTrackingSoundLevel() { - if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) { - mRefreshLevelThread.interrupt(); - mRefreshLevelThread = null; - } - } } diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java deleted file mode 100644 index ea206c79..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaChooser.java +++ /dev/null @@ -1,220 +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.ui.mediapicker; - -import android.content.Context; -import android.content.Intent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; - -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.ui.BasePagerViewHolder; -import com.android.messaging.util.Assert; - -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; - -abstract class MediaChooser extends BasePagerViewHolder - implements DraftMessageSubscriptionDataProvider { - /** The media picker that the chooser is hosted in */ - protected final MediaPicker mMediaPicker; - - /** Referencing the main media picker binding to perform data loading */ - protected final ImmutableBindingRef mBindingRef; - - /** True if this is the selected chooser */ - protected boolean mSelected; - - /** True if this chooser is open */ - protected boolean mOpen; - - /** The button to show in the tab strip */ - private ImageButton mTabButton; - - /** Used by subclasses to indicate that no loader is required from the data model in order for - * this chooser to function. - */ - public static final int NO_LOADER_REQUIRED = -1; - - /** - * Initializes a new instance of the Chooser class - * @param mediaPicker The media picker that the chooser is hosted in - */ - MediaChooser(final MediaPicker mediaPicker) { - Assert.notNull(mediaPicker); - mMediaPicker = mediaPicker; - mBindingRef = mediaPicker.getMediaPickerDataBinding(); - mSelected = false; - } - - protected void setSelected(final boolean selected) { - mSelected = selected; - if (selected) { - // If we're selected, it must be open - mOpen = true; - } - if (mTabButton != null) { - mTabButton.setSelected(selected); - mTabButton.setAlpha(selected ? 1 : 0.5f); - } - } - - ImageButton getTabButton() { - return mTabButton; - } - - void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) { - mTabButton = (ImageButton) inflater.inflate( - R.layout.mediapicker_tab_button, - parent, - false /* addToParent */); - mTabButton.setImageResource(getIconResource()); - mTabButton.setContentDescription( - inflater.getContext().getResources().getString(getIconDescriptionResource())); - setSelected(mSelected); - mTabButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mMediaPicker.selectChooser(MediaChooser.this); - } - }); - } - - protected Context getContext() { - return mMediaPicker.getActivity(); - } - - protected FragmentManager getFragmentManager() { - return mMediaPicker.getChildFragmentManager(); - } - - protected LayoutInflater getLayoutInflater() { - return LayoutInflater.from(getContext()); - } - - /** Allows the chooser to handle full screen change */ - void onFullScreenChanged(final boolean fullScreen) {} - - /** Allows the chooser to handle the chooser being opened or closed */ - void onOpenedChanged(final boolean open) { - mOpen = open; - } - - /** @return The bit field of media types that this chooser can pick */ - public abstract int getSupportedMediaTypes(); - - /** @return The resource id of the icon for the chooser */ - abstract int getIconResource(); - - /** @return The resource id of the string to use for the accessibility text of the icon */ - abstract int getIconDescriptionResource(); - - /** - * Sets up the action bar to show the current state of the full-screen chooser - * @param actionBar The action bar to populate - */ - void updateActionBar(final ActionBar actionBar) { - final int actionBarTitleResId = getActionBarTitleResId(); - if (actionBarTitleResId == 0) { - actionBar.hide(); - } else { - actionBar.setCustomView(null); - actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.show(); - // Use X instead of <- in the action bar - actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light); - actionBar.setTitle(actionBarTitleResId); - } - } - - /** - * Returns the resource Id used for the action bar title. - */ - abstract int getActionBarTitleResId(); - - /** - * Throws an exception if the media chooser object doesn't require data support. - */ - public void onDataUpdated(final Object data, final int loaderId) { - throw new IllegalStateException(); - } - - /** - * Called by the MediaPicker to determine whether this panel can be swiped down further. If - * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the - * entire panel. - */ - public boolean canSwipeDown() { - return false; - } - - /** - * Typically the media picker is closed when the IME is opened, but this allows the chooser to - * specify that showing the IME is okay while the chooser is up - */ - public boolean canShowIme() { - return false; - } - - public boolean onBackPressed() { - return false; - } - - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - } - - public boolean onOptionsItemSelected(final MenuItem item) { - return false; - } - - public void setThemeColor(final int color) { - } - - /** - * Returns true if the chooser is owning any incoming touch events, so that the media picker - * panel won't process it and slide the panel. - */ - public boolean isHandlingTouch() { - return false; - } - - public void stopTouchHandling() { - } - - protected void onActivityResult( - final int requestCode, final int resultCode, final Intent data) {} - - @Override - public int getConversationSelfSubId() { - return mMediaPicker.getConversationSelfSubId(); - } - - /** Optional activity life-cycle methods to be overridden by subclasses */ - public void onPause() { } - public void onResume() { } - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java deleted file mode 100644 index 0ffa2a6d..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPicker.java +++ /dev/null @@ -1,719 +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.ui.mediapicker; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; - -import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; -import androidx.appcompat.app.ActionBar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.LinearLayout; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.FixedViewPagerAdapter; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * Fragment used to select or capture media to be added to the message - */ -public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider { - /** The listener interface for events from the media picker */ - public interface MediaPickerListener { - /** Called when the media picker is opened so the host can accommodate the UI */ - void onOpened(); - - /** - * Called when the media picker goes into or leaves full screen mode so the host can - * accommodate the fullscreen UI - */ - void onFullScreenChanged(boolean fullScreen); - - /** - * Called when the user selects one or more items - * @param items The list of items which were selected - */ - void onItemsSelected(Collection items, boolean dismissMediaPicker); - - /** - * Called when the user unselects one item. - */ - void onItemUnselected(MessagePartData item); - - /** - * Called when the media picker is closed. Always called immediately after onItemsSelected - */ - void onDismissed(); - - /** - * Called when media item selection is confirmed in a multi-select action. - */ - void onConfirmItemSelection(); - - /** - * Called when a pending attachment is added. - * @param pendingItem the pending attachment data being loaded. - */ - void onPendingItemAdded(PendingAttachmentData pendingItem); - - /** - * Called when a new media chooser is selected. - */ - void onChooserSelected(final int chooserIndex); - } - - /** The tag used when registering and finding this fragment */ - public static final String FRAGMENT_TAG = "mediapicker"; - - // Media type constants that the media picker supports - public static final int MEDIA_TYPE_DEFAULT = 0x0000; - public static final int MEDIA_TYPE_NONE = 0x0000; - public static final int MEDIA_TYPE_IMAGE = 0x0001; - public static final int MEDIA_TYPE_VIDEO = 0x0002; - public static final int MEDIA_TYPE_AUDIO = 0x0004; - public static final int MEDIA_TYPE_VCARD = 0x0008; - public static final int MEDIA_TYPE_LOCATION = 0x0010; - private static final int MEDA_TYPE_INVALID = 0x0020; - public static final int MEDIA_TYPE_ALL = 0xFFFF; - - /** The listener to call when events occur */ - private MediaPickerListener mListener; - - /** The handler used to dispatch calls to the listener */ - private Handler mListenerHandler; - - /** The bit flags of media types supported */ - private int mSupportedMediaTypes; - - /** The list of choosers which could be within the media picker */ - private final MediaChooser[] mChoosers; - - /** The list of currently enabled choosers */ - private final ArrayList mEnabledChoosers; - - /** The currently selected chooser */ - private MediaChooser mSelectedChooser; - - /** The main panel that controls the custom layout */ - private MediaPickerPanel mMediaPickerPanel; - - /** The linear layout that holds the icons to select individual chooser tabs */ - private LinearLayout mTabStrip; - - /** The view pager to swap between choosers */ - private ViewPager mViewPager; - - /** The current pager adapter for the view pager */ - private FixedViewPagerAdapter mPagerAdapter; - - /** True if the media picker is visible */ - private boolean mOpen; - - /** The theme color to use to make the media picker match the rest of the UI */ - private int mThemeColor; - - @VisibleForTesting - final Binding mBinding = BindingBase.createBinding(this); - - /** Provides subscription-related data to access per-subscription configurations. */ - private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; - - /** Provides access to DraftMessageData associated with the current conversation */ - private ImmutableBindingRef mDraftMessageDataModel; - - public MediaPicker() { - this(Factory.get().getApplicationContext()); - } - - public MediaPicker(final Context context) { - mBinding.bind(DataModel.get().createMediaPickerData(context)); - mEnabledChoosers = new ArrayList(); - mChoosers = new MediaChooser[] { - new CameraMediaChooser(this), - new GalleryMediaChooser(this), - new AudioMediaChooser(this), - new ContactMediaChooser(this), - }; - - mOpen = false; - setSupportedMediaTypes(MEDIA_TYPE_ALL); - } - - private boolean mIsAttached; - private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; - private boolean mAnimateOnAttach; - - @Override - public void onAttach (final Activity activity) { - super.onAttach(activity); - mIsAttached = true; - if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { - // open() was previously called. Do the pending open now. - doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); - } - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mBinding.getData().init(LoaderManager.getInstance(this)); - } - - @Override - public View onCreateView( - final LayoutInflater inflater, - final ViewGroup container, - final Bundle savedInstanceState) { - mMediaPickerPanel = (MediaPickerPanel) inflater.inflate( - R.layout.mediapicker_fragment, - container, - false); - mMediaPickerPanel.setMediaPicker(this); - mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); - mTabStrip.setBackgroundColor(mThemeColor); - for (final MediaChooser chooser : mChoosers) { - chooser.onCreateTabButton(inflater, mTabStrip); - final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != - MEDIA_TYPE_NONE; - final ImageButton tabButton = chooser.getTabButton(); - if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); - mTabStrip.addView(tabButton); - } - } - - mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager); - mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled( - final int position, - final float positionOffset, - final int positionOffsetPixels) { - } - - @Override - public void onPageSelected(int position) { - // The position returned is relative to if we are in RtL mode. This class never - // switches the indices of the elements if we are in RtL mode so we need to - // translate the index back. For example, if the user clicked the item most to the - // right in RtL mode we would want the index to appear as 0 here, however the - // position returned would the last possible index. - if (UiUtils.isRtlMode()) { - position = mEnabledChoosers.size() - 1 - position; - } - selectChooser(mEnabledChoosers.get(position)); - } - - @Override - public void onPageScrollStateChanged(final int state) { - } - }); - // Camera initialization is expensive, so don't realize offscreen pages if not needed. - mViewPager.setOffscreenPageLimit(0); - mViewPager.setAdapter(mPagerAdapter); - final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( - getActivity()); - mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); - return mMediaPickerPanel; - } - - @Override - public void onPause() { - super.onPause(); - CameraManager.get().onPause(); - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.onPause(); - } - } - - @Override - public void onResume() { - super.onResume(); - CameraManager.get().onResume(); - - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.onResume(); - } - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - mSelectedChooser.onActivityResult(requestCode, resultCode, data); - } - - @Override - public void onDestroy() { - super.onDestroy(); - mBinding.unbind(); - } - - /** - * Sets the theme color to make the media picker match the surrounding UI - * @param themeColor The new theme color - */ - public void setConversationThemeColor(final int themeColor) { - mThemeColor = themeColor; - if (mTabStrip != null) { - mTabStrip.setBackgroundColor(mThemeColor); - } - - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.setThemeColor(mThemeColor); - } - } - - /** - * Gets the current conversation theme color. - */ - public int getConversationThemeColor() { - return mThemeColor; - } - - public void setDraftMessageDataModel(final BindingBase draftBinding) { - mDraftMessageDataModel = Binding.createBindingReference(draftBinding); - } - - public ImmutableBindingRef getDraftMessageDataModel() { - return mDraftMessageDataModel; - } - - public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { - mSubscriptionDataProvider = provider; - } - - @Override - public int getConversationSelfSubId() { - return mSubscriptionDataProvider.getConversationSelfSubId(); - } - - /** - * Opens the media picker and optionally shows the chooser for the supplied media type - * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT} - * is used, then the default chooser from saved shared prefs is opened - */ - public void open(final int startingMediaType, final boolean animate) { - mOpen = true; - if (mIsAttached) { - doOpen(startingMediaType, animate); - } else { - // open() can get called immediately after the MediaPicker is created. In that case, - // we defer doing work as it may require an attached fragment (eg. calling - // Fragment#requestPermission) - mStartingMediaTypeOnAttach = startingMediaType; - mAnimateOnAttach = animate; - } - } - - private void doOpen(int startingMediaType, final boolean animate) { - final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( - // getActivity() will be null at this point - Factory.get().getApplicationContext()); - - // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the - // last opened chooser index from shared prefs. - if (startingMediaType == MEDIA_TYPE_DEFAULT) { - final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); - if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { - selectChooser(mEnabledChoosers.get(selectedChooserIndex)); - } else { - // This is the first time the picker is being used - if (isTouchExplorationEnabled) { - // Accessibility defaults to audio attachment mode. - startingMediaType = MEDIA_TYPE_AUDIO; - } - } - } - - if (mSelectedChooser == null) { - for (final MediaChooser chooser : mEnabledChoosers) { - if (startingMediaType == MEDIA_TYPE_DEFAULT || - (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) { - selectChooser(chooser); - break; - } - } - } - - if (mSelectedChooser == null) { - // Fall back to the first chooser. - selectChooser(mEnabledChoosers.get(0)); - } - - if (mMediaPickerPanel != null) { - mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(true, animate, - mEnabledChoosers.indexOf(mSelectedChooser)); - } - } - - /** @return True if the media picker is open */ - public boolean isOpen() { - return mOpen; - } - - /** - * Sets the list of media types to allow the user to select - * @param mediaTypes The bit flags of media types to allow. Can be any combination of the - * MEDIA_TYPE_* values - */ - void setSupportedMediaTypes(final int mediaTypes) { - mSupportedMediaTypes = mediaTypes; - mEnabledChoosers.clear(); - boolean selectNextChooser = false; - for (final MediaChooser chooser : mChoosers) { - final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != - MEDIA_TYPE_NONE; - if (enabled) { - // TODO Add a way to inform the chooser which media types are supported - mEnabledChoosers.add(chooser); - if (selectNextChooser) { - selectChooser(chooser); - selectNextChooser = false; - } - } else if (mSelectedChooser == chooser) { - selectNextChooser = true; - } - final ImageButton tabButton = chooser.getTabButton(); - if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); - } - } - - if (selectNextChooser && mEnabledChoosers.size() > 0) { - selectChooser(mEnabledChoosers.get(0)); - } - final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()]; - mEnabledChoosers.toArray(enabledChoosers); - mPagerAdapter = new FixedViewPagerAdapter(enabledChoosers); - if (mViewPager != null) { - mViewPager.setAdapter(mPagerAdapter); - } - - // Only rebind data if we are currently bound. Otherwise, we must have not - // bound to any data yet and should wait until onCreate() to bind data. - if (mBinding.isBound() && getActivity() != null) { - mBinding.unbind(); - mBinding.bind(DataModel.get().createMediaPickerData(getActivity())); - mBinding.getData().init(LoaderManager.getInstance(this)); - } - } - - ViewPager getViewPager() { - return mViewPager; - } - - /** Hides the media picker, and frees up any resources it’s using */ - public void dismiss(final boolean animate) { - mOpen = false; - if (mMediaPickerPanel != null) { - mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); - } - mSelectedChooser = null; - } - - /** - * Sets the listener for the media picker events - * @param listener The listener which will receive events - */ - public void setListener(final MediaPickerListener listener) { - Assert.isMainThread(); - mListener = listener; - mListenerHandler = listener != null ? new Handler() : null; - } - - /** @return True if the media picker is in full-screen mode */ - public boolean isFullScreen() { - return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen(); - } - - public void setFullScreen(final boolean fullScreen) { - mMediaPickerPanel.setFullScreenView(fullScreen, true); - } - - public void updateActionBar(final ActionBar actionBar) { - if (getActivity() == null) { - return; - } - if (isFullScreen() && mSelectedChooser != null) { - mSelectedChooser.updateActionBar(actionBar); - } else { - actionBar.hide(); - } - } - - /** - * Selects a new chooser - * @param newSelectedChooser The newly selected chooser - */ - void selectChooser(final MediaChooser newSelectedChooser) { - if (mSelectedChooser == newSelectedChooser) { - return; - } - - if (mSelectedChooser != null) { - mSelectedChooser.setSelected(false); - } - mSelectedChooser = newSelectedChooser; - if (mSelectedChooser != null) { - mSelectedChooser.setSelected(true); - } - - final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser); - if (mViewPager != null) { - mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - - // Save the newly selected chooser's index so we may directly switch to it the - // next time user opens the media picker. - mBinding.getData().saveSelectedChooserIndex(chooserIndex); - if (mMediaPickerPanel != null) { - mMediaPickerPanel.onChooserChanged(); - } - dispatchChooserSelected(chooserIndex); - } - - public boolean canShowIme() { - if (mSelectedChooser != null) { - return mSelectedChooser.canShowIme(); - } - return false; - } - - public boolean onBackPressed() { - return mSelectedChooser != null && mSelectedChooser.onBackPressed(); - } - - void invalidateOptionsMenu() { - ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu(); - } - - void dispatchOpened() { - setHasOptionsMenu(false); - mOpen = true; - mPagerAdapter.notifyDataSetChanged(); - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onOpened(); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onFullScreenChanged(false); - mSelectedChooser.onOpenedChanged(true); - } - } - - void dispatchDismissed() { - setHasOptionsMenu(false); - mOpen = false; - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onDismissed(); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onOpenedChanged(false); - } - } - - void dispatchFullScreen(final boolean fullScreen) { - setHasOptionsMenu(fullScreen); - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onFullScreenChanged(fullScreen); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onFullScreenChanged(fullScreen); - } - } - - void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) { - final List items = new ArrayList(1); - items.add(item); - dispatchItemsSelected(items, dismissMediaPicker); - } - - void dispatchItemsSelected(final Collection items, - final boolean dismissMediaPicker) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onItemsSelected(items, dismissMediaPicker); - } - }); - } - - if (isFullScreen() && !dismissMediaPicker) { - invalidateOptionsMenu(); - } - } - - void dispatchItemUnselected(final MessagePartData item) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onItemUnselected(item); - } - }); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - } - - void dispatchConfirmItemSelection() { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onConfirmItemSelection(); - } - }); - } - } - - void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onPendingItemAdded(pendingItem); - } - }); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - } - - void dispatchChooserSelected(final int chooserIndex) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onChooserSelected(chooserIndex); - } - }); - } - } - - public boolean canSwipeDownChooser() { - return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown(); - } - - public boolean isChooserHandlingTouch() { - return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch(); - } - - public void stopChooserTouchHandling() { - if (mSelectedChooser != null) { - mSelectedChooser.stopTouchHandling(); - } - } - - boolean getChooserShowsActionBarInFullScreen() { - return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0; - } - - @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { - if (mSelectedChooser != null) { - mSelectedChooser.onCreateOptionsMenu(inflater, menu); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) || - super.onOptionsItemSelected(item); - } - - PagerAdapter getPagerAdapter() { - return mPagerAdapter; - } - - public void resetViewHolderState() { - mPagerAdapter.resetState(); - } - - public ImmutableBindingRef getMediaPickerDataBinding() { - return BindingBase.createBindingReference(mBinding); - } - - protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1; - protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2; - protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3; - protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4; - protected static final int READ_CONTACT_PERMISSION_REQUEST_CODE = 5; - - @Override - public void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (mSelectedChooser != null) { - mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java deleted file mode 100644 index cc3a4a1e..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java +++ /dev/null @@ -1,44 +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.ui.mediapicker; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.GridView; - -public class MediaPickerGridView extends GridView { - - public MediaPickerGridView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - /** - * Returns if the grid view can be swiped down further. It cannot be swiped down - * if there's no item or if we are already at the top. - */ - public boolean canSwipeDown() { - if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) { - return false; - } - - final int firstVisiblePosition = getFirstVisiblePosition(); - if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) { - return false; - } - return true; - } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java deleted file mode 100644 index 4dd22a03..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java +++ /dev/null @@ -1,561 +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.ui.mediapicker; - -import android.content.Context; -import android.content.res.Resources; -import android.os.Handler; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.Transformation; -import android.widget.LinearLayout; - -import com.android.messaging.R; -import com.android.messaging.ui.PagingAwareViewPager; -import com.android.messaging.util.UiUtils; - -/** - * Custom layout panel which makes the MediaPicker animations seamless and synchronized - * Designed to be very specific to the MediaPicker's usage - */ -public class MediaPickerPanel extends ViewGroup { - /** - * The window of time in which we might to decide to reinterpret the intent of a gesture. - */ - private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; - - // The two view components to layout - private LinearLayout mTabStrip; - private boolean mFullScreenOnly; - private PagingAwareViewPager mViewPager; - - /** - * True if the MediaPicker is full screen or animating into it - */ - private boolean mFullScreen; - - /** - * True if the MediaPicker is open at all - */ - private boolean mExpanded; - - /** - * The current desired height of the MediaPicker. This property may be animated and the - * measure pass uses it to determine what size the components are. - */ - private int mCurrentDesiredHeight; - - private final Handler mHandler = new Handler(); - - /** - * The media picker for dispatching events to the MediaPicker's listener - */ - private MediaPicker mMediaPicker; - - /** - * The computed default "half-screen" height of the view pager in px - */ - private final int mDefaultViewPagerHeight; - - /** - * The action bar height used to compute the padding on the view pager when it's full screen. - */ - private final int mActionBarHeight; - - private TouchHandler mTouchHandler; - - static final int PAGE_NOT_SET = -1; - - public MediaPickerPanel(final Context context, final AttributeSet attrs) { - super(context, attrs); - // Cache the computed dimension - mDefaultViewPagerHeight = getResources().getDimensionPixelSize( - R.dimen.mediapicker_default_chooser_height); - mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); - mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); - mTouchHandler = new TouchHandler(); - setOnTouchListener(mTouchHandler); - mViewPager.setOnTouchListener(mTouchHandler); - - // Make sure full screen mode is updated in landscape mode change when the panel is open. - addOnLayoutChangeListener(new OnLayoutChangeListener() { - private boolean mLandscapeMode = UiUtils.isLandscapeMode(); - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - final boolean newLandscapeMode = UiUtils.isLandscapeMode(); - if (mLandscapeMode != newLandscapeMode) { - mLandscapeMode = newLandscapeMode; - if (mExpanded) { - setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), - true /* force */); - } - } - } - }); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); - if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { - requestedHeight -= mActionBarHeight; - } - int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); - if (mExpanded && desiredHeight == 0) { - // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will - // cause the framework to abort the animation from 0, so we must always have some - // height once we start expanding - desiredHeight = 1; - } else if (!mExpanded && desiredHeight == 0) { - mViewPager.setVisibility(View.GONE); - mViewPager.setAdapter(null); - } - - measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); - - int tabStripHeight; - if (requiresFullScreen()) { - // Ensure that the tab strip is always visible, even in full screen. - tabStripHeight = mTabStrip.getMeasuredHeight(); - } else { - // Slide out the tab strip at the end of the animation to full screen. - tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), - requestedHeight - desiredHeight); - } - - // If we are animating and have an interim desired height, use the default height. We can't - // take the max here as on some devices the mDefaultViewPagerHeight may be too big in - // landscape mode after animation. - final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; - final int viewPagerHeight = - tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; - - int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( - viewPagerHeight, MeasureSpec.EXACTLY); - measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); - setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); - } - - @Override - protected void onLayout(final boolean changed, final int left, final int top, final int right, - final int bottom) { - int y = top; - final int width = right - left; - - final int viewPagerHeight = mViewPager.getMeasuredHeight(); - mViewPager.layout(0, y, width, y + viewPagerHeight); - y += viewPagerHeight; - - mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); - } - - void onChooserChanged() { - if (mFullScreen) { - setDesiredHeight(getDesiredHeight(), true); - } - } - - void setFullScreenOnly(boolean fullScreenOnly) { - mFullScreenOnly = fullScreenOnly; - } - - boolean isFullScreen() { - return mFullScreen; - } - - void setMediaPicker(final MediaPicker mediaPicker) { - mMediaPicker = mediaPicker; - } - - /** - * Get the desired height of the media picker panel for when the panel is not in motion (i.e. - * not being dragged by the user). - */ - private int getDesiredHeight() { - if (mFullScreen) { - int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; - if (isAttachedToWindow()) { - // When we're attached to the window, we can get an accurate height, not necessary - // on older API level devices because they don't include the action bar height - View composeContainer = - getRootView().findViewById(R.id.conversation_and_compose_container); - if (composeContainer != null) { - // protect against composeContainer having been unloaded already - fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; - } - } - if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { - return fullHeight - mActionBarHeight; - } else { - return fullHeight; - } - } else if (mExpanded) { - return LayoutParams.WRAP_CONTENT; - } else { - return 0; - } - } - - private void setupViewPager(final int startingPage) { - mViewPager.setVisibility(View.VISIBLE); - if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { - mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); - mViewPager.setCurrentItem(startingPage); - } - updateViewPager(); - } - - /** - * Expand the media picker panel. Since we always set the pager adapter to null when the panel - * is collapsed, we need to restore the adapter and the starting page. - * @param expanded expanded or collapsed - * @param animate need animation - * @param startingPage the desired selected page to start - */ - void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { - setExpanded(expanded, animate, startingPage, false /* force */); - } - - private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, - final boolean force) { - if (expanded == mExpanded && !force) { - return; - } - mFullScreen = false; - mExpanded = expanded; - mHandler.post(new Runnable() { - @Override - public void run() { - setDesiredHeight(getDesiredHeight(), animate); - } - }); - if (expanded) { - setupViewPager(startingPage); - mMediaPicker.dispatchOpened(); - } else { - mMediaPicker.dispatchDismissed(); - } - - // Call setFullScreenView() when we are in landscape mode so it can go full screen as - // soon as it is expanded. - if (expanded && requiresFullScreen()) { - setFullScreenView(true, animate); - } - } - - private boolean requiresFullScreen() { - return mFullScreenOnly || UiUtils.isLandscapeMode(); - } - - private void setDesiredHeight(int height, final boolean animate) { - final int startHeight = mCurrentDesiredHeight; - if (height == LayoutParams.WRAP_CONTENT) { - height = measureHeight(); - } - clearAnimation(); - if (animate) { - final int deltaHeight = height - startHeight; - final Animation animation = new Animation() { - @Override - protected void applyTransformation(final float interpolatedTime, - final Transformation t) { - mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); - requestLayout(); - } - - @Override - public boolean willChangeBounds() { - return true; - } - }; - animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); - animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); - startAnimation(animation); - } else { - mCurrentDesiredHeight = height; - } - requestLayout(); - } - - /** - * @return The minimum total height of the view - */ - private int measureHeight() { - final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); - measureChild(mTabStrip, measureSpec, measureSpec); - return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); - } - - /** - * Enters or leaves full screen view - * - * @param fullScreen True to enter full screen view, false to leave - * @param animate True to animate the transition - */ - void setFullScreenView(final boolean fullScreen, final boolean animate) { - if (fullScreen == mFullScreen) { - return; - } - - if (requiresFullScreen() && !fullScreen) { - setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); - return; - } - mFullScreen = fullScreen; - setDesiredHeight(getDesiredHeight(), animate); - mMediaPicker.dispatchFullScreen(mFullScreen); - updateViewPager(); - } - - /** - * ViewPager should have its paging disabled when in full screen mode. - */ - private void updateViewPager() { - mViewPager.setPagingEnabled(!mFullScreen); - } - - @Override - public boolean onInterceptTouchEvent(final MotionEvent ev) { - return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); - } - - /** - * Helper class to handle touch events and swipe gestures - */ - private class TouchHandler implements OnTouchListener { - /** - * The height of the view when the touch press started - */ - private int mDownHeight = -1; - - /** - * True if the panel moved at all (changed height) during the drag - */ - private boolean mMoved = false; - - // The threshold constants converted from DP to px - private final float mFlingThresholdPx; - private final float mBigFlingThresholdPx; - - // The system defined pixel size to determine when a movement is considered a drag. - private final int mTouchSlop; - - /** - * A copy of the MotionEvent that started the drag/swipe gesture - */ - private MotionEvent mDownEvent; - - /** - * Whether we are currently moving down. We may not be able to move down in full screen - * mode when the child view can swipe down (such as a list view). - */ - private boolean mMovedDown = false; - - /** - * Indicates whether the child view contained in the panel can swipe down at the beginning - * of the drag event (i.e. the initial down). The MediaPanel can contain - * scrollable children such as a list view / grid view. If the child view can swipe down, - * We want to let the child view handle the scroll first instead of handling it ourselves. - */ - private boolean mCanChildViewSwipeDown = false; - - /** - * Necessary direction ratio for a fling to be considered in one direction this prevents - * horizontal swipes with small vertical components from triggering vertical swipe actions - */ - private static final float DIRECTION_RATIO = 1.1f; - - TouchHandler() { - final Resources resources = getContext().getResources(); - final ViewConfiguration configuration = ViewConfiguration.get(getContext()); - mFlingThresholdPx = resources.getDimensionPixelSize( - R.dimen.mediapicker_fling_threshold); - mBigFlingThresholdPx = resources.getDimensionPixelSize( - R.dimen.mediapicker_big_fling_threshold); - mTouchSlop = configuration.getScaledTouchSlop(); - } - - /** - * The media picker panel may contain scrollable children such as a GridView, which eats - * all touch events before we get to it. Therefore, we'd like to intercept these events - * before the children to determine if we should handle swiping down in full screen mode. - * In non-full screen mode, we should handle all vertical scrolling events and leave - * horizontal scrolling to the view pager. - */ - public boolean onInterceptTouchEvent(final MotionEvent ev) { - switch (ev.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - // Never capture the initial down, so that the children may handle it - // as well. Let the touch handler know about the down event as well. - mTouchHandler.onTouch(MediaPickerPanel.this, ev); - - // Ask the MediaPicker whether the contained view can be swiped down. - // We record the value at the start of the drag to decide the swiping mode - // for the entire motion. - mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); - return false; - - case MotionEvent.ACTION_MOVE: { - if (mMediaPicker.isChooserHandlingTouch()) { - if (shouldAllowRecaptureTouch(ev)) { - mMediaPicker.stopChooserTouchHandling(); - mViewPager.setPagingEnabled(true); - return false; - } - // If the chooser is claiming ownership on all touch events, then we - // shouldn't try to handle them (neither should the view pager). - mViewPager.setPagingEnabled(false); - return false; - } else if (mCanChildViewSwipeDown) { - // Never capture event if the child view can swipe down. - return false; - } else if (!mFullScreen && mMoved) { - // When we are not fullscreen, we own any vertical drag motion. - return true; - } else if (mMovedDown) { - // We are currently handling the down swipe ourselves, so always - // capture this event. - return true; - } else { - // The current interaction mode is undetermined, so always let the - // touch handler know about this event. However, don't capture this - // event so the child may handle it as well. - mTouchHandler.onTouch(MediaPickerPanel.this, ev); - - // Capture the touch event from now on if we are handling the drag. - return mFullScreen ? mMovedDown : mMoved; - } - } - } - return false; - } - - /** - * Determine whether we think the user is actually trying to expand or slide despite the - * fact that they touched first on a chooser that captured the input. - */ - private boolean shouldAllowRecaptureTouch(MotionEvent ev) { - final long elapsedMs = ev.getEventTime() - ev.getDownTime(); - if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { - // Either we don't have info to decide or it's been long enough that we no longer - // want to reinterpret user intent. - return false; - } - final float dx = ev.getRawX() - mDownEvent.getRawX(); - final float dy = ev.getRawY() - mDownEvent.getRawY(); - final float dt = elapsedMs / 1000.0f; - final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); - final float velocity = maxAbsDelta / dt; - return velocity > mFlingThresholdPx; - } - - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_UP: { - if (!mMoved || mDownEvent == null) { - return false; - } - final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); - final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); - - final float dt = - (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; - final float yVelocity = dy / dt; - - boolean handled = false; - - // Vertical swipe occurred if the direction is as least mostly in the y - // component and has the required velocity (px/sec) - if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && - Math.abs(yVelocity) > mFlingThresholdPx) { - if (yVelocity < 0 && mExpanded) { - setFullScreenView(true, true); - handled = true; - } else if (yVelocity > 0) { - if (mFullScreen && yVelocity < mBigFlingThresholdPx) { - setFullScreenView(false, true); - } else { - setExpanded(false, true, PAGE_NOT_SET); - } - handled = true; - } - } - - if (!handled) { - // If they didn't swipe enough, animate back to resting state - setDesiredHeight(getDesiredHeight(), true); - } - resetState(); - break; - } - case MotionEvent.ACTION_DOWN: { - mDownHeight = getHeight(); - mDownEvent = MotionEvent.obtain(motionEvent); - // If we are here and care about the return value (i.e. this is not called - // from onInterceptTouchEvent), then presumably no children view in the panel - // handles the down event. We'd like to handle future ACTION_MOVE events, so - // always claim ownership on this event so it doesn't fall through and gets - // cancelled by the framework. - return true; - } - case MotionEvent.ACTION_MOVE: { - if (mDownEvent == null) { - return mMoved; - } - - final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); - final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); - // Don't act if the move is mostly horizontal - if (Math.abs(dy) > mTouchSlop && - (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { - setDesiredHeight((int) (mDownHeight + dy), false); - mMoved = true; - if (dy < -mTouchSlop) { - mMovedDown = true; - } - } - return mMoved; - } - - } - return mMoved; - } - - private void resetState() { - mDownEvent = null; - mDownHeight = -1; - mMoved = false; - mMovedDown = false; - mCanChildViewSwipeDown = false; - updateViewPager(); - } - } -} - diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java deleted file mode 100644 index 89241b74..00000000 --- a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java +++ /dev/null @@ -1,142 +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.ui.mediapicker; - -import android.hardware.Camera; -import android.media.CamcorderProfile; -import android.media.MediaRecorder; -import android.net.Uri; -import android.os.ParcelFileDescriptor; - -import com.android.messaging.Factory; -import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.SafeAsyncTask; - -import java.io.FileNotFoundException; -import java.io.IOException; - -class MmsVideoRecorder extends MediaRecorder { - private static final float VIDEO_OVERSHOOT_SLOP = .85F; - - private static final int BITS_PER_BYTE = 8; - - // We think user will expect to be able to record videos at least this long - private static final long MIN_DURATION_LIMIT_SECONDS = 25; - - /** The uri where video is being recorded to */ - private Uri mTempVideoUri; - - private ParcelFileDescriptor mVideoFD; - - /** The settings used for video recording */ - private final CamcorderProfile mCamcorderProfile; - - public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation, - final int maxMessageSize) - throws FileNotFoundException { - mCamcorderProfile = - CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW); - mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( - ContentType.getExtension(getContentType())); - - // The video recorder can sometimes return a file that's larger than the max we - // say we can handle. Try to handle that overshoot by specifying an 85% limit. - final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP); - - // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable - // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video - // to be recorded. - int audioBitRate = mCamcorderProfile.audioBitRate; - int videoBitRate = mCamcorderProfile.videoBitRate; - final double initialDurationLimit = sizeLimit * BITS_PER_BYTE - / (double) (audioBitRate + videoBitRate); - if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) { - // Reduce the suggested bitrates. These bitrates are only requests, if implementation - // can't actually hit these goals it will still record video at higher rate and stop when - // it hits the size limit. - final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS; - audioBitRate *= bitRateAdjustmentFactor; - videoBitRate *= bitRateAdjustmentFactor; - } - - setCamera(camera); - setOrientationHint(orientation); - setAudioSource(MediaRecorder.AudioSource.CAMCORDER); - setVideoSource(MediaRecorder.VideoSource.CAMERA); - setOutputFormat(mCamcorderProfile.fileFormat); - mVideoFD = Factory.get().getApplicationContext().getContentResolver() - .openFileDescriptor(mTempVideoUri, "w"); - setOutputFile(mVideoFD.getFileDescriptor()); - - // Copy settings from CamcorderProfile to MediaRecorder - setAudioEncodingBitRate(audioBitRate); - setAudioChannels(mCamcorderProfile.audioChannels); - setAudioEncoder(mCamcorderProfile.audioCodec); - setAudioSamplingRate(mCamcorderProfile.audioSampleRate); - setVideoEncodingBitRate(videoBitRate); - setVideoEncoder(mCamcorderProfile.videoCodec); - setVideoFrameRate(mCamcorderProfile.videoFrameRate); - setVideoSize( - mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight); - setMaxFileSize(sizeLimit); - } - - Uri getVideoUri() { - return mTempVideoUri; - } - - int getVideoWidth() { - return mCamcorderProfile.videoFrameWidth; - } - - int getVideoHeight() { - return mCamcorderProfile.videoFrameHeight; - } - - void cleanupTempFile() { - final Uri tempUri = mTempVideoUri; - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - Factory.get().getApplicationContext().getContentResolver().delete( - tempUri, null, null); - } - }); - mTempVideoUri = null; - } - - String getContentType() { - if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) { - return ContentType.VIDEO_MP4; - } else { - // 3GPP is the only other video format with a constant in OutputFormat - return ContentType.VIDEO_3GPP; - } - } - - public void closeVideoFileDescriptor() { - if (mVideoFD != null) { - try { - mVideoFD.close(); - } catch (IOException e) { - // Ignore - } - mVideoFD = null; - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java deleted file mode 100644 index 5dc31855..00000000 --- a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java +++ /dev/null @@ -1,114 +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.ui.mediapicker; - -import android.content.Context; -import android.hardware.Camera; -import android.os.Parcelable; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.View; - -import java.io.IOException; - -/** - * A software rendered preview surface for the camera. This renders slower and causes more jank, so - * HardwareCameraPreview is preferred if possible. - * - * There is a significant amount of duplication between HardwareCameraPreview and - * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The - * implementations of the shared methods are delegated to CameraPreview - */ -public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost { - private final CameraPreview mPreview; - - public SoftwareCameraPreview(final Context context) { - super(context); - mPreview = new CameraPreview(this); - getHolder().addCallback(new SurfaceHolder.Callback() { - @Override - public void surfaceCreated(final SurfaceHolder surfaceHolder) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void surfaceChanged(final SurfaceHolder surfaceHolder, final int format, final int width, - final int height) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void surfaceDestroyed(final SurfaceHolder surfaceHolder) { - CameraManager.get().setSurface(null); - } - }); - } - - - @Override - protected void onVisibilityChanged(final View changedView, final int visibility) { - super.onVisibilityChanged(changedView, visibility); - mPreview.onVisibilityChanged(visibility); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mPreview.onDetachedFromWindow(); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mPreview.onAttachedToWindow(); - } - - @Override - protected void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(state); - mPreview.onRestoreInstanceState(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); - heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public View getView() { - return this; - } - - @Override - public boolean isValid() { - return getHolder() != null; - } - - @Override - public void startPreview(final Camera camera) throws IOException { - camera.setPreviewDisplay(getHolder()); - } - - @Override - public void onCameraPermissionGranted() { - mPreview.onCameraPermissionGranted(); - } -} - - diff --git a/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java deleted file mode 100644 index 6f4dca6a..00000000 --- a/src/com/android/messaging/ui/mediapicker/SoundLevels.java +++ /dev/null @@ -1,212 +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.ui.mediapicker; - -import android.animation.ObjectAnimator; -import android.animation.TimeAnimator; -import android.animation.TimeAnimator.TimeListener; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.util.AttributeSet; -import android.util.Log; -import android.view.View; -import android.view.accessibility.AccessibilityNodeInfo; - -import com.android.messaging.R; -import com.android.messaging.util.LogUtil; - -/** - * This view draws circular sound levels. By default the sound levels are black, unless - * otherwise defined via {@link #mPrimaryLevelPaint}. - */ -public class SoundLevels extends View { - private static final String TAG = LogUtil.BUGLE_TAG; - private static final boolean DEBUG = false; - - private boolean mCenterDefined; - private int mCenterX; - private int mCenterY; - - // Paint for the main level meter, most closely follows the mic. - private final Paint mPrimaryLevelPaint; - - // The minimum size of the levels as a percentage of the max, that is the size when volume is 0. - private final float mMinimumLevel; - - // The minimum size of the levels, that is the size when volume is 0. - private final float mMinimumLevelSize; - - // The maximum size of the levels, that is the size when volume is 100. - private final float mMaximumLevelSize; - - // Generates clock ticks for the animation using the global animation loop. - private final TimeAnimator mSpeechLevelsAnimator; - - private float mCurrentVolume; - - // Indicates whether we should be animating the sound level. - private boolean mIsEnabled; - - // Input level is pulled from here. - private AudioLevelSource mLevelSource; - - public SoundLevels(final Context context) { - this(context, null); - } - - public SoundLevels(final Context context, final AttributeSet attrs) { - this(context, attrs, 0); - } - - public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) { - super(context, attrs, defStyle); - - // Safe source, replaced with system one when attached. - mLevelSource = new AudioLevelSource(); - mLevelSource.setSpeechLevel(0); - - final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels, - defStyle, 0); - - mMaximumLevelSize = a.getDimensionPixelOffset( - R.styleable.SoundLevels_maxLevelRadius, 0); - mMinimumLevelSize = a.getDimensionPixelOffset( - R.styleable.SoundLevels_minLevelRadius, 0); - mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize; - - mPrimaryLevelPaint = new Paint(); - mPrimaryLevelPaint.setColor( - a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK)); - mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG); - - a.recycle(); - - // This animator generates ticks that invalidate the - // view so that the animation is synced with the global animation loop. - // TODO: We could probably remove this in favor of using postInvalidateOnAnimation - // which might improve things further. - mSpeechLevelsAnimator = new TimeAnimator(); - mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE); - mSpeechLevelsAnimator.setTimeListener(new TimeListener() { - @Override - public void onTimeUpdate(final TimeAnimator animation, final long totalTime, - final long deltaTime) { - invalidate(); - } - }); - } - - @Override - protected void onDraw(final Canvas canvas) { - if (!mIsEnabled) { - return; - } - - if (!mCenterDefined) { - // One time computation here, because we can't rely on getWidth() to be computed at - // constructor time or in onFinishInflate :(. - mCenterX = getWidth() / 2; - mCenterY = getWidth() / 2; - mCenterDefined = true; - } - - final int level = mLevelSource.getSpeechLevel(); - // Either ease towards the target level, or decay away from it depending on whether - // its higher or lower than the current. - if (level > mCurrentVolume) { - mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4); - } else { - mCurrentVolume = mCurrentVolume * 0.95f; - } - - final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100; - mPrimaryLevelPaint.setStyle(Style.FILL); - canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint); - } - - public void setLevelSource(final AudioLevelSource source) { - if (DEBUG) { - Log.d(TAG, "Speech source set."); - } - mLevelSource = source; - } - - private void startSpeechLevelsAnimator() { - if (DEBUG) { - Log.d(TAG, "startAnimator()"); - } - if (!mSpeechLevelsAnimator.isStarted()) { - mSpeechLevelsAnimator.start(); - } - } - - private void stopSpeechLevelsAnimator() { - if (DEBUG) { - Log.d(TAG, "stopAnimator()"); - } - if (mSpeechLevelsAnimator.isStarted()) { - mSpeechLevelsAnimator.end(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - stopSpeechLevelsAnimator(); - } - - @Override - public void setEnabled(final boolean enabled) { - if (enabled == mIsEnabled) { - return; - } - if (DEBUG) { - Log.d("TAG", "setEnabled: " + enabled); - } - super.setEnabled(enabled); - mIsEnabled = enabled; - setKeepScreenOn(enabled); - updateSpeechLevelsAnimatorState(); - } - - private void updateSpeechLevelsAnimatorState() { - if (mIsEnabled) { - startSpeechLevelsAnimator(); - } else { - stopSpeechLevelsAnimator(); - } - } - - /** - * This is required to make the View findable by uiautomator - */ - @Override - public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) { - super.onInitializeAccessibilityNodeInfo(info); - info.setClassName(SoundLevels.class.getCanonicalName()); - } - - /** - * Set the alpha level of the sound circles. - */ - public void setPrimaryColorAlpha(final int alpha) { - mPrimaryLevelPaint.setAlpha(alpha); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java deleted file mode 100644 index 92ed3c1c..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java +++ /dev/null @@ -1,24 +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.ui.mediapicker.camerafocus; - -public interface FocusIndicator { - public void showStart(); - public void showSuccess(boolean timeout); - public void showFail(boolean timeout); - public void clear(); -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java deleted file mode 100644 index e620fc27..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java +++ /dev/null @@ -1,589 +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.ui.mediapicker.camerafocus; - -import android.graphics.Matrix; -import android.graphics.Rect; -import android.graphics.RectF; -import android.hardware.Camera.Area; -import android.hardware.Camera.Parameters; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; - -import com.android.messaging.util.Assert; -import com.android.messaging.util.LogUtil; - -import java.util.ArrayList; -import java.util.List; - -/* A class that handles everything about focus in still picture mode. - * This also handles the metering area because it is the same as focus area. - * - * The test cases: - * (1) The camera has continuous autofocus. Move the camera. Take a picture when - * CAF is not in progress. - * (2) The camera has continuous autofocus. Move the camera. Take a picture when - * CAF is in progress. - * (3) The camera has face detection. Point the camera at some faces. Hold the - * shutter. Release to take a picture. - * (4) The camera has face detection. Point the camera at some faces. Single tap - * the shutter to take a picture. - * (5) The camera has autofocus. Single tap the shutter to take a picture. - * (6) The camera has autofocus. Hold the shutter. Release to take a picture. - * (7) The camera has no autofocus. Single tap the shutter and take a picture. - * (8) The camera has autofocus and supports focus area. Touch the screen to - * trigger autofocus. Take a picture. - * (9) The camera has autofocus and supports focus area. Touch the screen to - * trigger autofocus. Wait until it times out. - * (10) The camera has no autofocus and supports metering area. Touch the screen - * to change metering area. - */ -public class FocusOverlayManager { - private static final String TAG = LogUtil.BUGLE_TAG; - private static final String TRUE = "true"; - private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported"; - private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED = - "auto-whitebalance-lock-supported"; - - private static final int RESET_TOUCH_FOCUS = 0; - private static final int RESET_TOUCH_FOCUS_DELAY = 3000; - - private int mState = STATE_IDLE; - private static final int STATE_IDLE = 0; // Focus is not active. - private static final int STATE_FOCUSING = 1; // Focus is in progress. - // Focus is in progress and the camera should take a picture after focus finishes. - private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2; - private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds. - private static final int STATE_FAIL = 4; // Focus finishes and fails. - - private boolean mInitialized; - private boolean mFocusAreaSupported; - private boolean mMeteringAreaSupported; - private boolean mLockAeAwbNeeded; - private boolean mAeAwbLock; - private Matrix mMatrix; - - private PieRenderer mPieRenderer; - - private int mPreviewWidth; // The width of the preview frame layout. - private int mPreviewHeight; // The height of the preview frame layout. - private boolean mMirror; // true if the camera is front-facing. - private int mDisplayOrientation; - private List mFocusArea; // focus area in driver format - private List mMeteringArea; // metering area in driver format - private String mFocusMode; - private String mOverrideFocusMode; - private Parameters mParameters; - private Handler mHandler; - Listener mListener; - - public interface Listener { - public void autoFocus(); - public void cancelAutoFocus(); - public boolean capture(); - public void setFocusParameters(); - } - - private class MainHandler extends Handler { - public MainHandler(Looper looper) { - super(looper); - } - - @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case RESET_TOUCH_FOCUS: { - cancelAutoFocus(); - break; - } - } - } - } - - public FocusOverlayManager(Listener listener, Looper looper) { - mHandler = new MainHandler(looper); - mMatrix = new Matrix(); - mListener = listener; - } - - public void setFocusRenderer(PieRenderer renderer) { - mPieRenderer = renderer; - mInitialized = (mMatrix != null); - } - - public void setParameters(Parameters parameters) { - // parameters can only be null when onConfigurationChanged is called - // before camera is open. We will just return in this case, because - // parameters will be set again later with the right parameters after - // camera is open. - if (parameters == null) { - return; - } - mParameters = parameters; - mFocusAreaSupported = isFocusAreaSupported(parameters); - mMeteringAreaSupported = isMeteringAreaSupported(parameters); - mLockAeAwbNeeded = (isAutoExposureLockSupported(mParameters) || - isAutoWhiteBalanceLockSupported(mParameters)); - } - - public void setPreviewSize(int previewWidth, int previewHeight) { - if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) { - mPreviewWidth = previewWidth; - mPreviewHeight = previewHeight; - setMatrix(); - } - } - - public void setMirror(boolean mirror) { - mMirror = mirror; - setMatrix(); - } - - public void setDisplayOrientation(int displayOrientation) { - mDisplayOrientation = displayOrientation; - setMatrix(); - } - - private void setMatrix() { - if (mPreviewWidth != 0 && mPreviewHeight != 0) { - Matrix matrix = new Matrix(); - prepareMatrix(matrix, mMirror, mDisplayOrientation, - mPreviewWidth, mPreviewHeight); - // In face detection, the matrix converts the driver coordinates to UI - // coordinates. In tap focus, the inverted matrix converts the UI - // coordinates to driver coordinates. - matrix.invert(mMatrix); - mInitialized = (mPieRenderer != null); - } - } - - private void lockAeAwbIfNeeded() { - if (mLockAeAwbNeeded && !mAeAwbLock) { - mAeAwbLock = true; - mListener.setFocusParameters(); - } - } - - private void unlockAeAwbIfNeeded() { - if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) { - mAeAwbLock = false; - mListener.setFocusParameters(); - } - } - - public void onShutterDown() { - if (!mInitialized) { - return; - } - - boolean autoFocusCalled = false; - if (needAutoFocusCall()) { - // Do not focus if touch focus has been triggered. - if (mState != STATE_SUCCESS && mState != STATE_FAIL) { - autoFocus(); - autoFocusCalled = true; - } - } - - if (!autoFocusCalled) { - lockAeAwbIfNeeded(); - } - } - - public void onShutterUp() { - if (!mInitialized) { - return; - } - - if (needAutoFocusCall()) { - // User releases half-pressed focus key. - if (mState == STATE_FOCUSING || mState == STATE_SUCCESS - || mState == STATE_FAIL) { - cancelAutoFocus(); - } - } - - // Unlock AE and AWB after cancelAutoFocus. Camera API does not - // guarantee setParameters can be called during autofocus. - unlockAeAwbIfNeeded(); - } - - public void doSnap() { - if (!mInitialized) { - return; - } - - // If the user has half-pressed the shutter and focus is completed, we - // can take the photo right away. If the focus mode is infinity, we can - // also take the photo. - if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) { - capture(); - } else if (mState == STATE_FOCUSING) { - // Half pressing the shutter (i.e. the focus button event) will - // already have requested AF for us, so just request capture on - // focus here. - mState = STATE_FOCUSING_SNAP_ON_FINISH; - } else if (mState == STATE_IDLE) { - // We didn't do focus. This can happen if the user press focus key - // while the snapshot is still in progress. The user probably wants - // the next snapshot as soon as possible, so we just do a snapshot - // without focusing again. - capture(); - } - } - - public void onAutoFocus(boolean focused, boolean shutterButtonPressed) { - if (mState == STATE_FOCUSING_SNAP_ON_FINISH) { - // Take the picture no matter focus succeeds or fails. No need - // to play the AF sound if we're about to play the shutter - // sound. - if (focused) { - mState = STATE_SUCCESS; - } else { - mState = STATE_FAIL; - } - updateFocusUI(); - capture(); - } else if (mState == STATE_FOCUSING) { - // This happens when (1) user is half-pressing the focus key or - // (2) touch focus is triggered. Play the focus tone. Do not - // take the picture now. - if (focused) { - mState = STATE_SUCCESS; - } else { - mState = STATE_FAIL; - } - updateFocusUI(); - // If this is triggered by touch focus, cancel focus after a - // while. - if (mFocusArea != null) { - mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); - } - if (shutterButtonPressed) { - // Lock AE & AWB so users can half-press shutter and recompose. - lockAeAwbIfNeeded(); - } - } else if (mState == STATE_IDLE) { - // User has released the focus key before focus completes. - // Do nothing. - } - } - - public void onAutoFocusMoving(boolean moving) { - if (!mInitialized) { - return; - } - - // Ignore if we have requested autofocus. This method only handles - // continuous autofocus. - if (mState != STATE_IDLE) { - return; - } - - if (moving) { - mPieRenderer.showStart(); - } else { - mPieRenderer.showSuccess(true); - } - } - - private void initializeFocusAreas(int focusWidth, int focusHeight, - int x, int y, int previewWidth, int previewHeight) { - if (mFocusArea == null) { - mFocusArea = new ArrayList(); - mFocusArea.add(new Area(new Rect(), 1)); - } - - // Convert the coordinates to driver format. - calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, - ((Area) mFocusArea.get(0)).rect); - } - - private void initializeMeteringAreas(int focusWidth, int focusHeight, - int x, int y, int previewWidth, int previewHeight) { - if (mMeteringArea == null) { - mMeteringArea = new ArrayList(); - mMeteringArea.add(new Area(new Rect(), 1)); - } - - // Convert the coordinates to driver format. - // AE area is bigger because exposure is sensitive and - // easy to over- or underexposure if area is too small. - calculateTapArea(focusWidth, focusHeight, 1.5f, x, y, previewWidth, previewHeight, - ((Area) mMeteringArea.get(0)).rect); - } - - public void onSingleTapUp(int x, int y) { - if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) { - return; - } - - // Let users be able to cancel previous touch focus. - if ((mFocusArea != null) && (mState == STATE_FOCUSING || - mState == STATE_SUCCESS || mState == STATE_FAIL)) { - cancelAutoFocus(); - } - // Initialize variables. - int focusWidth = mPieRenderer.getSize(); - int focusHeight = mPieRenderer.getSize(); - if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) { - return; - } - int previewWidth = mPreviewWidth; - int previewHeight = mPreviewHeight; - // Initialize mFocusArea. - if (mFocusAreaSupported) { - initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); - } - // Initialize mMeteringArea. - if (mMeteringAreaSupported) { - initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); - } - - // Use margin to set the focus indicator to the touched area. - mPieRenderer.setFocus(x, y); - - // Set the focus area and metering area. - mListener.setFocusParameters(); - if (mFocusAreaSupported) { - autoFocus(); - } else { // Just show the indicator in all other cases. - updateFocusUI(); - // Reset the metering area in 3 seconds. - mHandler.removeMessages(RESET_TOUCH_FOCUS); - mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); - } - } - - public void onPreviewStarted() { - mState = STATE_IDLE; - } - - public void onPreviewStopped() { - // If auto focus was in progress, it would have been stopped. - mState = STATE_IDLE; - resetTouchFocus(); - updateFocusUI(); - } - - public void onCameraReleased() { - onPreviewStopped(); - } - - private void autoFocus() { - LogUtil.v(TAG, "Start autofocus."); - mListener.autoFocus(); - mState = STATE_FOCUSING; - updateFocusUI(); - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - private void cancelAutoFocus() { - LogUtil.v(TAG, "Cancel autofocus."); - - // Reset the tap area before calling mListener.cancelAutofocus. - // Otherwise, focus mode stays at auto and the tap area passed to the - // driver is not reset. - resetTouchFocus(); - mListener.cancelAutoFocus(); - mState = STATE_IDLE; - updateFocusUI(); - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - private void capture() { - if (mListener.capture()) { - mState = STATE_IDLE; - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - } - - public String getFocusMode() { - if (mOverrideFocusMode != null) { - return mOverrideFocusMode; - } - List supportedFocusModes = mParameters.getSupportedFocusModes(); - - if (mFocusAreaSupported && mFocusArea != null) { - // Always use autofocus in tap-to-focus. - mFocusMode = Parameters.FOCUS_MODE_AUTO; - } else { - mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; - } - - if (!isSupported(mFocusMode, supportedFocusModes)) { - // For some reasons, the driver does not support the current - // focus mode. Fall back to auto. - if (isSupported(Parameters.FOCUS_MODE_AUTO, - mParameters.getSupportedFocusModes())) { - mFocusMode = Parameters.FOCUS_MODE_AUTO; - } else { - mFocusMode = mParameters.getFocusMode(); - } - } - return mFocusMode; - } - - public List getFocusAreas() { - return mFocusArea; - } - - public List getMeteringAreas() { - return mMeteringArea; - } - - public void updateFocusUI() { - if (!mInitialized) { - return; - } - FocusIndicator focusIndicator = mPieRenderer; - - if (mState == STATE_IDLE) { - if (mFocusArea == null) { - focusIndicator.clear(); - } else { - // Users touch on the preview and the indicator represents the - // metering area. Either focus area is not supported or - // autoFocus call is not required. - focusIndicator.showStart(); - } - } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) { - focusIndicator.showStart(); - } else { - if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) { - // TODO: check HAL behavior and decide if this can be removed. - focusIndicator.showSuccess(false); - } else if (mState == STATE_SUCCESS) { - focusIndicator.showSuccess(false); - } else if (mState == STATE_FAIL) { - focusIndicator.showFail(false); - } - } - } - - public void resetTouchFocus() { - if (!mInitialized) { - return; - } - - // Put focus indicator to the center. clear reset position - mPieRenderer.clear(); - - mFocusArea = null; - mMeteringArea = null; - } - - private void calculateTapArea(int focusWidth, int focusHeight, float areaMultiple, - int x, int y, int previewWidth, int previewHeight, Rect rect) { - int areaWidth = (int) (focusWidth * areaMultiple); - int areaHeight = (int) (focusHeight * areaMultiple); - int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth); - int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight); - - RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); - mMatrix.mapRect(rectF); - rectFToRect(rectF, rect); - } - - /* package */ int getFocusState() { - return mState; - } - - public boolean isFocusCompleted() { - return mState == STATE_SUCCESS || mState == STATE_FAIL; - } - - public boolean isFocusingSnapOnFinish() { - return mState == STATE_FOCUSING_SNAP_ON_FINISH; - } - - public void removeMessages() { - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - public void overrideFocusMode(String focusMode) { - mOverrideFocusMode = focusMode; - } - - public void setAeAwbLock(boolean lock) { - mAeAwbLock = lock; - } - - public boolean getAeAwbLock() { - return mAeAwbLock; - } - - private boolean needAutoFocusCall() { - String focusMode = getFocusMode(); - return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY) - || focusMode.equals(Parameters.FOCUS_MODE_FIXED) - || focusMode.equals(Parameters.FOCUS_MODE_EDOF)); - } - - public static boolean isAutoExposureLockSupported(Parameters params) { - return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); - } - - public static boolean isAutoWhiteBalanceLockSupported(Parameters params) { - return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); - } - - public static boolean isSupported(String value, List supported) { - return supported != null && supported.indexOf(value) >= 0; - } - - public static boolean isMeteringAreaSupported(Parameters params) { - return params.getMaxNumMeteringAreas() > 0; - } - - public static boolean isFocusAreaSupported(Parameters params) { - return (params.getMaxNumFocusAreas() > 0 - && isSupported(Parameters.FOCUS_MODE_AUTO, - params.getSupportedFocusModes())); - } - - public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation, - int viewWidth, int viewHeight) { - // Need mirror for front camera. - matrix.setScale(mirror ? -1 : 1, 1); - // This is the value for android.hardware.Camera.setDisplayOrientation. - matrix.postRotate(displayOrientation); - // Camera driver coordinates range from (-1000, -1000) to (1000, 1000). - // UI coordinates range from (0, 0) to (width, height). - matrix.postScale(viewWidth / 2000f, viewHeight / 2000f); - matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); - } - - public static int clamp(int x, int min, int max) { - Assert.isTrue(max >= min); - if (x > max) { - return max; - } - if (x < min) { - return min; - } - return x; - } - - public static void rectFToRect(RectF rectF, Rect rect) { - rect.left = Math.round(rectF.left); - rect.top = Math.round(rectF.top); - rect.right = Math.round(rectF.right); - rect.bottom = Math.round(rectF.bottom); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java deleted file mode 100644 index df6734f6..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java +++ /dev/null @@ -1,95 +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.ui.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.view.MotionEvent; - -public abstract class OverlayRenderer implements RenderOverlay.Renderer { - - private static final String TAG = "CAM OverlayRenderer"; - protected RenderOverlay mOverlay; - - protected int mLeft, mTop, mRight, mBottom; - - protected boolean mVisible; - - public void setVisible(boolean vis) { - mVisible = vis; - update(); - } - - public boolean isVisible() { - return mVisible; - } - - // default does not handle touch - @Override - public boolean handlesTouch() { - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - return false; - } - - public abstract void onDraw(Canvas canvas); - - public void draw(Canvas canvas) { - if (mVisible) { - onDraw(canvas); - } - } - - @Override - public void setOverlay(RenderOverlay overlay) { - mOverlay = overlay; - } - - @Override - public void layout(int left, int top, int right, int bottom) { - mLeft = left; - mRight = right; - mTop = top; - mBottom = bottom; - } - - protected Context getContext() { - if (mOverlay != null) { - return mOverlay.getContext(); - } else { - return null; - } - } - - public int getWidth() { - return mRight - mLeft; - } - - public int getHeight() { - return mBottom - mTop; - } - - protected void update() { - if (mOverlay != null) { - mOverlay.update(); - } - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java deleted file mode 100644 index c6028521..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java +++ /dev/null @@ -1,202 +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.ui.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Path; -import android.graphics.drawable.Drawable; - -import java.util.ArrayList; -import java.util.List; - -/** - * Pie menu item - */ -public class PieItem { - - public static interface OnClickListener { - void onClick(PieItem item); - } - - private Drawable mDrawable; - private int level; - private float mCenter; - private float start; - private float sweep; - private float animate; - private int inner; - private int outer; - private boolean mSelected; - private boolean mEnabled; - private List mItems; - private Path mPath; - private OnClickListener mOnClickListener; - private float mAlpha; - - // Gray out the view when disabled - private static final float ENABLED_ALPHA = 1; - private static final float DISABLED_ALPHA = (float) 0.3; - private boolean mChangeAlphaWhenDisabled = true; - - public PieItem(Drawable drawable, int level) { - mDrawable = drawable; - this.level = level; - setAlpha(1f); - mEnabled = true; - setAnimationAngle(getAnimationAngle()); - start = -1; - mCenter = -1; - } - - public boolean hasItems() { - return mItems != null; - } - - public List getItems() { - return mItems; - } - - public void addItem(PieItem item) { - if (mItems == null) { - mItems = new ArrayList(); - } - mItems.add(item); - } - - public void setPath(Path p) { - mPath = p; - } - - public Path getPath() { - return mPath; - } - - public void setChangeAlphaWhenDisabled (boolean enable) { - mChangeAlphaWhenDisabled = enable; - } - - public void setAlpha(float alpha) { - mAlpha = alpha; - mDrawable.setAlpha((int) (255 * alpha)); - } - - public void setAnimationAngle(float a) { - animate = a; - } - - public float getAnimationAngle() { - return animate; - } - - public void setEnabled(boolean enabled) { - mEnabled = enabled; - if (mChangeAlphaWhenDisabled) { - if (mEnabled) { - setAlpha(ENABLED_ALPHA); - } else { - setAlpha(DISABLED_ALPHA); - } - } - } - - public boolean isEnabled() { - return mEnabled; - } - - public void setSelected(boolean s) { - mSelected = s; - } - - public boolean isSelected() { - return mSelected; - } - - public int getLevel() { - return level; - } - - public void setGeometry(float st, float sw, int inside, int outside) { - start = st; - sweep = sw; - inner = inside; - outer = outside; - } - - public void setFixedSlice(float center, float sweep) { - mCenter = center; - this.sweep = sweep; - } - - public float getCenter() { - return mCenter; - } - - public float getStart() { - return start; - } - - public float getStartAngle() { - return start + animate; - } - - public float getSweep() { - return sweep; - } - - public int getInnerRadius() { - return inner; - } - - public int getOuterRadius() { - return outer; - } - - public void setOnClickListener(OnClickListener listener) { - mOnClickListener = listener; - } - - public void performClick() { - if (mOnClickListener != null) { - mOnClickListener.onClick(this); - } - } - - public int getIntrinsicWidth() { - return mDrawable.getIntrinsicWidth(); - } - - public int getIntrinsicHeight() { - return mDrawable.getIntrinsicHeight(); - } - - public void setBounds(int left, int top, int right, int bottom) { - mDrawable.setBounds(left, top, right, bottom); - } - - public void draw(Canvas canvas) { - mDrawable.draw(canvas); - } - - public void setImageResource(Context context, int resId) { - Drawable d = context.getResources().getDrawable(resId).mutate(); - d.setBounds(mDrawable.getBounds()); - mDrawable = d; - setAlpha(mAlpha); - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java deleted file mode 100644 index ce8ca007..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java +++ /dev/null @@ -1,825 +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.ui.mediapicker.camerafocus; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.Point; -import android.graphics.PointF; -import android.graphics.RectF; -import android.os.Handler; -import android.os.Message; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import android.view.animation.Animation; -import android.view.animation.Animation.AnimationListener; -import android.view.animation.LinearInterpolator; -import android.view.animation.Transformation; -import com.android.messaging.R; - -import java.util.ArrayList; -import java.util.List; - -public class PieRenderer extends OverlayRenderer - implements FocusIndicator { - // Sometimes continuous autofocus starts and stops several times quickly. - // These states are used to make sure the animation is run for at least some - // time. - private volatile int mState; - private ScaleAnimation mAnimation = new ScaleAnimation(); - private static final int STATE_IDLE = 0; - private static final int STATE_FOCUSING = 1; - private static final int STATE_FINISHING = 2; - private static final int STATE_PIE = 8; - - private Runnable mDisappear = new Disappear(); - private Animation.AnimationListener mEndAction = new EndAction(); - private static final int SCALING_UP_TIME = 600; - private static final int SCALING_DOWN_TIME = 100; - private static final int DISAPPEAR_TIMEOUT = 200; - private static final int DIAL_HORIZONTAL = 157; - - private static final long PIE_FADE_IN_DURATION = 200; - private static final long PIE_XFADE_DURATION = 200; - private static final long PIE_SELECT_FADE_DURATION = 300; - - private static final int MSG_OPEN = 0; - private static final int MSG_CLOSE = 1; - private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3); - // geometry - private Point mCenter; - private int mRadius; - private int mRadiusInc; - - // the detection if touch is inside a slice is offset - // inbounds by this amount to allow the selection to show before the - // finger covers it - private int mTouchOffset; - - private List mItems; - - private PieItem mOpenItem; - - private Paint mSelectedPaint; - private Paint mSubPaint; - - // touch handling - private PieItem mCurrentItem; - - private Paint mFocusPaint; - private int mSuccessColor; - private int mFailColor; - private int mCircleSize; - private int mFocusX; - private int mFocusY; - private int mCenterX; - private int mCenterY; - - private int mDialAngle; - private RectF mCircle; - private RectF mDial; - private Point mPoint1; - private Point mPoint2; - private int mStartAnimationAngle; - private boolean mFocused; - private int mInnerOffset; - private int mOuterStroke; - private int mInnerStroke; - private boolean mTapMode; - private boolean mBlockFocus; - private int mTouchSlopSquared; - private Point mDown; - private boolean mOpening; - private LinearAnimation mXFade; - private LinearAnimation mFadeIn; - private volatile boolean mFocusCancelled; - - private Handler mHandler = new Handler() { - public void handleMessage(Message msg) { - switch(msg.what) { - case MSG_OPEN: - if (mListener != null) { - mListener.onPieOpened(mCenter.x, mCenter.y); - } - break; - case MSG_CLOSE: - if (mListener != null) { - mListener.onPieClosed(); - } - break; - } - } - }; - - private PieListener mListener; - - public static interface PieListener { - public void onPieOpened(int centerX, int centerY); - public void onPieClosed(); - } - - public void setPieListener(PieListener pl) { - mListener = pl; - } - - public PieRenderer(Context context) { - init(context); - } - - private void init(Context ctx) { - setVisible(false); - mItems = new ArrayList(); - Resources res = ctx.getResources(); - mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start); - mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); - mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment); - mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset); - mCenter = new Point(0, 0); - mSelectedPaint = new Paint(); - mSelectedPaint.setColor(Color.argb(255, 51, 181, 229)); - mSelectedPaint.setAntiAlias(true); - mSubPaint = new Paint(); - mSubPaint.setAntiAlias(true); - mSubPaint.setColor(Color.argb(200, 250, 230, 128)); - mFocusPaint = new Paint(); - mFocusPaint.setAntiAlias(true); - mFocusPaint.setColor(Color.WHITE); - mFocusPaint.setStyle(Paint.Style.STROKE); - mSuccessColor = Color.GREEN; - mFailColor = Color.RED; - mCircle = new RectF(); - mDial = new RectF(); - mPoint1 = new Point(); - mPoint2 = new Point(); - mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset); - mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke); - mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke); - mState = STATE_IDLE; - mBlockFocus = false; - mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop(); - mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared; - mDown = new Point(); - } - - public boolean showsItems() { - return mTapMode; - } - - public void addItem(PieItem item) { - // add the item to the pie itself - mItems.add(item); - } - - public void removeItem(PieItem item) { - mItems.remove(item); - } - - public void clearItems() { - mItems.clear(); - } - - public void showInCenter() { - if ((mState == STATE_PIE) && isVisible()) { - mTapMode = false; - show(false); - } else { - if (mState != STATE_IDLE) { - cancelFocus(); - } - mState = STATE_PIE; - setCenter(mCenterX, mCenterY); - mTapMode = true; - show(true); - } - } - - public void hide() { - show(false); - } - - /** - * guaranteed has center set - * @param show - */ - private void show(boolean show) { - if (show) { - mState = STATE_PIE; - // ensure clean state - mCurrentItem = null; - mOpenItem = null; - for (PieItem item : mItems) { - item.setSelected(false); - } - layoutPie(); - fadeIn(); - } else { - mState = STATE_IDLE; - mTapMode = false; - if (mXFade != null) { - mXFade.cancel(); - } - } - setVisible(show); - mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE); - } - - private void fadeIn() { - mFadeIn = new LinearAnimation(0, 1); - mFadeIn.setDuration(PIE_FADE_IN_DURATION); - mFadeIn.setAnimationListener(new AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - mFadeIn = null; - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }); - mFadeIn.startNow(); - mOverlay.startAnimation(mFadeIn); - } - - public void setCenter(int x, int y) { - mCenter.x = x; - mCenter.y = y; - // when using the pie menu, align the focus ring - alignFocus(x, y); - } - - private void layoutPie() { - int rgap = 2; - int inner = mRadius + rgap; - int outer = mRadius + mRadiusInc - rgap; - int gap = 1; - layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap); - } - - private void layoutItems(List items, float centerAngle, int inner, - int outer, int gap) { - float emptyangle = PIE_SWEEP / 16; - float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size(); - float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2; - // check if we have custom geometry - // first item we find triggers custom sweep for all - // this allows us to re-use the path - for (PieItem item : items) { - if (item.getCenter() >= 0) { - sweep = item.getSweep(); - break; - } - } - Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, - outer, inner, mCenter); - for (PieItem item : items) { - // shared between items - item.setPath(path); - if (item.getCenter() >= 0) { - angle = item.getCenter(); - } - int w = item.getIntrinsicWidth(); - int h = item.getIntrinsicHeight(); - // move views to outer border - int r = inner + (outer - inner) * 2 / 3; - int x = (int) (r * Math.cos(angle)); - int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2; - x = mCenter.x + x - w / 2; - item.setBounds(x, y, x + w, y + h); - float itemstart = angle - sweep / 2; - item.setGeometry(itemstart, sweep, inner, outer); - if (item.hasItems()) { - layoutItems(item.getItems(), angle, inner, - outer + mRadiusInc / 2, gap); - } - angle += sweep; - } - } - - private Path makeSlice(float start, float end, int outer, int inner, Point center) { - RectF bb = - new RectF(center.x - outer, center.y - outer, center.x + outer, - center.y + outer); - RectF bbi = - new RectF(center.x - inner, center.y - inner, center.x + inner, - center.y + inner); - Path path = new Path(); - path.arcTo(bb, start, end - start, true); - path.arcTo(bbi, end, start - end); - path.close(); - return path; - } - - /** - * converts a - * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock) - * @return skia angle - */ - private float getDegrees(double angle) { - return (float) (360 - 180 * angle / Math.PI); - } - - private void startFadeOut() { - mOverlay.animate().alpha(0).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - deselect(); - show(false); - mOverlay.setAlpha(1); - super.onAnimationEnd(animation); - } - }).setDuration(PIE_SELECT_FADE_DURATION); - } - - @Override - public void onDraw(Canvas canvas) { - float alpha = 1; - if (mXFade != null) { - alpha = mXFade.getValue(); - } else if (mFadeIn != null) { - alpha = mFadeIn.getValue(); - } - int state = canvas.save(); - if (mFadeIn != null) { - float sf = 0.9f + alpha * 0.1f; - canvas.scale(sf, sf, mCenter.x, mCenter.y); - } - drawFocus(canvas); - if (mState == STATE_FINISHING) { - canvas.restoreToCount(state); - return; - } - if ((mOpenItem == null) || (mXFade != null)) { - // draw base menu - for (PieItem item : mItems) { - drawItem(canvas, item, alpha); - } - } - if (mOpenItem != null) { - for (PieItem inner : mOpenItem.getItems()) { - drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1); - } - } - canvas.restoreToCount(state); - } - - private void drawItem(Canvas canvas, PieItem item, float alpha) { - if (mState == STATE_PIE) { - if (item.getPath() != null) { - if (item.isSelected()) { - Paint p = mSelectedPaint; - int state = canvas.save(); - float r = getDegrees(item.getStartAngle()); - canvas.rotate(r, mCenter.x, mCenter.y); - canvas.drawPath(item.getPath(), p); - canvas.restoreToCount(state); - } - alpha = alpha * (item.isEnabled() ? 1 : 0.3f); - // draw the item view - item.setAlpha(alpha); - item.draw(canvas); - } - } - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - float x = evt.getX(); - float y = evt.getY(); - int action = evt.getActionMasked(); - PointF polar = getPolar(x, y, !(mTapMode)); - if (MotionEvent.ACTION_DOWN == action) { - mDown.x = (int) evt.getX(); - mDown.y = (int) evt.getY(); - mOpening = false; - if (mTapMode) { - PieItem item = findItem(polar); - if ((item != null) && (mCurrentItem != item)) { - mState = STATE_PIE; - onEnter(item); - } - } else { - setCenter((int) x, (int) y); - show(true); - } - return true; - } else if (MotionEvent.ACTION_UP == action) { - if (isVisible()) { - PieItem item = mCurrentItem; - if (mTapMode) { - item = findItem(polar); - if (item != null && mOpening) { - mOpening = false; - return true; - } - } - if (item == null) { - mTapMode = false; - show(false); - } else if (!mOpening - && !item.hasItems()) { - item.performClick(); - startFadeOut(); - mTapMode = false; - } - return true; - } - } else if (MotionEvent.ACTION_CANCEL == action) { - if (isVisible() || mTapMode) { - show(false); - } - deselect(); - return false; - } else if (MotionEvent.ACTION_MOVE == action) { - if (polar.y < mRadius) { - if (mOpenItem != null) { - mOpenItem = null; - } else { - deselect(); - } - return false; - } - PieItem item = findItem(polar); - boolean moved = hasMoved(evt); - if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) { - // only select if we didn't just open or have moved past slop - mOpening = false; - if (moved) { - // switch back to swipe mode - mTapMode = false; - } - onEnter(item); - } - } - return false; - } - - private boolean hasMoved(MotionEvent e) { - return mTouchSlopSquared < (e.getX() - mDown.x) * (e.getX() - mDown.x) - + (e.getY() - mDown.y) * (e.getY() - mDown.y); - } - - /** - * enter a slice for a view - * updates model only - * @param item - */ - private void onEnter(PieItem item) { - if (mCurrentItem != null) { - mCurrentItem.setSelected(false); - } - if (item != null && item.isEnabled()) { - item.setSelected(true); - mCurrentItem = item; - if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) { - openCurrentItem(); - } - } else { - mCurrentItem = null; - } - } - - private void deselect() { - if (mCurrentItem != null) { - mCurrentItem.setSelected(false); - } - if (mOpenItem != null) { - mOpenItem = null; - } - mCurrentItem = null; - } - - private void openCurrentItem() { - if ((mCurrentItem != null) && mCurrentItem.hasItems()) { - mCurrentItem.setSelected(false); - mOpenItem = mCurrentItem; - mOpening = true; - mXFade = new LinearAnimation(1, 0); - mXFade.setDuration(PIE_XFADE_DURATION); - mXFade.setAnimationListener(new AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - mXFade = null; - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }); - mXFade.startNow(); - mOverlay.startAnimation(mXFade); - } - } - - private PointF getPolar(float x, float y, boolean useOffset) { - PointF res = new PointF(); - // get angle and radius from x/y - res.x = (float) Math.PI / 2; - x = x - mCenter.x; - y = mCenter.y - y; - res.y = (float) Math.sqrt(x * x + y * y); - if (x != 0) { - res.x = (float) Math.atan2(y, x); - if (res.x < 0) { - res.x = (float) (2 * Math.PI + res.x); - } - } - res.y = res.y + (useOffset ? mTouchOffset : 0); - return res; - } - - /** - * @param polar x: angle, y: dist - * @return the item at angle/dist or null - */ - private PieItem findItem(PointF polar) { - // find the matching item: - List items = (mOpenItem != null) ? mOpenItem.getItems() : mItems; - for (PieItem item : items) { - if (inside(polar, item)) { - return item; - } - } - return null; - } - - private boolean inside(PointF polar, PieItem item) { - return (item.getInnerRadius() < polar.y) - && (item.getStartAngle() < polar.x) - && (item.getStartAngle() + item.getSweep() > polar.x) - && (!mTapMode || (item.getOuterRadius() > polar.y)); - } - - @Override - public boolean handlesTouch() { - return true; - } - - // focus specific code - - public void setBlockFocus(boolean blocked) { - mBlockFocus = blocked; - if (blocked) { - clear(); - } - } - - public void setFocus(int x, int y) { - mFocusX = x; - mFocusY = y; - setCircle(mFocusX, mFocusY); - } - - public void alignFocus(int x, int y) { - mOverlay.removeCallbacks(mDisappear); - mAnimation.cancel(); - mAnimation.reset(); - mFocusX = x; - mFocusY = y; - mDialAngle = DIAL_HORIZONTAL; - setCircle(x, y); - mFocused = false; - } - - public int getSize() { - return 2 * mCircleSize; - } - - private int getRandomRange() { - return (int) (-60 + 120 * Math.random()); - } - - @Override - public void layout(int l, int t, int r, int b) { - super.layout(l, t, r, b); - mCenterX = (r - l) / 2; - mCenterY = (b - t) / 2; - mFocusX = mCenterX; - mFocusY = mCenterY; - setCircle(mFocusX, mFocusY); - if (isVisible() && mState == STATE_PIE) { - setCenter(mCenterX, mCenterY); - layoutPie(); - } - } - - private void setCircle(int cx, int cy) { - mCircle.set(cx - mCircleSize, cy - mCircleSize, - cx + mCircleSize, cy + mCircleSize); - mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset, - cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset); - } - - public void drawFocus(Canvas canvas) { - if (mBlockFocus) { - return; - } - mFocusPaint.setStrokeWidth(mOuterStroke); - canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint); - if (mState == STATE_PIE) { - return; - } - int color = mFocusPaint.getColor(); - if (mState == STATE_FINISHING) { - mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor); - } - mFocusPaint.setStrokeWidth(mInnerStroke); - drawLine(canvas, mDialAngle, mFocusPaint); - drawLine(canvas, mDialAngle + 45, mFocusPaint); - drawLine(canvas, mDialAngle + 180, mFocusPaint); - drawLine(canvas, mDialAngle + 225, mFocusPaint); - canvas.save(); - // rotate the arc instead of its offset to better use framework's shape caching - canvas.rotate(mDialAngle, mFocusX, mFocusY); - canvas.drawArc(mDial, 0, 45, false, mFocusPaint); - canvas.drawArc(mDial, 180, 45, false, mFocusPaint); - canvas.restore(); - mFocusPaint.setColor(color); - } - - private void drawLine(Canvas canvas, int angle, Paint p) { - convertCart(angle, mCircleSize - mInnerOffset, mPoint1); - convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2); - canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY, - mPoint2.x + mFocusX, mPoint2.y + mFocusY, p); - } - - private static void convertCart(int angle, int radius, Point out) { - double a = 2 * Math.PI * (angle % 360) / 360; - out.x = (int) (radius * Math.cos(a) + 0.5); - out.y = (int) (radius * Math.sin(a) + 0.5); - } - - @Override - public void showStart() { - if (mState == STATE_PIE) { - return; - } - cancelFocus(); - mStartAnimationAngle = 67; - int range = getRandomRange(); - startAnimation(SCALING_UP_TIME, - false, mStartAnimationAngle, mStartAnimationAngle + range); - mState = STATE_FOCUSING; - } - - @Override - public void showSuccess(boolean timeout) { - if (mState == STATE_FOCUSING) { - startAnimation(SCALING_DOWN_TIME, - timeout, mStartAnimationAngle); - mState = STATE_FINISHING; - mFocused = true; - } - } - - @Override - public void showFail(boolean timeout) { - if (mState == STATE_FOCUSING) { - startAnimation(SCALING_DOWN_TIME, - timeout, mStartAnimationAngle); - mState = STATE_FINISHING; - mFocused = false; - } - } - - private void cancelFocus() { - mFocusCancelled = true; - mOverlay.removeCallbacks(mDisappear); - if (mAnimation != null) { - mAnimation.cancel(); - } - mFocusCancelled = false; - mFocused = false; - mState = STATE_IDLE; - } - - @Override - public void clear() { - if (mState == STATE_PIE) { - return; - } - cancelFocus(); - mOverlay.post(mDisappear); - } - - private void startAnimation(long duration, boolean timeout, - float toScale) { - startAnimation(duration, timeout, mDialAngle, - toScale); - } - - private void startAnimation(long duration, boolean timeout, - float fromScale, float toScale) { - setVisible(true); - mAnimation.reset(); - mAnimation.setDuration(duration); - mAnimation.setScale(fromScale, toScale); - mAnimation.setAnimationListener(timeout ? mEndAction : null); - mOverlay.startAnimation(mAnimation); - update(); - } - - private class EndAction implements Animation.AnimationListener { - @Override - public void onAnimationEnd(Animation animation) { - // Keep the focus indicator for some time. - if (!mFocusCancelled) { - mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT); - } - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - @Override - public void onAnimationStart(Animation animation) { - } - } - - private class Disappear implements Runnable { - @Override - public void run() { - if (mState == STATE_PIE) { - return; - } - setVisible(false); - mFocusX = mCenterX; - mFocusY = mCenterY; - mState = STATE_IDLE; - setCircle(mFocusX, mFocusY); - mFocused = false; - } - } - - private class ScaleAnimation extends Animation { - private float mFrom = 1f; - private float mTo = 1f; - - public ScaleAnimation() { - setFillAfter(true); - } - - public void setScale(float from, float to) { - mFrom = from; - mTo = to; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime); - } - } - - - private class LinearAnimation extends Animation { - private float mFrom; - private float mTo; - private float mValue; - - public LinearAnimation(float from, float to) { - setFillAfter(true); - setInterpolator(new LinearInterpolator()); - mFrom = from; - mTo = to; - } - - public float getValue() { - return mValue; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - mValue = (mFrom + (mTo - mFrom) * interpolatedTime); - } - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt deleted file mode 100644 index ed4e783b..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt +++ /dev/null @@ -1,3 +0,0 @@ -The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders -com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications -have been made to remove unneeded features and adjust to our needs. \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java deleted file mode 100644 index 95cddc4e..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java +++ /dev/null @@ -1,178 +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.ui.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; - -import java.util.ArrayList; -import java.util.List; - -public class RenderOverlay extends FrameLayout { - - interface Renderer { - - public boolean handlesTouch(); - public boolean onTouchEvent(MotionEvent evt); - public void setOverlay(RenderOverlay overlay); - public void layout(int left, int top, int right, int bottom); - public void draw(Canvas canvas); - - } - - private RenderView mRenderView; - private List mClients; - - // reverse list of touch clients - private List mTouchClients; - private int[] mPosition = new int[2]; - - public RenderOverlay(Context context, AttributeSet attrs) { - super(context, attrs); - mRenderView = new RenderView(context); - addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT)); - mClients = new ArrayList(10); - mTouchClients = new ArrayList(10); - setWillNotDraw(false); - - addRenderer(new PieRenderer(context)); - } - - public PieRenderer getPieRenderer() { - for (Renderer renderer : mClients) { - if (renderer instanceof PieRenderer) { - return (PieRenderer) renderer; - } - } - return null; - } - - public void addRenderer(Renderer renderer) { - mClients.add(renderer); - renderer.setOverlay(this); - if (renderer.handlesTouch()) { - mTouchClients.add(0, renderer); - } - renderer.layout(getLeft(), getTop(), getRight(), getBottom()); - } - - public void addRenderer(int pos, Renderer renderer) { - mClients.add(pos, renderer); - renderer.setOverlay(this); - renderer.layout(getLeft(), getTop(), getRight(), getBottom()); - } - - public void remove(Renderer renderer) { - mClients.remove(renderer); - renderer.setOverlay(null); - } - - public int getClientSize() { - return mClients.size(); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent m) { - return false; - } - - public boolean directDispatchTouch(MotionEvent m, Renderer target) { - mRenderView.setTouchTarget(target); - boolean res = super.dispatchTouchEvent(m); - mRenderView.setTouchTarget(null); - return res; - } - - private void adjustPosition() { - getLocationInWindow(mPosition); - } - - public int getWindowPositionX() { - return mPosition[0]; - } - - public int getWindowPositionY() { - return mPosition[1]; - } - - public void update() { - mRenderView.invalidate(); - } - - private class RenderView extends View { - - private Renderer mTouchTarget; - - public RenderView(Context context) { - super(context); - setWillNotDraw(false); - } - - public void setTouchTarget(Renderer target) { - mTouchTarget = target; - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - if (mTouchTarget != null) { - return mTouchTarget.onTouchEvent(evt); - } - if (mTouchClients != null) { - boolean res = false; - for (Renderer client : mTouchClients) { - res |= client.onTouchEvent(evt); - } - return res; - } - return false; - } - - @Override - public void onLayout(boolean changed, int left, int top, int right, int bottom) { - adjustPosition(); - super.onLayout(changed, left, top, right, bottom); - if (mClients == null) { - return; - } - for (Renderer renderer : mClients) { - renderer.layout(left, top, right, bottom); - } - } - - @Override - public void draw(Canvas canvas) { - super.draw(canvas); - if (mClients == null) { - return; - } - boolean redraw = false; - for (Renderer renderer : mClients) { - renderer.draw(canvas); - redraw = redraw || ((OverlayRenderer) renderer).isVisible(); - } - if (redraw) { - invalidate(); - } - } - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java index c5587dca..6f8f73d4 100644 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java @@ -37,7 +37,7 @@ import com.android.messaging.R; import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery; import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.ui.AttachmentSaveTask; import com.android.messaging.util.Dates; import com.android.messaging.util.LogUtil; @@ -148,7 +148,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { return true; } final String photoUri = adapter.getPhotoUri(cursor); - new ConversationFragment.SaveAttachmentTask(((Activity) getActivity()), + new AttachmentSaveTask(((Activity) getActivity()), Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool(); return true; } else if (itemId == R.id.action_share) { diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java index 7212c42c..20614340 100644 --- a/src/com/android/messaging/util/DebugUtils.java +++ b/src/com/android/messaging/util/DebugUtils.java @@ -39,6 +39,8 @@ import com.android.messaging.datamodel.SyncManager; import com.android.messaging.datamodel.action.DumpDatabaseAction; import com.android.messaging.datamodel.action.LogTelephonyDatabaseAction; +import com.android.messaging.debug.DebugSimEmulationMode; +import com.android.messaging.debug.DebugSimEmulationStore; import com.android.messaging.debug.TestDataSeeder; import com.android.messaging.sms.MmsUtils; import com.android.messaging.ui.UIIntents; @@ -217,6 +219,13 @@ public void run() { } }); + arrayAdapter.add(new DebugAction("SIM emulation mode...") { + @Override + public void run() { + showSimEmulationModeDialog(host); + } + }); + builder.setAdapter(arrayAdapter, new android.content.DialogInterface.OnClickListener() { @Override @@ -228,6 +237,45 @@ public void onClick(final DialogInterface arg0, final int pos) { builder.create().show(); } + private static void showSimEmulationModeDialog(final AppCompatActivity host) { + final DebugSimEmulationMode[] modes = DebugSimEmulationMode.values(); + final String[] labels = new String[modes.length]; + int checkedIndex = 0; + final DebugSimEmulationMode currentMode = DebugSimEmulationStore.getCurrentMode(); + for (int i = 0; i < modes.length; i++) { + labels[i] = describeSimEmulationMode(modes[i]); + if (modes[i] == currentMode) { + checkedIndex = i; + } + } + final int[] selectedIndex = new int[] { checkedIndex }; + new AlertDialog.Builder(host) + .setTitle("SIM emulation mode") + .setSingleChoiceItems(labels, checkedIndex, + (dialog, which) -> selectedIndex[0] = which) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + final DebugSimEmulationMode newMode = modes[selectedIndex[0]]; + DebugSimEmulationStore.setMode(newMode); + Toast.makeText(host, + "SIM emulation: " + labels[selectedIndex[0]], + Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private static String describeSimEmulationMode(final DebugSimEmulationMode mode) { + switch (mode) { + case SINGLE: + return "Single SIM (emulate 1 if none present)"; + case DUAL: + return "Dual SIM (emulate up to 2 if fewer present)"; + case DEFAULT: + default: + return "Default (use real SIMs)"; + } + } + /** * Task to list all the dump files and perform an action on it */ diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java index 70ca7b66..bbd9dc2b 100644 --- a/src/com/android/messaging/util/ImageUtils.java +++ b/src/com/android/messaging/util/ImageUtils.java @@ -43,6 +43,7 @@ import com.android.messaging.util.exif.ExifInterface; import com.google.common.annotations.VisibleForTesting; +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; @@ -268,8 +269,11 @@ public static int getOrientation(final InputStream inputStream) { return orientation; } final ExifInterface exifInterface = new ExifInterface(); - try (inputStream) { - exifInterface.readExif(inputStream); + try (final InputStream bufferedInputStream = wrapForExifHeaderProbe(inputStream)) { + if (!isJpegStream(bufferedInputStream)) { + return orientation; + } + exifInterface.readExif(bufferedInputStream); } catch (IOException e) { LogUtil.e(TAG, "getOrientation", e); } @@ -281,6 +285,21 @@ public static int getOrientation(final InputStream inputStream) { return orientation; } + private static InputStream wrapForExifHeaderProbe(final InputStream inputStream) { + if (inputStream.markSupported()) { + return inputStream; + } + return new BufferedInputStream(inputStream); + } + + private static boolean isJpegStream(final InputStream inputStream) throws IOException { + inputStream.mark(2); + final int firstByte = inputStream.read(); + final int secondByte = inputStream.read(); + inputStream.reset(); + return firstByte == 0xFF && secondByte == 0xD8; + } + /** * Returns whether the resource is a GIF image. */ diff --git a/src/com/android/messaging/util/PhoneUtils.java b/src/com/android/messaging/util/PhoneUtils.java index a09a0892..ce63a4c5 100644 --- a/src/com/android/messaging/util/PhoneUtils.java +++ b/src/com/android/messaging/util/PhoneUtils.java @@ -42,9 +42,12 @@ import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.collection.ArrayMap; /** @@ -445,6 +448,28 @@ public String getSimOrDefaultLocaleCountry() { return country; } + @Nullable + public String getNetworkCountry() { + try { + final String country; + + if (mSubId == ParticipantData.DEFAULT_SELF_SUB_ID) { + country = mTelephonyManager.getNetworkCountryIso(); + } else { + country = mTelephonyManager.createForSubscriptionId(mSubId).getNetworkCountryIso(); + } + + if (TextUtils.isEmpty(country)) { + return null; + } + + return country.toUpperCase(); + } catch (final Exception e) { + LogUtil.e(TAG, "PhoneUtils.getNetworkCountry(): system exception for " + mSubId, e); + return null; + } + } + // Get or set the cache of canonicalized phone numbers for a specific country private static ArrayMap getOrAddCountryMapInCacheLocked(String country) { if (country == null) { @@ -482,10 +507,24 @@ private static void putCanonicalToCache(final String phoneText, String country, * @param country ISO country code based on which to parse the number. * @return E164 phone number. Returns null in case parsing failed. */ - private static String getValidE164Number(final String phoneText, final String country) { + @Nullable + private static String getValidE164Number( + @NonNull final String phoneText, + @Nullable final String country + ) { + if (!TextUtils.isEmpty(country)) { + final String frameworkE164Number = PhoneNumberUtils + .formatNumberToE164(phoneText, country); + + if (!TextUtils.isEmpty(frameworkE164Number)) { + return frameworkE164Number; + } + } + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); try { final PhoneNumber phoneNumber = phoneNumberUtil.parse(phoneText, country); + if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) { return phoneNumberUtil.format(phoneNumber, PhoneNumberFormat.E164); } @@ -493,6 +532,7 @@ private static String getValidE164Number(final String phoneText, final String co LogUtil.e(TAG, "PhoneUtils.getValidE164Number(): Not able to parse phone number " + LogUtil.sanitizePII(phoneText) + " for country " + country); } + return null; } @@ -506,6 +546,97 @@ public String getCanonicalBySystemLocale(final String phoneText) { return getCanonicalByCountry(phoneText, getLocaleCountry()); } + /** + * Canonicalize phone number using the best currently available country signal for manually + * entered destinations. The priority is network country, subscription countries, then locale. + * + * @param phoneText The phone number to canonicalize + * @return the canonicalized number + */ + public String getCanonicalForEnteredPhoneNumber(@NonNull final String phoneText) { + if (phoneText.isEmpty()) { + return phoneText; + } + + if (phoneText.charAt(0) == '+') { + return canonicalizeE164(phoneText); + } + + return getCanonicalByCountryCandidates( + phoneText, + getCountryCandidatesForEnteredPhoneNumber() + ); + } + + /** + * Same as {@link #getCanonicalForEnteredPhoneNumber(String)} but reuses a precomputed list of country candidates. + * Bulk callers (e.g. canonicalizing every row in a contacts page) should compute the candidate list once and + * pass it here to avoid re-running the telephony IPC required to assemble it on every call. + */ + public String getCanonicalForEnteredPhoneNumber( + @NonNull final String phoneText, + @NonNull final List countryCandidates + ) { + if (phoneText.isEmpty()) { + return phoneText; + } + + if (phoneText.charAt(0) == '+') { + return canonicalizeE164(phoneText); + } + + return getCanonicalByCountryCandidates(phoneText, countryCandidates); + } + + private String canonicalizeE164(@NonNull final String phoneText) { + final String cachedCanonicalNumber = getCanonicalFromCache(phoneText, null); + if (cachedCanonicalNumber != null) { + return cachedCanonicalNumber; + } + + final String canonicalNumber = getValidE164Number(phoneText, null); + final String resolvedCanonicalNumber = canonicalNumber != null + ? canonicalNumber : phoneText; + + putCanonicalToCache(phoneText, null, resolvedCanonicalNumber); + + return resolvedCanonicalNumber; + } + + /** + * Trigger libphonenumber metadata initialization eagerly so the first canonicalization call doesn't pay it. + * Cheap on later invocations — libphonenumber's singleton initializes only once per process. + */ + public void warmUp() { + PhoneNumberUtil.getInstance(); + } + + @NonNull + private String getCanonicalByCountryCandidates( + @NonNull final String phoneText, + final Iterable countryCandidates + ) { + for (final String country : countryCandidates) { + final String cachedCanonicalNumber = getCanonicalFromCache(phoneText, country); + if (cachedCanonicalNumber != null) { + if (!TextUtils.equals(cachedCanonicalNumber, phoneText)) { + return cachedCanonicalNumber; + } + continue; + } + + final String canonicalNumber = getValidE164Number(phoneText, country); + if (canonicalNumber != null) { + putCanonicalToCache(phoneText, country, canonicalNumber); + return canonicalNumber; + } + + putCanonicalToCache(phoneText, country, phoneText); + } + + return phoneText; + } + /** * Canonicalize phone number using SIM's country, may fall back to system locale country * if SIM country can not be obtained @@ -517,6 +648,53 @@ public String getCanonicalBySimLocale(final String phoneText) { return getCanonicalByCountry(phoneText, getSimOrDefaultLocaleCountry()); } + public List getCountryCandidatesForEnteredPhoneNumber() { + final LinkedHashSet uniqueCountries = new LinkedHashSet<>(); + String normalizedCountryCode = normalizeCountryCode(getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + final int defaultSmsSubId = getDefaultSmsSubscriptionId(); + if (defaultSmsSubId != ParticipantData.DEFAULT_SELF_SUB_ID) { + final PhoneUtils defaultSmsPhoneUtils = PhoneUtils.get(defaultSmsSubId); + normalizedCountryCode = normalizeCountryCode(defaultSmsPhoneUtils.getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + normalizedCountryCode = normalizeCountryCode(defaultSmsPhoneUtils.getSimCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + } + + for (final SubscriptionInfo subscriptionInfo : getActiveSubscriptionInfoList()) { + normalizedCountryCode = normalizeCountryCode(subscriptionInfo.getCountryIso()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + final PhoneUtils subscriptionPhoneUtils = + PhoneUtils.get(subscriptionInfo.getSubscriptionId()); + normalizedCountryCode = normalizeCountryCode(subscriptionPhoneUtils.getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + normalizedCountryCode = normalizeCountryCode(subscriptionPhoneUtils.getSimCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + } + + normalizedCountryCode = normalizeCountryCode(getLocaleCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + return new ArrayList<>(uniqueCountries); + } + /** * Canonicalize phone number using a country code. * This uses an internal cache per country to speed up. @@ -542,6 +720,14 @@ private String getCanonicalByCountry(final String phoneText, final String countr return canonicalNumber; } + @Nullable + private static String normalizeCountryCode(final String country) { + if (!TextUtils.isEmpty(country)) { + return country.toUpperCase(); + } + return null; + } + /** * Canonicalize the self (per SIM) phone number * diff --git a/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt new file mode 100644 index 00000000..7e56023b --- /dev/null +++ b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt @@ -0,0 +1,24 @@ +package com.android.messaging.util.core.extension + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +inline fun typedFlow( + crossinline block: suspend FlowCollector.() -> T, +): Flow { + return flow { + val value = block() + + emit(value) + } +} + +inline fun unitFlow( + crossinline block: suspend FlowCollector.() -> Unit, +): Flow { + return flow { + block() + emit(Unit) + } +} diff --git a/src/com/android/messaging/util/db/ReversedCursor.kt b/src/com/android/messaging/util/db/ReversedCursor.kt new file mode 100644 index 00000000..5984eda4 --- /dev/null +++ b/src/com/android/messaging/util/db/ReversedCursor.kt @@ -0,0 +1,62 @@ +package com.android.messaging.util.db + +import android.database.Cursor +import android.database.CursorWrapper + +/** + * Presents a cursor in reverse row order while preserving standard cursor navigation semantics + * Will replace [com.android.messaging.datamodel.data.ConversationData.ReversedCursor] in the future + */ +internal class ReversedCursor( + cursor: Cursor, +) : CursorWrapper(cursor) { + private val count: Int = cursor.count + + init { + cursor.moveToPosition(count) + } + + override fun moveToPosition(position: Int): Boolean { + return super.moveToPosition(count - position - 1) + } + + override fun getPosition(): Int { + return count - super.getPosition() - 1 + } + + override fun isAfterLast(): Boolean { + return super.isBeforeFirst + } + + override fun isBeforeFirst(): Boolean { + return super.isAfterLast + } + + override fun isFirst(): Boolean { + return super.isLast + } + + override fun isLast(): Boolean { + return super.isFirst + } + + override fun move(offset: Int): Boolean { + return super.move(-offset) + } + + override fun moveToFirst(): Boolean { + return super.moveToLast() + } + + override fun moveToLast(): Boolean { + return super.moveToFirst() + } + + override fun moveToNext(): Boolean { + return super.moveToPrevious() + } + + override fun moveToPrevious(): Boolean { + return super.moveToNext() + } +} diff --git a/src/com/android/messaging/util/db/ext/CursorExtensions.kt b/src/com/android/messaging/util/db/ext/CursorExtensions.kt new file mode 100644 index 00000000..49bf3d6e --- /dev/null +++ b/src/com/android/messaging/util/db/ext/CursorExtensions.kt @@ -0,0 +1,23 @@ +package com.android.messaging.util.db.ext + +import android.database.Cursor +import androidx.core.database.getStringOrNull + +fun Cursor.getStringOrNull(columnName: String): String? { + return getColumnIndexOrThrow(columnName) + .let(::getStringOrNull) +} + +fun Cursor.getStringOrEmpty(columnName: String): String { + return getStringOrNull(columnName = columnName).orEmpty() +} + +fun Cursor.getInt(columnName: String): Int { + return getColumnIndexOrThrow(columnName) + .let(::getInt) +} + +fun Cursor.getLong(columnName: String): Long { + return getColumnIndexOrThrow(columnName) + .let(::getLong) +}