From a3a1f5d844915f99089df3de607b343bf57574bf Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 1 Jul 2026 19:09:31 +0300 Subject: [PATCH 01/19] Modernize Photo Viewer screen with Compose --- .gitmodules | 4 - .idea/vcs.xml | 1 - AndroidManifest.xml | 6 +- app/build.gradle.kts | 2 +- .../screen/PhotoViewerScreenContentTest.kt | 347 +++++++++++++++ .../PhotoViewerRepositoryImplTest.kt | 341 +++++++++++++++ .../ui/ActivityPermissionGateTest.kt | 4 +- .../component/PhotoViewerDecodeSizeTest.kt | 62 +++ .../PhotoViewerDismissDragStateTest.kt | 123 ++++++ .../ZoomablePhotoGestureStateTest.kt | 123 ++++++ .../screen/PhotoViewerTransformTest.kt | 169 +++++++ .../screen/PhotoViewerViewModelTest.kt | 411 ++++++++++++++++++ gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 76 ++-- lib/platform_frameworks_opt_photoviewer | 1 - res/menu/photo_view_menu.xml | 30 -- res/values/strings.xml | 4 + res/values/styles.xml | 13 +- settings.gradle.kts | 1 - .../data/media/model/PhotoViewerItem.kt | 23 + .../data/media/model/PhotoViewerItems.kt | 18 + .../media/repository/PhotoViewerRepository.kt | 223 ++++++++++ .../datamodel/ConversationImagePartsView.java | 34 +- .../android/messaging/debug/TestDataSeeder.kt | 114 +++++ .../di/photoviewer/PhotoViewerBindsModule.kt | 20 + .../android/messaging/ui/UIIntentsImpl.java | 21 +- .../photoviewer/BuglePhotoBitmapLoader.java | 191 -------- .../ui/photoviewer/BuglePhotoPageAdapter.java | 38 -- .../photoviewer/BuglePhotoViewActivity.java | 31 -- .../photoviewer/BuglePhotoViewController.java | 208 --------- .../photoviewer/BuglePhotoViewFragment.java | 89 ---- .../ui/photoviewer/PhotoViewerActivity.kt | 104 +++++ .../ui/photoviewer/PhotoViewerTestTags.kt | 12 + .../component/PhotoViewerContentKey.kt | 9 + .../component/PhotoViewerDecodeSize.kt | 28 ++ .../component/PhotoViewerDismissDragState.kt | 131 ++++++ .../photoviewer/component/PhotoViewerImage.kt | 208 +++++++++ .../component/PhotoViewerMetadataSheet.kt | 217 +++++++++ .../photoviewer/component/PhotoViewerPage.kt | 138 ++++++ .../photoviewer/component/PhotoViewerPager.kt | 214 +++++++++ .../component/PhotoViewerStatusMessage.kt | 57 +++ .../component/PhotoViewerTopBar.kt | 265 +++++++++++ .../component/ZoomablePhotoContainer.kt | 238 ++++++++++ .../component/ZoomablePhotoGestureState.kt | 115 +++++ .../model/PhotoViewerLaunchRequest.kt | 25 ++ .../model/PhotoViewerSourceBounds.kt | 23 + .../preview/PhotoViewerPreviewData.kt | 25 ++ .../photoviewer/screen/PhotoViewerContent.kt | 221 ++++++++++ .../photoviewer/screen/PhotoViewerScreen.kt | 161 +++++++ .../screen/PhotoViewerScreenEffects.kt | 138 ++++++ .../screen/PhotoViewerTransform.kt | 113 +++++ .../screen/PhotoViewerViewModel.kt | 376 ++++++++++++++++ .../screen/model/PhotoViewerEffect.kt | 22 + .../screen/model/PhotoViewerUiState.kt | 28 ++ .../messaging/util/db/ext/CursorExtensions.kt | 4 + 55 files changed, 4927 insertions(+), 674 deletions(-) create mode 100644 app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSizeTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureStateTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransformTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt delete mode 160000 lib/platform_frameworks_opt_photoviewer delete mode 100644 res/menu/photo_view_menu.xml create mode 100644 src/com/android/messaging/data/media/model/PhotoViewerItem.kt create mode 100644 src/com/android/messaging/data/media/model/PhotoViewerItems.kt create mode 100644 src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt create mode 100644 src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt delete mode 100644 src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java delete mode 100644 src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java delete mode 100644 src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java delete mode 100644 src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java delete mode 100644 src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java create mode 100644 src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt create mode 100644 src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerContentKey.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSize.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragState.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerImage.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerPage.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerStatusMessage.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt create mode 100644 src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureState.kt create mode 100644 src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt create mode 100644 src/com/android/messaging/ui/photoviewer/model/PhotoViewerSourceBounds.kt create mode 100644 src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransform.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerEffect.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerUiState.kt diff --git a/.gitmodules b/.gitmodules index b01b07e25..d3109c176 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,10 +10,6 @@ path = lib/platform_frameworks_opt_chips url = https://github.com/GrapheneOS-Archive/platform_frameworks_opt_chips branch = main -[submodule "lib/platform_frameworks_opt_photoviewer"] - path = lib/platform_frameworks_opt_photoviewer - url = https://github.com/GrapheneOS-Archive/platform_frameworks_opt_photoviewer - branch = main [submodule "lib/platform_frameworks_opt_vcard"] path = lib/platform_frameworks_opt_vcard url = https://github.com/GrapheneOS/platform_frameworks_opt_vcard diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 4d760f2f2..e0146fae7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -12,7 +12,6 @@ - \ No newline at end of file diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 3403682d8..1a28947f3 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -161,11 +161,11 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b8764dc0..64f9cc60a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,7 @@ dependencies { implementation(libs.androidx.photo.picker) implementation(libs.coil.compose) + implementation(libs.coil.gif) implementation(libs.coil.network.okhttp) implementation(libs.glide) @@ -197,7 +198,6 @@ dependencies { implementation(libs.libphonenumber) implementation(project(":lib:platform_frameworks_opt_chips")) - implementation(project(":lib:platform_frameworks_opt_photoviewer")) implementation(project(":lib:platform_frameworks_opt_vcard")) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt new file mode 100644 index 000000000..349181223 --- /dev/null +++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt @@ -0,0 +1,347 @@ +package com.android.messaging.ui.photoviewer.screen + +import android.content.Context +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.android.messaging.R +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.core.AppTheme +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGER_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +private const val TEST_WAIT_TIMEOUT_MILLIS = 5_000L + +internal class PhotoViewerScreenContentTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun initialRender_showsCurrentItemChrome() { + setScreenContent() + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_TITLE_TEST_TAG) + .assertTextContains(value = FIRST_SENDER) + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG) + .assertIsDisplayed() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG) + .assertIsDisplayed() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun swipeLeft_setsNextPage() { + val currentPage = AtomicInteger() + setScreenContent( + onPageSettled = { page -> + currentPage.set(page) + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGER_TEST_TAG) + .performTouchInput { + swipeLeft() + } + + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + currentPage.get() == 1 + } + composeRule.onNodeWithText(text = SECOND_SENDER).assertIsDisplayed() + } + + @Test + fun overflowDetailsClick_showsMetadataSheet() { + setScreenContent() + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG) + .performClick() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG) + .performClick() + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_SHEET_TEST_TAG) + .assertIsDisplayed() + composeRule.onNodeWithText(text = string(resId = R.string.message_details_from_label)) + .assertIsDisplayed() + composeRule.onNodeWithText(text = string(resId = R.string.message_details_type_label)) + .assertIsDisplayed() + composeRule.onNodeWithText(text = IMAGE_JPEG).assertIsDisplayed() + } + + @Test + fun chromeActions_emitCallbacks() { + val closeClickCount = AtomicInteger() + val forwardClickCount = AtomicInteger() + val saveClickCount = AtomicInteger() + val shareClickCount = AtomicInteger() + setScreenContent( + onCloseClick = { + closeClickCount.incrementAndGet() + }, + onForwardClick = { + forwardClickCount.incrementAndGet() + }, + onSaveClick = { + saveClickCount.incrementAndGet() + }, + onShareClick = { + shareClickCount.incrementAndGet() + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG) + .performClick() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG) + .performClick() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG) + .performClick() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG) + .performClick() + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG) + .performClick() + + composeRule.runOnIdle { + assertEquals(1, saveClickCount.get()) + assertEquals(1, shareClickCount.get()) + assertEquals(1, forwardClickCount.get()) + assertEquals(1, closeClickCount.get()) + } + } + + @Test + fun doubleTapPhoto_entersImmersiveMode() { + val enterImmersiveCount = AtomicInteger() + val uiState = setScreenContent( + onEnterImmersiveMode = { + enterImmersiveCount.incrementAndGet() + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG) + .performTouchInput { + doubleClick() + } + + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + enterImmersiveCount.get() == 1 && + uiState.value.displayMode == PhotoViewerDisplayMode.Immersive + } + } + + @Test + fun swipeDownPhotoPastDismissThreshold_emitsCloseCallback() { + val closeClickCount = AtomicInteger() + setScreenContent( + onCloseClick = { + closeClickCount.incrementAndGet() + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG) + .performTouchInput { + swipe( + start = center, + end = Offset(x = center.x, y = bottom - 1f), + durationMillis = 100, + ) + } + + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + closeClickCount.get() == 1 + } + } + + @Test + fun closing_whenRootSizeChangesAfterAnimation_callsCloseAnimationFinishedOnce() { + val closeAnimationFinishedCount = AtomicInteger() + val containerHeight = mutableStateOf(value = 640.dp) + val uiState = mutableStateOf(value = loadedPhotoViewerUiState()) + composeRule.mainClock.autoAdvance = false + + try { + composeRule.setContent { + AppTheme { + Box( + modifier = Modifier.size( + width = 320.dp, + height = containerHeight.value, + ), + ) { + PhotoViewerScreenContent( + launchRequest = launchRequest, + uiState = uiState.value, + onPageSettled = {}, + onToggleDisplayMode = {}, + onEnterImmersiveMode = {}, + onMetadataClick = {}, + onMetadataDismissed = {}, + onCloseClick = {}, + onCloseAnimationFinished = { + closeAnimationFinishedCount.incrementAndGet() + }, + onForwardClick = {}, + onSaveClick = {}, + onShareClick = {}, + ) + } + } + } + + composeRule.mainClock.advanceTimeBy(milliseconds = 100) + composeRule.runOnIdle { + uiState.value = uiState.value.copy(isClosing = true) + } + composeRule.mainClock.advanceTimeBy(milliseconds = 1_000) + composeRule.runOnIdle { + assertEquals(1, closeAnimationFinishedCount.get()) + containerHeight.value = 480.dp + } + composeRule.mainClock.advanceTimeBy(milliseconds = 1_000) + composeRule.runOnIdle { + assertEquals(1, closeAnimationFinishedCount.get()) + } + } finally { + composeRule.mainClock.autoAdvance = true + } + } + + private fun setScreenContent( + onPageSettled: (Int) -> Unit = {}, + onToggleDisplayMode: () -> Unit = {}, + onEnterImmersiveMode: () -> Unit = {}, + onCloseClick: () -> Unit = {}, + onForwardClick: () -> Unit = {}, + onSaveClick: () -> Unit = {}, + onShareClick: () -> Unit = {}, + ): MutableState { + val uiState = mutableStateOf(value = loadedPhotoViewerUiState()) + + composeRule.setContent { + AppTheme { + PhotoViewerScreenContent( + launchRequest = launchRequest, + uiState = uiState.value, + onPageSettled = { page -> + uiState.value = uiState.value.copy(currentPage = page) + onPageSettled(page) + }, + onToggleDisplayMode = { + uiState.value = uiState.value.copy( + displayMode = nextDisplayMode( + displayMode = uiState.value.displayMode, + ), + ) + onToggleDisplayMode() + }, + onEnterImmersiveMode = { + uiState.value = uiState.value.copy( + displayMode = PhotoViewerDisplayMode.Immersive, + ) + onEnterImmersiveMode() + }, + onMetadataClick = { + uiState.value = uiState.value.copy(isMetadataSheetVisible = true) + }, + onMetadataDismissed = { + uiState.value = uiState.value.copy(isMetadataSheetVisible = false) + }, + onCloseClick = onCloseClick, + onCloseAnimationFinished = {}, + onForwardClick = onForwardClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } + } + + return uiState + } + + private fun loadedPhotoViewerUiState(): PhotoViewerUiState { + return PhotoViewerUiState( + items = persistentListOf( + photoViewerItem(index = 1, senderName = FIRST_SENDER), + photoViewerItem(index = 2, senderName = SECOND_SENDER), + ), + loadState = PhotoViewerLoadState.Loaded, + ) + } + + private fun photoViewerItem(index: Int, senderName: String): PhotoViewerItem { + return PhotoViewerItem( + contentUri = photoViewerImageUri(), + contentType = IMAGE_JPEG, + senderName = senderName, + senderDestination = "+1555000$index", + receivedTimestampMillis = 1_735_689_600_000L + index, + isDraft = false, + ) + } + + private fun photoViewerImageUri(): Uri { + return Uri.parse( + "android.resource://${context.packageName}/${R.drawable.ic_preview_play}", + ) + } + + private fun string(@StringRes resId: Int): String { + return context.getString(resId) + } + + private fun nextDisplayMode(displayMode: PhotoViewerDisplayMode): PhotoViewerDisplayMode { + return when (displayMode) { + PhotoViewerDisplayMode.Carousel -> PhotoViewerDisplayMode.Immersive + PhotoViewerDisplayMode.Immersive -> PhotoViewerDisplayMode.Carousel + } + } + + private companion object { + const val FIRST_SENDER = "Ada Lovelace" + const val IMAGE_JPEG = "image/jpeg" + const val SECOND_SENDER = "Grace Hopper" + + val launchRequest = PhotoViewerLaunchRequest( + initialPhotoUri = "content://example/content/1", + photosUri = "content://example/photos", + sourceBounds = PhotoViewerSourceBounds(), + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt new file mode 100644 index 000000000..8bf74a184 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt @@ -0,0 +1,341 @@ +package com.android.messaging.data.media.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.database.MatrixCursor +import android.database.sqlite.SQLiteException +import android.net.Uri +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.data.media.model.PhotoViewerItems +import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult +import com.android.messaging.datamodel.ConversationImagePartsView +import com.android.messaging.datamodel.data.MessageData +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class PhotoViewerRepositoryImplTest { + + private val contentResolver = mockk(relaxed = true) + private val testDispatcher = StandardTestDispatcher() + private val repository = PhotoViewerRepositoryImpl( + contentResolver = contentResolver, + messagingDbDispatcher = testDispatcher, + defaultDispatcher = testDispatcher, + ) + + @Test + fun getPhotoViewerItems_mapsRowsAndInitialIndex() { + runTest(context = testDispatcher) { + val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply { + addPhotoRow( + uri = "content://example/photo/1", + senderName = "Ada", + contentUri = "content://example/content/1", + contentType = IMAGE_JPEG, + senderDestination = "+15550001", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + addPhotoRow( + uri = "content://example/photo/2", + senderName = null, + contentUri = "content://example/content/2?updated=true", + contentType = IMAGE_JPEG, + senderDestination = "+15550002", + receivedTimestampMillis = 2000L, + status = MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + ) + } + stubContentResolver(cursor = cursor) + + val result = repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/content/2"), + ).firstLoadedForTest() + + assertEquals(1, result.initialIndex) + assertEquals(2, result.items.size) + assertEquals( + PhotoViewerItem( + contentUri = Uri.parse("content://example/content/2?updated=true"), + contentType = IMAGE_JPEG, + senderName = null, + senderDestination = "+15550002", + receivedTimestampMillis = 2000L, + isDraft = true, + ), + result.items[1], + ) + } + } + + @Test + fun getPhotoViewerItems_whenInitialMissing_usesZeroIndex() { + runTest(context = testDispatcher) { + val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply { + addPhotoRow( + uri = "content://example/photo/1", + senderName = "Ada", + contentUri = "content://example/content/1", + contentType = IMAGE_JPEG, + senderDestination = "+15550001", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + } + stubContentResolver(cursor = cursor) + + val result = repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/missing"), + ).firstLoadedForTest() + + assertEquals(0, result.initialIndex) + } + } + + @Test + fun getPhotoViewerItems_whenCursorIsNull_returnsEmptyLoadedResult() { + runTest(context = testDispatcher) { + stubContentResolver(cursor = null) + + val result = repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/missing"), + ).firstLoadedForTest() + + assertEquals(0, result.initialIndex) + assertEquals(0, result.items.size) + } + } + + @Test + fun getPhotoViewerItems_skipsRowsWithMissingRequiredValues() { + runTest(context = testDispatcher) { + val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply { + addPhotoRow( + uri = null, + senderName = "Ada", + contentUri = "content://example/content/valid-without-legacy-uri", + contentType = IMAGE_JPEG, + senderDestination = "+15550001", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + addPhotoRow( + uri = "content://example/photo/invalid-content-uri", + senderName = "Invalid", + contentUri = null, + contentType = IMAGE_JPEG, + senderDestination = "+15550000", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + addPhotoRow( + uri = "content://example/photo/invalid-content-type", + senderName = "Invalid", + contentUri = "content://example/content/invalid-content-type", + contentType = null, + senderDestination = "+15550000", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + addPhotoRow( + uri = "content://example/photo/valid", + senderName = "Grace", + contentUri = "content://example/content/valid", + contentType = IMAGE_JPEG, + senderDestination = "+15550002", + receivedTimestampMillis = 2000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + } + stubContentResolver(cursor = cursor) + + val result = repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/content/valid"), + ).firstLoadedForTest() + + assertEquals(2, result.items.size) + assertEquals( + Uri.parse("content://example/content/valid-without-legacy-uri"), + result.items[0].contentUri, + ) + assertEquals(Uri.parse("content://example/content/valid"), result.items[1].contentUri) + assertEquals(1, result.initialIndex) + } + } + + @Test + fun getPhotoViewerItems_whenQueryThrows_returnsErrorResult() { + runTest(context = testDispatcher) { + every { + contentResolver.query( + photosUri, + ConversationImagePartsView.PhotoViewQuery.PROJECTION, + null, + null, + null, + ) + } throws SQLiteException("query failed") + + val result = repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/missing"), + ).first() + + assertTrue(result is PhotoViewerItemsLoadResult.Error) + } + } + + @Test + fun getPhotoViewerItems_whenQueryFails_keepsObservingForRefresh() { + runTest(context = testDispatcher) { + val observerSlot = slot() + val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply { + addPhotoRow( + uri = "content://example/photo/1", + senderName = "Ada", + contentUri = "content://example/content/1", + contentType = IMAGE_JPEG, + senderDestination = "+15550001", + receivedTimestampMillis = 1000L, + status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + ) + } + var queryCount = 0 + every { + contentResolver.registerContentObserver( + photosUri, + true, + capture(observerSlot), + ) + } returns Unit + every { + contentResolver.query( + photosUri, + ConversationImagePartsView.PhotoViewQuery.PROJECTION, + null, + null, + null, + ) + } answers { + queryCount += 1 + when (queryCount) { + 1 -> throw SQLiteException("query failed") + else -> cursor + } + } + + val results = mutableListOf() + val collectJob = launch(testDispatcher) { + repository + .getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/content/1"), + ) + .take(count = 2) + .toList(destination = results) + } + + runCurrent() + observerSlot.captured.onChange(false) + runCurrent() + + assertTrue(results[0] is PhotoViewerItemsLoadResult.Error) + assertTrue(results[1] is PhotoViewerItemsLoadResult.Loaded) + collectJob.cancel() + } + } + + @Test + fun getPhotoViewerItems_whenUnexpectedRuntimeFailure_propagates() { + assertThrows(IllegalStateException::class.java) { + runTest(context = testDispatcher) { + every { + contentResolver.query( + photosUri, + ConversationImagePartsView.PhotoViewQuery.PROJECTION, + null, + null, + null, + ) + } throws IllegalStateException("unexpected query failure") + + repository.getPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = Uri.parse("content://example/missing"), + ).first() + } + } + } + + private fun stubContentResolver(cursor: MatrixCursor?) { + every { + contentResolver.query( + photosUri, + ConversationImagePartsView.PhotoViewQuery.PROJECTION, + null, + null, + null, + ) + } returns cursor + } + + private fun MatrixCursor.addPhotoRow( + uri: String?, + senderName: String?, + contentUri: String?, + contentType: String?, + senderDestination: String, + receivedTimestampMillis: Long, + status: Int, + ) { + addRow( + arrayOf( + uri, + senderName, + contentUri, + null, + contentType, + senderDestination, + receivedTimestampMillis, + status, + ), + ) + } + + private suspend fun Flow.firstLoadedForTest(): PhotoViewerItems { + return when (val result = first()) { + is PhotoViewerItemsLoadResult.Loaded -> result.photoViewerItems + PhotoViewerItemsLoadResult.Error -> { + throw AssertionError("Expected loaded photo viewer items") + } + } + } + + private companion object { + const val IMAGE_JPEG = "image/jpeg" + + val photosUri: Uri = Uri.parse("content://example/photos") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt index cbe38e00b..de4f29761 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/ActivityPermissionGateTest.kt @@ -5,7 +5,7 @@ import com.android.messaging.ui.contact.AddContactActivity import com.android.messaging.ui.conversation.LaunchConversationActivity import com.android.messaging.ui.license.LicenseActivity import com.android.messaging.ui.permissioncheck.PermissionCheckActivity -import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity +import com.android.messaging.ui.photoviewer.PhotoViewerActivity import java.io.File import javax.xml.parsers.DocumentBuilderFactory import org.junit.Assert.assertTrue @@ -26,7 +26,7 @@ internal class ActivityPermissionGateTest { private val intentionallyUngated = setOf>( PermissionCheckActivity::class.java, LaunchConversationActivity::class.java, - BuglePhotoViewActivity::class.java, + PhotoViewerActivity::class.java, LicenseActivity::class.java, TestActivity::class.java, ClassZeroActivity::class.java, diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSizeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSizeTest.kt new file mode 100644 index 000000000..f9c3eb091 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSizeTest.kt @@ -0,0 +1,62 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.ui.unit.IntSize +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class PhotoViewerDecodeSizeTest { + + @Test + fun resolvePhotoViewerDecodeSize_scalesToOneAndHalfViewportSize() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 1080, height = 1920), + ) + + assertEquals(IntSize(width = 1620, height = 2880), decodeSize) + } + + @Test + fun resolvePhotoViewerDecodeSize_capsDimensionsIndependently() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 3000, height = 2500), + ) + + assertEquals(IntSize(width = 4096, height = 3750), decodeSize) + } + + @Test + fun resolvePhotoViewerDecodeSize_coercesInvalidDimensionsToOne() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 0, height = -100), + ) + + assertEquals(IntSize(width = 1, height = 1), decodeSize) + } + + @Test + fun resolvePhotoViewerDecodeSize_preservesLandscapeBoundsBeforeCap() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 1600, height = 900), + ) + + assertEquals(IntSize(width = 2400, height = 1350), decodeSize) + } + + @Test + fun resolvePhotoViewerDecodeSize_preservesPortraitBoundsBeforeCap() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 900, height = 1600), + ) + + assertEquals(IntSize(width = 1350, height = 2400), decodeSize) + } + + @Test + fun resolvePhotoViewerDecodeSize_roundsHalfPixelsUp() { + val decodeSize = resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize(width = 901, height = 601), + ) + + assertEquals(IntSize(width = 1352, height = 902), decodeSize) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragStateTest.kt new file mode 100644 index 000000000..e573c6c52 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragStateTest.kt @@ -0,0 +1,123 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val FLOAT_DELTA = 0.001f +private const val TEST_DISMISS_THRESHOLD = 200f + +internal class PhotoViewerDismissDragStateTest { + + @Test + fun canHandleDrag_allowsDownwardDragOnlyWhenImageIsNotZoomed() { + val state = photoViewerDismissDragState() + + assertTrue(state.canHandleDrag(isZoomed = false, dragAmount = 1f)) + assertFalse(state.canHandleDrag(isZoomed = true, dragAmount = 1f)) + assertFalse(state.canHandleDrag(isZoomed = false, dragAmount = -1f)) + } + + @Test + fun canHandleDrag_allowsUpwardDragAfterDismissDragStarts() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = 25f) + + assertTrue(state.canHandleDrag(isZoomed = false, dragAmount = -1f)) + assertFalse(state.canHandleDrag(isZoomed = true, dragAmount = -1f)) + } + + @Test + fun applyDrag_clampsOffsetToZero() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = 25f) + state.applyDrag(dragAmount = -50f) + + assertEquals(0f, state.animatedDragOffset, FLOAT_DELTA) + assertFalse(state.canHandleDrag(isZoomed = false, dragAmount = -1f)) + } + + @Test + fun shouldShowTopBar_flipsAtDismissProgressThreshold() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = 19f) + + assertTrue(state.shouldShowTopBar) + + state.applyDrag(dragAmount = 1f) + + assertFalse(state.shouldShowTopBar) + } + + @Test + fun backgroundAlpha_reachesZeroAtHalfDismissProgress() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = 50f) + + assertEquals(0.5f, state.backgroundAlpha, FLOAT_DELTA) + + state.applyDrag(dragAmount = 50f) + + assertEquals(0f, state.backgroundAlpha, FLOAT_DELTA) + } + + @Test + fun imageAlpha_clampsToMinimumAtFullDismissProgress() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = TEST_DISMISS_THRESHOLD * 2f) + + assertEquals(0.4f, state.imageAlpha, FLOAT_DELTA) + } + + @Test + fun shouldCloseOnDragEnd_usesHalfDismissThreshold() { + val state = photoViewerDismissDragState() + + state.applyDrag(dragAmount = 99f) + + assertFalse(state.shouldCloseOnDragEnd()) + + state.applyDrag(dragAmount = 1f) + + assertTrue(state.shouldCloseOnDragEnd()) + } + + @Test + fun reset_clearsDismissOffsetAndCloseState() { + val state = photoViewerDismissDragState() + + state.activate() + state.applyDrag(dragAmount = 100f) + + state.reset() + + assertEquals(0f, state.animatedDragOffset, FLOAT_DELTA) + assertFalse(state.shouldCloseOnDragEnd()) + assertTrue(state.shouldShowTopBar) + } + + private fun photoViewerDismissDragState(): PhotoViewerDismissDragState { + val dismissDragOffsetState = mutableFloatStateOf(value = 0f) + + return PhotoViewerDismissDragState( + swipeDismissThreshold = TEST_DISMISS_THRESHOLD, + dismissDragOffsetState = dismissDragOffsetState, + isActiveState = mutableStateOf(value = false), + animatedDismissDragOffsetState = object : State { + override val value: Float + get() { + return dismissDragOffsetState.floatValue + } + }, + ) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureStateTest.kt new file mode 100644 index 000000000..d68161f2d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureStateTest.kt @@ -0,0 +1,123 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val FLOAT_DELTA = 0.001f + +internal class ZoomablePhotoGestureStateTest { + + @Test + fun initialState_isUnzoomedAndUntranslated() { + val state = ZoomablePhotoGestureState() + + assertEquals(IntSize.Zero, state.containerSize) + assertEquals(1f, state.scale, FLOAT_DELTA) + assertEquals(Offset.Zero, state.offset) + assertFalse(state.isZoomed()) + assertFalse(state.isZoomAnimationEnabled) + } + + @Test + fun applyTransform_clampsScaleToSupportedRange() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + + assertTrue(state.applyTransform(zoomChange = 10f, panChange = Offset.Zero)) + assertEquals(5f, state.scale, FLOAT_DELTA) + assertTrue(state.isZoomed()) + + assertTrue(state.applyTransform(zoomChange = 0.1f, panChange = Offset(x = 50f, y = 50f))) + assertEquals(1f, state.scale, FLOAT_DELTA) + assertEquals(Offset.Zero, state.offset) + assertFalse(state.isZoomed()) + assertFalse(state.isZoomAnimationEnabled) + } + + @Test + fun applyTransform_clampsPanOffsetToScaledContainerBounds() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 200)) + state.applyTransform(zoomChange = 3f, panChange = Offset.Zero) + + assertFalse(state.applyTransform(zoomChange = 1f, panChange = Offset(x = 250f, y = -250f))) + assertEquals(Offset(x = 100f, y = -200f), state.offset) + } + + @Test + fun updateContainerSize_reclampsExistingOffset() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 200, height = 200)) + state.applyTransform(zoomChange = 3f, panChange = Offset.Zero) + state.applyTransform(zoomChange = 1f, panChange = Offset(x = 300f, y = 300f)) + + assertEquals(Offset(x = 200f, y = 200f), state.offset) + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + + assertEquals(Offset(x = 100f, y = 100f), state.offset) + } + + @Test + fun applyDoubleTap_whenUnzoomed_zoomsAroundTapOffset() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + state.applyDoubleTap(tapOffset = Offset(x = 75f, y = 25f)) + + assertEquals(2.5f, state.scale, FLOAT_DELTA) + assertEquals(Offset(x = -37.5f, y = 37.5f), state.offset) + assertTrue(state.isZoomed()) + assertTrue(state.isZoomAnimationEnabled) + } + + @Test + fun applyDoubleTap_whenZoomed_resetsZoomAndOffset() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + state.applyDoubleTap(tapOffset = Offset(x = 75f, y = 25f)) + state.applyDoubleTap(tapOffset = Offset(x = 50f, y = 50f)) + + assertEquals(1f, state.scale, FLOAT_DELTA) + assertEquals(Offset.Zero, state.offset) + assertFalse(state.isZoomed()) + assertTrue(state.isZoomAnimationEnabled) + } + + @Test + fun resetAll_clearsZoomOffsetAndAnimationFlag() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + state.applyDoubleTap(tapOffset = Offset(x = 75f, y = 25f)) + + state.resetAll() + + assertEquals(1f, state.scale, FLOAT_DELTA) + assertEquals(Offset.Zero, state.offset) + assertFalse(state.isZoomed()) + assertFalse(state.isZoomAnimationEnabled) + } + + @Test + fun isZoomed_usesEpsilonAboveDefaultScale() { + val state = ZoomablePhotoGestureState() + + state.updateContainerSize(containerSize = IntSize(width = 100, height = 100)) + state.applyTransform(zoomChange = 1.005f, panChange = Offset.Zero) + + assertFalse(state.isZoomed()) + + state.applyTransform(zoomChange = 1.02f, panChange = Offset.Zero) + + assertTrue(state.isZoomed()) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransformTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransformTest.kt new file mode 100644 index 000000000..67e4011c4 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransformTest.kt @@ -0,0 +1,169 @@ +package com.android.messaging.ui.photoviewer.screen + +import androidx.compose.ui.unit.IntSize +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds +import org.junit.Assert.assertEquals +import org.junit.Test + +private const val FLOAT_DELTA = 0.001f + +internal class PhotoViewerTransformTest { + + @Test + fun resolvePhotoViewerTransform_whenRootSizeIsZero_usesFallbackAlphaAndScale() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds(), + rootSize = IntSize.Zero, + progress = 1f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.95f, + translationX = 0f, + translationY = 0f, + alpha = 0f, + ), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_whenSourceBoundsAreInvalid_usesFallbackStartTransform() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds(), + rootSize = IntSize(width = 1000, height = 800), + progress = 0f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.95f, + translationX = 0f, + translationY = 24f, + alpha = 0f, + ), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_whenSourceBoundsAreInvalid_interpolatesFallbackToIdentity() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds(), + rootSize = IntSize(width = 1000, height = 800), + progress = 0.5f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.975f, + translationX = 0f, + translationY = 12f, + alpha = 0.5f, + ), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_whenSourceBoundsAreValid_usesSourceCenterAndScale() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds( + left = 100, + top = 200, + right = 300, + bottom = 500, + ), + rootSize = IntSize(width = 1000, height = 800), + progress = 0f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.375f, + translationX = -300f, + translationY = -50f, + alpha = 0f, + ), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_whenSourceBoundsAreValid_interpolatesToIdentity() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds( + left = 100, + top = 200, + right = 300, + bottom = 500, + ), + rootSize = IntSize(width = 1000, height = 800), + progress = 0.5f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.6875f, + translationX = -150f, + translationY = -25f, + alpha = 0.5f, + ), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_whenProgressIsComplete_returnsIdentityTransform() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds( + left = 100, + top = 200, + right = 300, + bottom = 500, + ), + rootSize = IntSize(width = 1000, height = 800), + progress = 1f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform(), + actual = transform, + ) + } + + @Test + fun resolvePhotoViewerTransform_clampsTinySourceBoundsToMinimumScale() { + val transform = resolvePhotoViewerTransform( + sourceBounds = PhotoViewerSourceBounds( + left = 495, + top = 395, + right = 505, + bottom = 405, + ), + rootSize = IntSize(width = 1000, height = 800), + progress = 0f, + ) + + assertPhotoViewerTransform( + expected = PhotoViewerTransform( + scale = 0.1f, + translationX = 0f, + translationY = 0f, + alpha = 0f, + ), + actual = transform, + ) + } + + private fun assertPhotoViewerTransform( + expected: PhotoViewerTransform, + actual: PhotoViewerTransform, + ) { + assertEquals(expected.scale, actual.scale, FLOAT_DELTA) + assertEquals(expected.translationX, actual.translationX, FLOAT_DELTA) + assertEquals(expected.translationY, actual.translationY, FLOAT_DELTA) + assertEquals(expected.alpha, actual.alpha, FLOAT_DELTA) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt new file mode 100644 index 000000000..ac7840969 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt @@ -0,0 +1,411 @@ +package com.android.messaging.ui.photoviewer.screen + +import android.net.Uri +import app.cash.turbine.test +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.data.media.model.PhotoViewerItems +import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult +import com.android.messaging.data.media.repository.PhotoViewerRepository +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerEffect +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class PhotoViewerViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun firstEmission_usesInitialIndex() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2, photo3), + initialIndex = 1, + ), + ) + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.currentPage) + assertEquals(PhotoViewerLoadState.Loaded, viewModel.uiState.value.loadState) + } + } + + @Test + fun refresh_whenCurrentItemRemains_preservesCurrentItem() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2, photo3), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + viewModel.onPageSettled(page = 2) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo3Refreshed, photo1, photo2), + initialIndex = 1, + ), + ) + advanceUntilIdle() + + assertEquals(0, viewModel.uiState.value.currentPage) + assertEquals(photo3Refreshed.contentUri, viewModel.uiState.value.items[0].contentUri) + } + } + + @Test + fun refresh_whenCurrentItemRemoved_clampsToNearestValidPage() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2, photo3), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + viewModel.onPageSettled(page = 2) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.currentPage) + } + } + + @Test + fun refresh_keepsDisplayMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + loadInitialItems( + repositoryResults = repositoryResults, + viewModel = viewModel, + ) + + viewModel.onEnterImmersiveMode() + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + assertEquals(PhotoViewerDisplayMode.Immersive, viewModel.uiState.value.displayMode) + } + } + + @Test + fun refresh_keepsMetadataSheetVisible() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + loadInitialItems( + repositoryResults = repositoryResults, + viewModel = viewModel, + ) + + viewModel.onMetadataClick() + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + assertEquals(true, viewModel.uiState.value.isMetadataSheetVisible) + } + } + + @Test + fun refresh_withEmptyResult_hidesMetadataSheet() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + loadInitialItems( + repositoryResults = repositoryResults, + viewModel = viewModel, + ) + + viewModel.onMetadataClick() + repositoryResults.emitLoaded( + photoViewerItems( + items = emptyList(), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + assertEquals(PhotoViewerLoadState.Empty, viewModel.uiState.value.loadState) + assertEquals(false, viewModel.uiState.value.isMetadataSheetVisible) + } + } + + @Test + fun scratchUriActions_emitEffects() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + val scratchItem = photo1.copy(contentUri = scratchUri) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(scratchItem), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onSaveClick() + assertEquals( + PhotoViewerEffect.Save( + uri = scratchUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + + viewModel.onShareClick() + assertEquals( + PhotoViewerEffect.Share( + uri = scratchUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + + viewModel.onForwardClick() + assertEquals( + PhotoViewerEffect.Forward( + uri = scratchUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun closeAnimationFinished_whenCalledTwice_emitsFinishOnce() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.effects.test { + viewModel.onCloseAnimationFinished() + viewModel.onCloseAnimationFinished() + advanceUntilIdle() + + assertEquals(PhotoViewerEffect.Finish, awaitItem()) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun loadError_setsErrorState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitError() + advanceUntilIdle() + + assertEquals(PhotoViewerLoadState.Error, viewModel.uiState.value.loadState) + assertEquals(0, viewModel.uiState.value.items.size) + assertEquals(0, viewModel.uiState.value.currentPage) + } + } + + @Test + fun emptyResult_setsEmptyState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = emptyList(), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + assertEquals(PhotoViewerLoadState.Empty, viewModel.uiState.value.loadState) + assertEquals(0, viewModel.uiState.value.items.size) + } + } + + @Test + fun errorThenLoadedResult_recoversAndUsesInitialIndex() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitError() + advanceUntilIdle() + + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2, photo3), + initialIndex = 2, + ), + ) + advanceUntilIdle() + + assertEquals(PhotoViewerLoadState.Loaded, viewModel.uiState.value.loadState) + assertEquals(2, viewModel.uiState.value.currentPage) + assertEquals(photo3.contentUri, viewModel.uiState.value.items[2].contentUri) + } + } + + private suspend fun TestScope.loadInitialItems( + repositoryResults: MutableSharedFlow, + viewModel: PhotoViewerViewModel, + ) { + viewModel.onLaunchRequest(launchRequest = launchRequest) + repositoryResults.emitLoaded( + photoViewerItems( + items = listOf(photo1, photo2), + initialIndex = 0, + ), + ) + advanceUntilIdle() + } + + private fun createViewModel(repository: PhotoViewerRepository): PhotoViewerViewModel { + return PhotoViewerViewModel( + photoViewerRepository = repository, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun photoViewerItems( + items: List, + initialIndex: Int, + ): PhotoViewerItems { + return PhotoViewerItems( + items = items.toImmutableList(), + initialIndex = initialIndex, + ) + } + + private fun mockPhotoViewerRepository( + results: MutableSharedFlow, + ): PhotoViewerRepository { + val repository = mockk() + every { + repository.getPhotoViewerItems( + photosUri = any(), + initialPhotoUri = any(), + ) + } returns results + + return repository + } + + private suspend fun MutableSharedFlow.emitLoaded( + items: PhotoViewerItems, + ) { + emit( + PhotoViewerItemsLoadResult.Loaded( + photoViewerItems = items, + ), + ) + } + + private suspend fun MutableSharedFlow.emitError() { + emit(PhotoViewerItemsLoadResult.Error) + } + + private companion object { + const val IMAGE_JPEG = "image/jpeg" + + val launchRequest = PhotoViewerLaunchRequest( + initialPhotoUri = "content://example/content/1", + photosUri = "content://example/photos", + sourceBounds = PhotoViewerSourceBounds(), + ) + val scratchUri: Uri = Uri.parse( + "content://com.android.messaging.debug.datamodel.MediaScratchFileProvider/800001?ext=jpg", + ) + val photo1 = photoViewerItem(index = 1) + val photo2 = photoViewerItem(index = 2) + val photo3 = photoViewerItem(index = 3) + val photo3Refreshed = photo3.copy( + contentUri = Uri.parse("content://example/content/3?updated=true"), + ) + + fun photoViewerItem(index: Int): PhotoViewerItem { + return PhotoViewerItem( + contentUri = Uri.parse("content://example/content/$index"), + contentType = IMAGE_JPEG, + senderName = "Ada Lovelace", + senderDestination = "+1555123000$index", + receivedTimestampMillis = 1_735_689_600_000L + index, + isDraft = false, + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c351889b..af71a46e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,7 @@ androidx-preference = { module = "androidx.preference:preference", version.ref = androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-gif = { module = "io.coil-kt.coil3:coil-gif", 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" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 60d084ede..94f7ff815 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -86,12 +86,6 @@ - - - - - - @@ -531,20 +525,6 @@ - - - - - - - - - - - - - - @@ -559,20 +539,6 @@ - - - - - - - - - - - - - - @@ -7278,6 +7244,20 @@ + + + + + + + + + + + + + + @@ -7289,6 +7269,20 @@ + + + + + + + + + + + + + + @@ -11256,6 +11250,20 @@ + + + + + + + + + + + + + + diff --git a/lib/platform_frameworks_opt_photoviewer b/lib/platform_frameworks_opt_photoviewer deleted file mode 160000 index d48e36941..000000000 --- a/lib/platform_frameworks_opt_photoviewer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d48e36941419ebfd4f6faf77bf3331d810fc06bd diff --git a/res/menu/photo_view_menu.xml b/res/menu/photo_view_menu.xml deleted file mode 100644 index 65920e60e..000000000 --- a/res/menu/photo_view_menu.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - diff --git a/res/values/strings.xml b/res/values/strings.xml index c4a2550d5..c9c5c2445 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -426,6 +426,10 @@ Switch between front and back camera Messaging photos + Image attachment + Couldn\'t load image + No images + Image details %d photo saved to \"%s\" album %d photos saved to \"%s\" album diff --git a/res/values/styles.xml b/res/values/styles.xml index 13f083416..bf71fd16d 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -32,6 +32,15 @@ false + + - - diff --git a/settings.gradle.kts b/settings.gradle.kts index 3d8f30d48..a79b08ad5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,5 +15,4 @@ dependencyResolutionManagement { include(":app") include(":lib:platform_frameworks_opt_chips") -include(":lib:platform_frameworks_opt_photoviewer") include(":lib:platform_frameworks_opt_vcard") diff --git a/src/com/android/messaging/data/media/model/PhotoViewerItem.kt b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt new file mode 100644 index 000000000..f4aba773a --- /dev/null +++ b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt @@ -0,0 +1,23 @@ +package com.android.messaging.data.media.model + +import android.net.Uri +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PhotoViewerItem( + val contentUri: Uri, + val contentType: String, + val senderName: String?, + val senderDestination: String?, + val receivedTimestampMillis: Long, + val isDraft: Boolean, +) { + val title: String + get() { + return senderName + ?.takeIf { it.isNotBlank() } + ?: senderDestination + ?.takeIf { it.isNotBlank() } + .orEmpty() + } +} diff --git a/src/com/android/messaging/data/media/model/PhotoViewerItems.kt b/src/com/android/messaging/data/media/model/PhotoViewerItems.kt new file mode 100644 index 000000000..9d3ffb173 --- /dev/null +++ b/src/com/android/messaging/data/media/model/PhotoViewerItems.kt @@ -0,0 +1,18 @@ +package com.android.messaging.data.media.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class PhotoViewerItems( + val items: ImmutableList, + val initialIndex: Int, +) + +internal sealed interface PhotoViewerItemsLoadResult { + data class Loaded( + val photoViewerItems: PhotoViewerItems, + ) : PhotoViewerItemsLoadResult + + data object Error : PhotoViewerItemsLoadResult +} diff --git a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt new file mode 100644 index 000000000..b25306572 --- /dev/null +++ b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt @@ -0,0 +1,223 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.messaging.data.media.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.database.Cursor +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.os.RemoteException +import androidx.core.database.getStringOrNull +import androidx.core.net.toUri +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.data.media.model.PhotoViewerItems +import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult +import com.android.messaging.datamodel.ConversationImagePartsView +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.di.core.MessagingDbDispatcher +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.typedFlow +import com.android.messaging.util.db.ext.getNonBlankStringOrNull +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface PhotoViewerRepository { + fun getPhotoViewerItems( + photosUri: Uri, + initialPhotoUri: Uri, + ): Flow +} + +internal class PhotoViewerRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:MessagingDbDispatcher + private val messagingDbDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : PhotoViewerRepository { + + override fun getPhotoViewerItems( + photosUri: Uri, + initialPhotoUri: Uri, + ): Flow { + return observeUri(uri = photosUri) + .flowOn(defaultDispatcher) + .flatMapConcat { + loadPhotoViewerItems( + photosUri = photosUri, + initialPhotoUri = initialPhotoUri, + ).recoverPhotoViewerItemsLoadFailure(photosUri = photosUri) + } + .recoverPhotoViewerItemsLoadFailure(photosUri = photosUri) + } + + private fun loadPhotoViewerItems( + photosUri: Uri, + initialPhotoUri: Uri, + ): Flow { + return typedFlow { queryItems(photosUri = photosUri) } + .flowOn(messagingDbDispatcher) + .map { queriedItems -> + val loadResult: PhotoViewerItemsLoadResult = PhotoViewerItemsLoadResult.Loaded( + photoViewerItems = buildPhotoViewerItems( + queriedItems = queriedItems, + initialPhotoUri = initialPhotoUri, + ), + ) + + loadResult + } + .flowOn(defaultDispatcher) + } + + private fun buildPhotoViewerItems( + queriedItems: List, + initialPhotoUri: Uri, + ): PhotoViewerItems { + val items = queriedItems.toImmutableList() + + return PhotoViewerItems( + items = items, + initialIndex = resolveInitialIndex( + items = items, + initialPhotoUri = initialPhotoUri, + ), + ) + } + + 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 queryItems(photosUri: Uri): List { + return contentResolver + .query( + photosUri, + ConversationImagePartsView.PhotoViewQuery.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + buildList(capacity = cursor.count) { + while (cursor.moveToNext()) { + val item = cursor.toPhotoViewerItem() + if (item != null) { + add(item) + } + } + } + } + ?: emptyList() + } + + private fun Cursor.toPhotoViewerItem(): PhotoViewerItem? { + val contentUriString = getNonBlankStringOrNull( + columnIndex = ConversationImagePartsView.PhotoViewQuery.INDEX_CONTENT_URI, + ) + val contentType = getNonBlankStringOrNull( + columnIndex = ConversationImagePartsView.PhotoViewQuery.INDEX_CONTENT_TYPE, + ) + val status = getInt(ConversationImagePartsView.PhotoViewQuery.INDEX_STATUS) + + return when { + contentUriString == null || contentType == null -> null + else -> { + PhotoViewerItem( + contentUri = contentUriString.toUri(), + contentType = contentType, + senderName = getStringOrNull( + ConversationImagePartsView + .PhotoViewQuery + .INDEX_SENDER_FULL_NAME, + ), + senderDestination = getStringOrNull( + ConversationImagePartsView + .PhotoViewQuery + .INDEX_DISPLAY_DESTINATION, + ), + receivedTimestampMillis = getLong( + ConversationImagePartsView + .PhotoViewQuery + .INDEX_RECEIVED_TIMESTAMP, + ), + isDraft = status == MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + ) + } + } + } + + private fun resolveInitialIndex( + items: List, + initialPhotoUri: Uri, + ): Int { + val normalizedInitialPhotoUri = normalizePhotoUri(uri = initialPhotoUri) + val matchIndex = items.indexOfFirst { item -> + normalizePhotoUri(uri = item.contentUri) == normalizedInitialPhotoUri + } + + return matchIndex.coerceAtLeast(minimumValue = 0) + } + + private fun normalizePhotoUri(uri: Uri): String { + return uri + .buildUpon() + .clearQuery() + .fragment(null) + .build() + .toString() + } + + private fun Flow.recoverPhotoViewerItemsLoadFailure( + photosUri: Uri, + ): Flow { + return catch { throwable -> + when { + isRecoverablePhotoViewerItemsLoadFailure(throwable = throwable) -> { + LogUtil.e( + TAG, + "Failed to load photo viewer items for $photosUri", + throwable, + ) + emit(value = PhotoViewerItemsLoadResult.Error) + } + + else -> throw throwable + } + } + } + + private fun isRecoverablePhotoViewerItemsLoadFailure(throwable: Throwable): Boolean { + return throwable is SQLiteException || + throwable is RemoteException || + throwable is SecurityException + } + + private companion object { + const val TAG = "PhotoViewerRepository" + } +} diff --git a/src/com/android/messaging/datamodel/ConversationImagePartsView.java b/src/com/android/messaging/datamodel/ConversationImagePartsView.java index 70ba3815a..102818317 100644 --- a/src/com/android/messaging/datamodel/ConversationImagePartsView.java +++ b/src/com/android/messaging/datamodel/ConversationImagePartsView.java @@ -17,8 +17,7 @@ package com.android.messaging.datamodel; import android.provider.BaseColumns; - -import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns; +import android.provider.OpenableColumns; import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; import com.android.messaging.datamodel.DatabaseHelper.PartColumns; @@ -28,8 +27,7 @@ /** * View for the image parts for the conversation. It is used to provide the photoviewer with a * a data source for all the photos in a conversation, so that the photoviewer can support paging - * through all the photos of the conversation. The columns of the view are a superset of - * {@link com.android.ex.photo.provider.PhotoContract.PhotoViewColumns}. + * through all the photos of the conversation. */ public class ConversationImagePartsView { private static final String VIEW_NAME = "conversation_image_parts_view"; @@ -49,7 +47,7 @@ public class ConversationImagePartsView { + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE + " as " + Columns.CONTENT_TYPE + ", " // - // Columns in addition to those specified by PhotoContract + // Columns in addition to the base photo viewer columns // + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + " as " + Columns.DISPLAY_DESTINATION + ", " @@ -75,12 +73,12 @@ public class ConversationImagePartsView { static class Columns implements BaseColumns { static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID; - static final String URI = PhotoViewColumns.URI; - static final String SENDER_FULL_NAME = PhotoViewColumns.NAME; - static final String CONTENT_URI = PhotoViewColumns.CONTENT_URI; - static final String THUMBNAIL_URI = PhotoViewColumns.THUMBNAIL_URI; - static final String CONTENT_TYPE = PhotoViewColumns.CONTENT_TYPE; - // Columns in addition to those specified by PhotoContract + static final String URI = "uri"; + static final String SENDER_FULL_NAME = OpenableColumns.DISPLAY_NAME; + static final String CONTENT_URI = "contentUri"; + static final String THUMBNAIL_URI = "thumbnailUri"; + static final String CONTENT_TYPE = "contentType"; + // Columns in addition to the base photo viewer columns static final String DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION; static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP; static final String STATUS = MessageColumns.STATUS; @@ -88,12 +86,12 @@ static class Columns implements BaseColumns { public interface PhotoViewQuery { public final String[] PROJECTION = { - PhotoViewColumns.URI, - PhotoViewColumns.NAME, - PhotoViewColumns.CONTENT_URI, - PhotoViewColumns.THUMBNAIL_URI, - PhotoViewColumns.CONTENT_TYPE, - // Columns in addition to those specified by PhotoContract + Columns.URI, + Columns.SENDER_FULL_NAME, + Columns.CONTENT_URI, + Columns.THUMBNAIL_URI, + Columns.CONTENT_TYPE, + // Columns in addition to the base photo viewer columns Columns.DISPLAY_DESTINATION, Columns.RECEIVED_TIMESTAMP, Columns.STATUS, @@ -104,7 +102,7 @@ public interface PhotoViewQuery { public final int INDEX_CONTENT_URI = 2; public final int INDEX_THUMBNAIL_URI = 3; public final int INDEX_CONTENT_TYPE = 4; - // Columns in addition to those specified by PhotoContract + // Columns in addition to the base photo viewer columns public final int INDEX_DISPLAY_DESTINATION = 5; public final int INDEX_RECEIVED_TIMESTAMP = 6; public final int INDEX_STATUS = 7; diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index 3dc7b2a49..6a0efc779 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -37,6 +37,7 @@ import java.io.ByteArrayOutputStream import java.io.DataOutputStream import java.io.File import java.io.FileOutputStream +import java.util.Base64 import kotlin.math.PI import kotlin.math.sin import kotlinx.coroutines.flow.first @@ -54,9 +55,12 @@ 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_ANIMATED_GIF_FILE_ID = "800008" 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 SEED_ANIMATED_GIF_WIDTH = 96 +private const val SEED_ANIMATED_GIF_HEIGHT = 96 private const val MINUTES = 60 * 1000L private const val HOURS = 60 * MINUTES @@ -86,6 +90,7 @@ fun seedTestData(context: Context) { val (simAId, simBId) = resolveDualSimSelfIds(context = context) val testImages = buildTestImages(context) + val testAnimatedGif = buildTestAnimatedGif() val testAudio = buildTestAudio() val testVideo = buildTestVideo(context) val testVCards = buildTestVCards() @@ -122,6 +127,7 @@ fun seedTestData(context: Context) { jackId = jack, carolId = carol, images = testImages, + animatedGifUri = testAnimatedGif, audioUri = testAudio, videoUri = testVideo, vCards = testVCards, @@ -261,6 +267,7 @@ fun clearSeededTestData(context: Context) { 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_ANIMATED_GIF_FILE_ID, fileExtension = "gif") deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID) deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID) deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID) @@ -268,6 +275,7 @@ fun clearSeededTestData(context: Context) { deleteSeedScratchFile(fileId = SEED_LOCATION_VCARD_FILE_ID) deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID) deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID) + deleteSeedScratchFile(fileId = SEED_ANIMATED_GIF_FILE_ID) deleteSeededAttachmentScratchFiles(attachmentUris = seededAttachmentUris) MessagingContentProvider.notifyConversationListChanged() @@ -321,6 +329,77 @@ private fun buildTestImages(context: Context): List { } } +private fun buildTestAnimatedGif(): String { + val animatedGifUri = buildSeedScratchUri( + fileId = SEED_ANIMATED_GIF_FILE_ID, + fileExtension = "gif", + ) + val file = MediaScratchFileProvider.getFileFromUri(animatedGifUri) + file.parentFile?.mkdirs() + file.writeBytes(seedAnimatedGifBytes()) + MediaScratchFileProvider.addUriToDisplayNameEntry(animatedGifUri, "seed_animation.gif") + return animatedGifUri.toString() +} + +private fun seedAnimatedGifBytes(): ByteArray { + return Base64.getMimeDecoder().decode( + """ + R0lGODlhYABgAPZtAEFp4UJq4UNr4UVs4kVt4khv4kpw4ktx4050409041F141J35FR45Ft+5Vx+ + 5V1/5WCC5mGC5mOE5mSF52aG52eH52iI53CO6HOQ6XSR6XSS6XmV6nqW6n2Y6n6Z64Ca64Kc64Od + 64Se7Iag7Ieg7Iih7Imh7Iqj7Yyk7Y6m7ZGp7pet75iu752y8J6z8KC08KG18Ka48ae68am78aq8 + 8qu98qy+8rDB87HB87LC87TE87nI9LvJ9L7M9cHO9cLP9cPQ9sXR9sfT9sjT9snV98zW98zX983Y + 987Y99Da+NHb+NLc+NTd+NXd+Nbf+dff+djg+dnh+dri+dvj+d3k+t3l+t7l+uDm+uLo+uPp++Xq + ++br++bs++jt++nt++nu/Orv/O/y/PDz/fL0/fL1/fX3/fb4/vj5/vn6/vr7/vv7/vv8/v7+//// + /wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/ + C05FVFNDQVBFMi4wAwEAAAAh+QQAIwAAACwAAAAAYABgAAAH/4AAgoOEhYaHiImKi4yNjo+QkZKT + lJWWl5iZmpucnZ6foKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr/AwcLDxMXGx8jJysvM + zc7P0NHS09TV1tfY2drBDjVXZWhWMgyCRG3nTwBR5+znPcYmaG1gRk9mbWOCMlltTysAOcC0OdKj + x5A27wYJPIeAV4Y2ZUAMOrCkTQFBPNpgGHSkTQRBDBASerIOTS9+Hgp9gEFAkJI2DgbxOzDIBIVC + CNp04UWhjZZFXCwOQpNPUYR+vES0+TEIRrscgogOytmujRJDD4HwStFGSM0eRtqESbmgjZVBPcHw + WMtDhaERbYig7rqgsxBcpgB6ehW0oY2ORSzatOBFoIvgqfxsCPIQdxBXGItqtEnRK4O8KDl4jLnH + AkCLdVcgz7DSJkoPCYdqRBHjM0oDXhaEjFmjZUZfiaTPMWXN7qahJ1UVbBtOvLjx48iTK1/OvLnz + 59CjS59Ovbr169iza9/Ovbv37+DDix9Pvrx5ToEAACH5BAAjAAAALAAAAABgAGAAhi59Mi9+MzB+ + NDB/NDKANjOANzWBODaCOjeDOziDPDmEPD2GQD6HQT6HQkGJREKKRkOKR0SLSEWLSUaMSUuPTkyP + T02QUVCSVFKTVVSUV1WVWFaWWVeWWliXW1mYXFuZXl+cYmKdZWWfaGagaWegaWujbWujbm2kcHCm + cnGndHOodnWpeHeqenirenmre3utfnytfn2uf36vgYCwgoOyhoe0iY24j465kJG7k5K7lJS8lpS9 + lpa+mJi/mpnAm5vBnZ3Cn57DoKDEoqLFpKTGpqjJqqnJq6rKq63Mr7HPs7LPs7TRtrjTubvVvLzV + vb/XwMDYwcLZw8LZxMTaxcbcyMfcyMjdycndysvezMvfzMzfzc7gz8/h0NPj1NTk1NXl1tbm19jn + 2Njn2dno2tro29vp3N3q3d/r4ODs4OLt4+Pu5OTu5Obv5ubw5+fw6Ojx6Ozz7e307e/17/D28fH2 + 8fT49fX59ff69/j6+Pj7+fn7+fr8+vv8+/7+/v///wAAAAf/gACCg4SFhoeIiYqLjI2Oj5CRkpOU + lZaXmJmam5ydnp+goaKjpKWmp6ipqqusra6vsLGys7S1tre4ubq7vL2+v8DBwsPExcbHyMnKy8zN + zs/Q0dLT1NXW19jZ2r8VQ2Z3d2VBEIJTfuddAGHn7OdMxjF4fm9VXXd+cwMAQGh+XDYAjrjxY4UJ + Eyl+3g2i8CMMHjxdQOga4ecOi0EKtvg5IGiJHxGDrPi5IAhCQkJJ8JWRB4cjrjN+UhRS0aOAIC1+ + LAzql0DQgBgbCKnhom+CPA+4NvhRs2jNxkF35iQagKOEoAL3JOBq4cfJIB7tjgjCI1XQgnbnsiTK + cRLXDD9RuQbJYELFjxwVAEyGGaTUzZK/S2ocKhDETxYFuUL4WaNv0As/TQR1gDuohB8kiy6E4eNj + wQWXtgq08bNj0IJ+RASh8CNWEA0/PBIV2MEnik6uEnGNkAfmSJI5924A2AHGj5nYQsj4AcMkw6Eo + FcFIf+NHay4PT+bwQSPkhJ+LZdh5ldMuqKEuaP3wabytvfv38OPLn0+/vv37+PPr38+/v///AAYo + 4IAEFmjggQgmqOCCDDbo4IMQahIIACH5BAAjAAAALAAAAABgAGAAh9gbYNgcYdgeYtkfY9kgZNkh + ZNkiZdokZtolZ9omZ9onaNsra9ssbNwwbtwwb9wxcNwycNw1ct02c905dd47dt48d949eN5Bet9E + fd9Ffd9Hf+BIf+BJgOFOhOFQheJTh+JVieJXiuJYiuNZi+NajONej+RgkORhkeRjkuVkk+VmlOVp + luZrl+ZrmOZvm+dwm+dxnOdzned0nud1n+h4oel8o+mApuqDqOqEqeuKreuLruuNr+yOsO2UtO2V + te2Xt+6aue6cuu+gve+hvu+ivvCmwfCnwvCow/GrxPGuxvGvx/GvyPKxyfK1y/K2zPO4zvO5zvS8 + 0PS90fS+0fS/0vTA0/TB1PXC1fXD1fXE1vXF1vXH2PbI2PbJ2vbL2/bM2/fP3ffQ3vfR3/fS4PjU + 4fjV4vjW4vjX4/jY5PjZ5PnZ5fnc5/nd5/rg6frh6vrj7Prk7Pvl7fvm7vvq8Pzr8fzt8/zv9Pzw + 9P3x9f3y9v3z9/30+P31+P32+f73+f74+v75+/77/P/9/v/+/v///wAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAj/AAEIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDNq3Mixo8ePIEOKHEmypMmT + KFOqXMmypcuXMGPKnEmzps2bOHPq3Mmzp8+fQIMKHUq0qNGjSJMqXcq0qdOnUKNKnUq1qtWrWLNq + /UkBCBo9fdD4gCCQCqGzYACQOcv2LBOjLwARknMFTB9CdgQA8MGGUJcbAIjIIZSFCROzbwcyuPHF + jh4qGnSKIKSHxUAFXQgdEJiEUIiBVwhdEAiBUGKBOwiFvTtnM86+KQqq4EFAYGYLA/u6FgAjMug3 + tSPc/YBzAyE3C91oHthHj0IdMwYO9m2zBSEnA1OzJQJAACA7Axe01z27JWEIQmT03pxBiMpAGExC + 01EBoAH6gRrmJtmfpMbBE0kAckUEOX1AyBvqCeQCIU0IxEF7A5VASBEMdUYIFBTkJMBgOijWVxAC + nUAIdwLRQMgODAlQQRGEoCGZXGEQgYQdd+EAgA5htIiiD2uNwQR1CRFwFlk5cQCFHoG44YOIlqHB + FnZ1tAXke0cMFMFZDVxVBSE2KBCBWS5eRcJ4enSQFQdFbJGFDwRu5eabcMYp55x01mnnnXjmqeee + fPbp55+ABirooIQWauihiCaq6KKMNupoogEBADs= + """.trimIndent(), + ) +} + private fun buildTestVCards(): SeedVCards { val contactVCardUri = buildSeedScratchUri( fileId = SEED_CONTACT_VCARD_FILE_ID, @@ -643,6 +722,29 @@ private fun insertImageMessage( ) } +private fun insertGifMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + gifUri: String, + status: Int, + timestamp: Long, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.IMAGE_GIF, + attachmentUri = gifUri, + status = status, + timestamp = timestamp, + width = SEED_ANIMATED_GIF_WIDTH, + height = SEED_ANIMATED_GIF_HEIGHT, + ) +} + private fun insertMixedMessage( db: DatabaseWrapper, conversationId: Long, @@ -1314,6 +1416,7 @@ private fun seedScenarioH( jackId: String, carolId: String, images: List, + animatedGifUri: String, audioUri: String, videoUri: String, vCards: SeedVCards, @@ -1360,6 +1463,7 @@ private fun seedScenarioH( 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("gif", attachmentUri = animatedGifUri, 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), @@ -1393,6 +1497,16 @@ private fun seedScenarioH( msgTime, ) + "gif" -> insertGifMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + gifUri = m.attachmentUri, + status = status, + timestamp = msgTime, + ) + "mixed" -> insertMixedMessage( db, convId, diff --git a/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt new file mode 100644 index 000000000..1dc71b4b5 --- /dev/null +++ b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt @@ -0,0 +1,20 @@ +package com.android.messaging.di.photoviewer + +import com.android.messaging.data.media.repository.PhotoViewerRepository +import com.android.messaging.data.media.repository.PhotoViewerRepositoryImpl +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 PhotoViewerBindsModule { + + @Binds + @Reusable + abstract fun bindPhotoViewerRepository( + impl: PhotoViewerRepositoryImpl, + ): PhotoViewerRepository +} diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java index 48137b077..a23160e55 100644 --- a/src/com/android/messaging/ui/UIIntentsImpl.java +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -35,9 +35,7 @@ import android.provider.Telephony; import android.text.TextUtils; -import com.android.ex.photo.Intents.PhotoViewIntentBuilder; import com.android.messaging.R; -import com.android.messaging.datamodel.ConversationImagePartsView; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.data.MessageData; @@ -58,7 +56,7 @@ import com.android.messaging.ui.conversationsettings.ConversationSettingsActivity; import com.android.messaging.ui.debug.DebugMmsConfigActivity; import com.android.messaging.ui.permissioncheck.PermissionCheckActivity; -import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity; +import com.android.messaging.ui.photoviewer.PhotoViewerActivity; import com.android.messaging.ui.vcarddetail.VCardDetailActivity; import com.android.messaging.util.Assert; import com.android.messaging.util.ContentType; @@ -304,21 +302,8 @@ public void launchFullScreenVideoViewer(final Context context, final Uri videoUr @Override public void launchFullScreenPhotoViewer(final Activity activity, final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) { - final PhotoViewIntentBuilder builder = - com.android.ex.photo.Intents.newPhotoViewIntentBuilder( - activity, BuglePhotoViewActivity.class); - builder.setPhotosUri(photosUri.toString()); - builder.setInitialPhotoUri(initialPhoto.toString()); - builder.setProjection(ConversationImagePartsView.PhotoViewQuery.PROJECTION); - - // Set the location of the imageView so that the photoviewer can animate from that location - // to full screen. - builder.setScaleAnimation(initialPhotoBounds.left, initialPhotoBounds.top, - initialPhotoBounds.width(), initialPhotoBounds.height()); - - builder.setDisplayThumbsFullScreen(false); - builder.setMaxInitialScale(8); - activity.startActivity(builder.build()); + activity.startActivity(PhotoViewerActivity.createIntent( + activity, initialPhoto, photosUri, initialPhotoBounds)); activity.overridePendingTransition(0, 0); } diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java deleted file mode 100644 index 7e484a315..000000000 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java +++ /dev/null @@ -1,191 +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.photoviewer; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import androidx.loader.content.AsyncTaskLoader; - -import com.android.ex.photo.PhotoViewController; -import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface; -import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; -import com.android.messaging.datamodel.media.ImageRequestDescriptor; -import com.android.messaging.datamodel.media.ImageResource; -import com.android.messaging.datamodel.media.MediaRequest; -import com.android.messaging.datamodel.media.MediaResourceManager; -import com.android.messaging.datamodel.media.UriImageRequestDescriptor; -import com.android.messaging.util.ImageUtils; -import com.bumptech.glide.load.resource.gif.GifDrawable; - -/** - * Loader for the bitmap of a photo. - */ -public class BuglePhotoBitmapLoader extends AsyncTaskLoader - implements PhotoBitmapLoaderInterface { - private String mPhotoUri; - private ImageResource mImageResource; - // The drawable that is currently "in use" and being presented to the user. This drawable - // should never exist without the image resource backing it. - private Drawable mDrawable; - - public BuglePhotoBitmapLoader(Context context, String photoUri) { - super(context); - mPhotoUri = photoUri; - } - - @Override - public void setPhotoUri(String photoUri) { - mPhotoUri = photoUri; - } - - @Override - public BitmapResult loadInBackground() { - final BitmapResult result = new BitmapResult(); - final Context context = getContext(); - if (context != null && mPhotoUri != null) { - final ImageRequestDescriptor descriptor = - new UriImageRequestDescriptor(Uri.parse(mPhotoUri), - PhotoViewController.sMaxPhotoSize, PhotoViewController.sMaxPhotoSize, - true /* allowCompression */, false /* isStatic */, - false /* cropToCircle */, - ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, - ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); - final MediaRequest imageRequest = - descriptor.buildSyncMediaRequest(context); - final ImageResource imageResource = - MediaResourceManager.get().requestMediaResourceSync(imageRequest); - if (imageResource != null) { - setImageResource(imageResource); - result.status = BitmapResult.STATUS_SUCCESS; - result.drawable = mImageResource.getDrawable(context.getResources()); - } else { - releaseImageResource(); - result.status = BitmapResult.STATUS_EXCEPTION; - } - } else { - result.status = BitmapResult.STATUS_EXCEPTION; - } - return result; - } - - /** - * Called when there is new data to deliver to the client. The super class will take care of - * delivering it; the implementation here just adds a little more logic. - */ - @Override - public void deliverResult(BitmapResult result) { - final Drawable drawable = result != null ? result.drawable : null; - if (isReset()) { - // An async query came in while the loader is stopped. We don't need the result. - releaseDrawable(drawable); - return; - } - - // We are now going to display this drawable so set to mDrawable - mDrawable = drawable; - - if (isStarted()) { - // If the Loader is currently started, we can immediately deliver its results. - super.deliverResult(result); - } - } - - /** - * Handles a request to start the Loader. - */ - @Override - protected void onStartLoading() { - if (mDrawable != null) { - // If we currently have a result available, deliver it - // immediately. - final BitmapResult result = new BitmapResult(); - result.status = BitmapResult.STATUS_SUCCESS; - result.drawable = mDrawable; - deliverResult(result); - } - - if (takeContentChanged() || (mImageResource == null)) { - // If the data has changed since the last time it was loaded - // or is not currently available, start a load. - forceLoad(); - } - } - - /** - * Handles a request to stop the Loader. - */ - @Override - protected void onStopLoading() { - // Attempt to cancel the current load task if possible. - cancelLoad(); - } - - /** - * Handles a request to cancel a load. - */ - @Override - public void onCanceled(BitmapResult result) { - super.onCanceled(result); - - // At this point we can release the resources associated with 'drawable' if needed. - if (result != null) { - releaseDrawable(result.drawable); - } - } - - /** - * Handles a request to completely reset the Loader. - */ - @Override - protected void onReset() { - super.onReset(); - - // Ensure the loader is stopped - onStopLoading(); - - releaseImageResource(); - } - - private void releaseDrawable(Drawable drawable) { - if (drawable != null && drawable instanceof GifDrawable) { - ((GifDrawable) drawable).recycle(); - } - - } - - private void setImageResource(final ImageResource resource) { - if (mImageResource != resource) { - // Clear out any information for what is currently used - releaseImageResource(); - mImageResource = resource; - // No need to add ref since a ref is already reserved as a result of - // requestMediaResourceSync. - } - } - - private void releaseImageResource() { - // If we are getting rid of the imageResource backing the drawable, we must also - // destroy the drawable before releasing it. - releaseDrawable(mDrawable); - mDrawable = null; - - if (mImageResource != null) { - mImageResource.release(); - } - mImageResource = null; - } -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java deleted file mode 100644 index c19070bf5..000000000 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java +++ /dev/null @@ -1,38 +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.photoviewer; - -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import androidx.fragment.app.FragmentManager; - -import com.android.ex.photo.adapters.PhotoPagerAdapter; -import com.android.ex.photo.fragments.PhotoViewFragment; - -public class BuglePhotoPageAdapter extends PhotoPagerAdapter { - - public BuglePhotoPageAdapter(Context context, FragmentManager fm, Cursor c, float maxScale, - boolean thumbsFullScreen) { - super(context, fm, c, maxScale, thumbsFullScreen); - } - - @Override - protected PhotoViewFragment createPhotoViewFragment(Intent intent, int position, - boolean onlyShowSpinner) { - return BuglePhotoViewFragment.newInstance(intent, position, onlyShowSpinner); - } -} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java deleted file mode 100644 index 1924a9627..000000000 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java +++ /dev/null @@ -1,31 +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.photoviewer; - -import com.android.ex.photo.PhotoViewActivity; -import com.android.ex.photo.PhotoViewController; - -/** - * Activity to display the conversation images in full-screen. Most of the customization is in - * {@link BuglePhotoViewController}. - */ -public class BuglePhotoViewActivity extends PhotoViewActivity { - @Override - public PhotoViewController createController() { - return new BuglePhotoViewController(this); - } -} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java deleted file mode 100644 index 6f8f73d4b..000000000 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java +++ /dev/null @@ -1,208 +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.photoviewer; - -import android.app.Activity; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.provider.BaseColumns; -import android.provider.MediaStore; -import android.text.TextUtils; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.widget.Toast; - -import com.android.ex.photo.PhotoViewController; -import com.android.ex.photo.adapters.PhotoPagerAdapter; -import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; -import com.android.messaging.R; -import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery; -import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.ui.AttachmentSaveTask; -import com.android.messaging.util.Dates; -import com.android.messaging.util.LogUtil; - -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentManager; -import androidx.loader.content.Loader; - -/** - * Customizations for the photoviewer to display conversation images in full screen. - */ -public class BuglePhotoViewController extends PhotoViewController { - private static final String TAG = "BuglePhotoViewController"; - - private MenuItem mShareItem; - private MenuItem mSaveItem; - - public BuglePhotoViewController(final ActivityInterface activity) { - super(activity); - } - - @Override - public Loader onCreateBitmapLoader( - final int id, final Bundle args, final String uri) { - switch (id) { - case BITMAP_LOADER_AVATAR: - case BITMAP_LOADER_THUMBNAIL: - case BITMAP_LOADER_PHOTO: - return new BuglePhotoBitmapLoader(getActivity().getContext(), uri); - default: - LogUtil.e(LogUtil.BUGLE_TAG, - "Photoviewer unable to open bitmap loader with unknown id: " + id); - return null; - } - } - - @Override - public void updateActionBar() { - final Cursor cursor = getCursorAtProperPosition(); - - if (mSaveItem == null || cursor == null) { - // Load not finished, called from framework code before ready - return; - } - // Show the name as the title - mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_SENDER_FULL_NAME); - if (TextUtils.isEmpty(mActionBarTitle)) { - // If the name is not known, fall back to the phone number - mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_DISPLAY_DESTINATION); - } - - // Show the timestamp as the subtitle - final long receivedTimestamp = cursor.getLong(PhotoViewQuery.INDEX_RECEIVED_TIMESTAMP); - mActionBarSubtitle = Dates.getMessageTimeString(receivedTimestamp).toString(); - - setActionBarTitles(getActivity().getActionBarInterface()); - mSaveItem.setVisible(!isTempFile()); - - updateShareItem(); - } - - private void updateShareItem() { - final PhotoPagerAdapter adapter = getAdapter(); - final Cursor cursor = getCursorAtProperPosition(); - if (mShareItem == null || adapter == null || cursor == null) { - // Not enough stuff loaded to update the share action - return; - } - mShareItem.setVisible(!isTempFile()); - } - - /** - * Checks whether the current photo is a temp file. A temp file can be deleted at any time, so - * we need to disable share and save options because the file may no longer be there. - */ - private boolean isTempFile() { - final Cursor cursor = getCursorAtProperPosition(); - final Uri photoUri = Uri.parse(getAdapter().getPhotoUri(cursor)); - return MediaScratchFileProvider.isMediaScratchSpaceUri(photoUri); - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - ((Activity) getActivity()).getMenuInflater().inflate(R.menu.photo_view_menu, menu); - - mShareItem = menu.findItem(R.id.action_share); - updateShareItem(); - - mSaveItem = menu.findItem(R.id.action_save); - return true; - } - - @Override - public boolean onPrepareOptionsMenu(final Menu menu) { - return !mIsEmpty; - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.action_save) { - final PhotoPagerAdapter adapter = getAdapter(); - final Cursor cursor = getCursorAtProperPosition(); - if (cursor == null) { - final Context context = getActivity().getContext(); - final String error = context.getResources().getQuantityString( - R.plurals.attachment_save_error, 1, 1); - Toast.makeText(context, error, Toast.LENGTH_SHORT).show(); - return true; - } - final String photoUri = adapter.getPhotoUri(cursor); - new AttachmentSaveTask(((Activity) getActivity()), - Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool(); - return true; - } else if (itemId == R.id.action_share) { - handleShare(); - return true; - } else { - return super.onOptionsItemSelected(item); - } - } - - private void handleShare() { - PhotoPagerAdapter adapter = getAdapter(); - Cursor cursor = getCursorAtProperPosition(); - String photoUri = adapter.getPhotoUri(cursor); - String contentType = adapter.getContentType(cursor); - - Uri uri = Uri.parse(photoUri); - if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { - Uri contentUri = fileUriToContentUri(getActivity().getContext(), uri); - if (contentUri == null) { - Log.e(TAG, "fileUriToContentUri failed for " + uri); - return; - } - Log.d(TAG, "converted " + uri + " to " + contentUri); - uri = contentUri; - } - var intent = new Intent(Intent.ACTION_SEND); - intent.setType(contentType); - intent.putExtra(Intent.EXTRA_STREAM, uri); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - ((Activity) getActivity()).startActivity(Intent.createChooser(intent, null)); - } - - @Nullable - private static Uri fileUriToContentUri(Context context, Uri uri) { - String path = uri.getPath(); - ContentResolver resolver = context.getContentResolver(); - String[] projection = { BaseColumns._ID }; - String selection = MediaStore.MediaColumns.DATA + "=?"; - String[] selectionArgs = { path }; - String volume = MediaStore.VOLUME_EXTERNAL; - Uri volumeUri = MediaStore.Files.getContentUri(volume); - try (Cursor c = resolver.query(volumeUri, projection, selection, selectionArgs, null)) { - if (c == null || !c.moveToFirst()) { - return null; - } - long id = c.getLong(0); - return MediaStore.Files.getContentUri(volume, id); - } - } - - @Override - public PhotoPagerAdapter createPhotoPagerAdapter(final Context context, - final FragmentManager fm, final Cursor c, final float maxScale) { - return new BuglePhotoPageAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen); - } -} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java deleted file mode 100644 index de3673822..000000000 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java +++ /dev/null @@ -1,89 +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.photoviewer; - -import android.content.Intent; -import android.graphics.drawable.Drawable; -import androidx.loader.content.Loader; - -import com.android.ex.photo.PhotoViewCallbacks; -import com.android.ex.photo.fragments.PhotoViewFragment; -import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; -import com.bumptech.glide.load.resource.gif.GifDrawable; - -public class BuglePhotoViewFragment extends PhotoViewFragment { - - /** Public no-arg constructor for allowing the framework to handle orientation changes */ - public BuglePhotoViewFragment() { - // Do nothing. - } - - public static PhotoViewFragment newInstance(Intent intent, int position, - boolean onlyShowSpinner) { - final PhotoViewFragment f = new BuglePhotoViewFragment(); - initializeArguments(intent, position, onlyShowSpinner, f); - return f; - } - - @Override - public void onLoadFinished(Loader loader, BitmapResult result) { - super.onLoadFinished(loader, result); - // Need to check for the first time when we load the photos - if (PhotoViewCallbacks.BITMAP_LOADER_PHOTO == loader.getId() - && result.status == BitmapResult.STATUS_SUCCESS - && mCallback.isFragmentActive(this)) { - startGif(); - } - } - - @Override - public void onResume() { - super.onResume(); - startGif(); - } - - @Override - public void onPause() { - stopGif(); - super.onPause(); - } - - @Override - public void onViewActivated() { - super.onViewActivated(); - startGif(); - } - - @Override - public void resetViews() { - super.resetViews(); - stopGif(); - } - - private void stopGif() { - final Drawable drawable = getDrawable(); - if (drawable != null && drawable instanceof GifDrawable) { - ((GifDrawable) drawable).stop(); - } - } - - private void startGif() { - final Drawable drawable = getDrawable(); - if (drawable != null && drawable instanceof GifDrawable) { - ((GifDrawable) drawable).start(); - } - } -} diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt new file mode 100644 index 000000000..4bc0cbd4e --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt @@ -0,0 +1,104 @@ +package com.android.messaging.ui.photoviewer + +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.android.messaging.ui.core.AppTheme +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds +import com.android.messaging.ui.photoviewer.screen.PhotoViewerScreen +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class PhotoViewerActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + val launchRequest = toPhotoViewerLaunchRequest(intent = intent) ?: run { + finish() + return + } + + setContent { + AppTheme { + PhotoViewerScreen( + launchRequest = launchRequest, + onFinish = ::finishAfterTransition, + ) + } + } + } + + private fun toPhotoViewerLaunchRequest(intent: Intent): PhotoViewerLaunchRequest? { + val initialPhotoUri = intent.getParcelableExtra( + EXTRA_INITIAL_PHOTO_URI, + Uri::class.java, + ) + val photosUri = intent.getParcelableExtra( + EXTRA_PHOTOS_URI, + Uri::class.java, + ) + + if (initialPhotoUri == null || photosUri == null) { + return null + } + + return PhotoViewerLaunchRequest( + initialPhotoUri = initialPhotoUri.toString(), + photosUri = photosUri.toString(), + sourceBounds = sourceBoundsFromIntent(intent = intent), + ) + } + + private fun sourceBoundsFromIntent(intent: Intent): PhotoViewerSourceBounds { + val left = intent.getIntExtra(EXTRA_START_BOUNDS_LEFT, 0) + val top = intent.getIntExtra(EXTRA_START_BOUNDS_TOP, 0) + + return PhotoViewerSourceBounds( + left = left, + top = top, + right = left + intent.getIntExtra(EXTRA_START_BOUNDS_WIDTH, 0), + bottom = top + intent.getIntExtra(EXTRA_START_BOUNDS_HEIGHT, 0), + ) + } + + companion object { + private const val EXTRA_INITIAL_PHOTO_URI = + "com.android.messaging.photoviewer.extra.INITIAL_PHOTO_URI" + private const val EXTRA_PHOTOS_URI = + "com.android.messaging.photoviewer.extra.PHOTOS_URI" + private const val EXTRA_START_BOUNDS_LEFT = + "com.android.messaging.photoviewer.extra.START_BOUNDS_LEFT" + private const val EXTRA_START_BOUNDS_TOP = + "com.android.messaging.photoviewer.extra.START_BOUNDS_TOP" + private const val EXTRA_START_BOUNDS_WIDTH = + "com.android.messaging.photoviewer.extra.START_BOUNDS_WIDTH" + private const val EXTRA_START_BOUNDS_HEIGHT = + "com.android.messaging.photoviewer.extra.START_BOUNDS_HEIGHT" + + @JvmStatic + fun createIntent( + context: Context, + initialPhotoUri: Uri, + photosUri: Uri, + sourceBounds: Rect, + ): Intent { + return Intent(context, PhotoViewerActivity::class.java).apply { + putExtra(EXTRA_INITIAL_PHOTO_URI, initialPhotoUri) + putExtra(EXTRA_PHOTOS_URI, photosUri) + putExtra(EXTRA_START_BOUNDS_LEFT, sourceBounds.left) + putExtra(EXTRA_START_BOUNDS_TOP, sourceBounds.top) + putExtra(EXTRA_START_BOUNDS_WIDTH, sourceBounds.width()) + putExtra(EXTRA_START_BOUNDS_HEIGHT, sourceBounds.height()) + } + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt new file mode 100644 index 000000000..ff5b65ea4 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.photoviewer + +internal const val PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG = "photo_viewer_close_button" +internal const val PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG = "photo_viewer_details_menu_item" +internal const val PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG = "photo_viewer_forward_menu_item" +internal const val PHOTO_VIEWER_METADATA_SHEET_TEST_TAG = "photo_viewer_metadata_sheet" +internal const val PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG = "photo_viewer_overflow_button" +internal const val PHOTO_VIEWER_PAGER_TEST_TAG = "photo_viewer_pager" +internal const val PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG = "photo_viewer_save_button" +internal const val PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG = "photo_viewer_share_button" +internal const val PHOTO_VIEWER_TITLE_TEST_TAG = "photo_viewer_title" +internal const val PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG = "photo_viewer_zoomable_photo" diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerContentKey.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerContentKey.kt new file mode 100644 index 000000000..c134e4909 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerContentKey.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PhotoViewerContentKey( + val page: Int, + val contentUri: String, +) diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSize.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSize.kt new file mode 100644 index 000000000..960c11a68 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDecodeSize.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.ui.unit.IntSize + +private const val PHOTO_VIEWER_DECODE_SIZE_NUMERATOR = 3L +private const val PHOTO_VIEWER_DECODE_SIZE_DENOMINATOR = 2L +private const val PHOTO_VIEWER_MAX_DECODE_SIZE_PX = 4096L + +internal fun resolvePhotoViewerDecodeSize(displayedImageSize: IntSize): IntSize { + return IntSize( + width = resolvePhotoViewerDecodeDimension(size = displayedImageSize.width), + height = resolvePhotoViewerDecodeDimension(size = displayedImageSize.height), + ) +} + +private fun resolvePhotoViewerDecodeDimension(size: Int): Int { + return ceilScaledDecodeDimension(size = size.toLong()) + .coerceIn( + minimumValue = 1L, + maximumValue = PHOTO_VIEWER_MAX_DECODE_SIZE_PX, + ) + .toInt() +} + +private fun ceilScaledDecodeDimension(size: Long): Long { + return (size * PHOTO_VIEWER_DECODE_SIZE_NUMERATOR + PHOTO_VIEWER_DECODE_SIZE_DENOMINATOR - 1L) / + PHOTO_VIEWER_DECODE_SIZE_DENOMINATOR +} diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragState.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragState.kt new file mode 100644 index 000000000..b7c96bd4e --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerDismissDragState.kt @@ -0,0 +1,131 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequestKey + +private const val PHOTO_VIEWER_BACKGROUND_DISMISS_TRANSPARENT_PROGRESS = 0.5f +private const val PHOTO_VIEWER_TOP_BAR_DISMISS_HIDE_PROGRESS = 0.1f +private const val PHOTO_VIEWER_DISMISS_CLOSE_PROGRESS = 0.5f +private const val PHOTO_VIEWER_DISMISS_RETURN_ANIMATION_MILLIS = 200 +private const val PHOTO_VIEWER_IMAGE_DISMISS_MIN_ALPHA = 0.4f + +private val PhotoViewerDismissDragThreshold = 180.dp + +@Composable +internal fun rememberPhotoViewerDismissDragState( + resetKey: PhotoViewerLaunchRequestKey, +): PhotoViewerDismissDragState { + val swipeDismissThreshold = with(LocalDensity.current) { + PhotoViewerDismissDragThreshold.toPx() + } + val dismissDragOffsetState = remember(resetKey, swipeDismissThreshold) { + mutableFloatStateOf(value = 0f) + } + val isActiveState = remember(resetKey, swipeDismissThreshold) { + mutableStateOf(value = false) + } + val animatedDismissDragOffsetState = animateFloatAsState( + targetValue = dismissDragOffsetState.floatValue, + animationSpec = when { + isActiveState.value -> snap() + else -> tween(durationMillis = PHOTO_VIEWER_DISMISS_RETURN_ANIMATION_MILLIS) + }, + label = "photoViewerDismissDragOffset", + ) + + return remember( + swipeDismissThreshold, + dismissDragOffsetState, + isActiveState, + animatedDismissDragOffsetState, + ) { + PhotoViewerDismissDragState( + swipeDismissThreshold = swipeDismissThreshold, + dismissDragOffsetState = dismissDragOffsetState, + isActiveState = isActiveState, + animatedDismissDragOffsetState = animatedDismissDragOffsetState, + ) + } +} + +@Stable +internal class PhotoViewerDismissDragState( + private val swipeDismissThreshold: Float, + private val dismissDragOffsetState: MutableFloatState, + private val isActiveState: MutableState, + private val animatedDismissDragOffsetState: State, +) { + val animatedDragOffset: Float + get() = animatedDismissDragOffsetState.value + + val animatedProgress: Float + get() = dismissDragProgress(dismissDragOffset = animatedDragOffset) + + val backgroundAlpha: Float + get() = (1f - animatedProgress / PHOTO_VIEWER_BACKGROUND_DISMISS_TRANSPARENT_PROGRESS) + .coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) + + val imageAlpha: Float + get() = (1f - animatedProgress) + .coerceIn( + minimumValue = PHOTO_VIEWER_IMAGE_DISMISS_MIN_ALPHA, + maximumValue = 1f, + ) + + val shouldShowTopBar: Boolean + get() = animatedProgress < PHOTO_VIEWER_TOP_BAR_DISMISS_HIDE_PROGRESS + + fun canHandleDrag( + isZoomed: Boolean, + dragAmount: Float, + ): Boolean { + return !isZoomed && (dragAmount > 0f || dismissDragOffsetState.floatValue > 0f) + } + + fun activate() { + isActiveState.value = true + } + + fun applyDrag(dragAmount: Float) { + dismissDragOffsetState.floatValue = (dismissDragOffsetState.floatValue + dragAmount) + .coerceAtLeast(minimumValue = 0f) + } + + fun shouldCloseOnDragEnd(): Boolean { + return dismissDragOffsetState.floatValue >= + swipeDismissThreshold * PHOTO_VIEWER_DISMISS_CLOSE_PROGRESS + } + + fun reset() { + dismissDragOffsetState.floatValue = 0f + isActiveState.value = false + } + + private fun dismissDragProgress(dismissDragOffset: Float): Float { + return when { + swipeDismissThreshold <= 0f -> 0f + + else -> { + (dismissDragOffset / swipeDismissThreshold).coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerImage.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerImage.kt new file mode 100644 index 000000000..414deab03 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerImage.kt @@ -0,0 +1,208 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.foundation.layout.BoxWithConstraints +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.pager.PagerState +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.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 coil3.compose.AsyncImage +import coil3.request.ImageRequest +import com.android.messaging.R +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.common.components.mediapreview.mediaReviewPageTransform +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode + +private const val PHOTO_VIEWER_DEFAULT_IMAGE_ASPECT_RATIO = 0.75f + +private val PhotoViewerCarouselBottomPadding = 96.dp +private val PhotoViewerCarouselTopPadding = 112.dp + +@Composable +internal fun PhotoViewerImage( + item: PhotoViewerItem, + page: Int, + pagerState: PagerState, + displayMode: PhotoViewerDisplayMode, + imageSize: IntSize?, + imageDecodeSize: IntSize, + onImageLoading: () -> Unit, + onImageError: () -> Unit, + onImageLoaded: (IntSize) -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding( + top = when (displayMode) { + PhotoViewerDisplayMode.Carousel -> PhotoViewerCarouselTopPadding + PhotoViewerDisplayMode.Immersive -> 0.dp + }, + bottom = when (displayMode) { + PhotoViewerDisplayMode.Carousel -> PhotoViewerCarouselBottomPadding + PhotoViewerDisplayMode.Immersive -> 0.dp + }, + ), + contentAlignment = Alignment.Center, + ) { + val imageLayout = rememberPhotoViewerImageLayout( + maxWidth = maxWidth, + maxHeight = maxHeight, + imageSize = imageSize, + ) + + Surface( + modifier = Modifier + .width(width = imageLayout.width) + .height(height = imageLayout.height) + .photoViewerPageTransform( + page = page, + pagerState = pagerState, + displayMode = displayMode, + ), + shape = when (displayMode) { + PhotoViewerDisplayMode.Carousel -> MaterialTheme.shapes.extraLarge + PhotoViewerDisplayMode.Immersive -> RectangleShape + }, + color = Color.Transparent, + ) { + PhotoViewerAsyncImage( + item = item, + imageDecodeSize = imageDecodeSize, + onImageLoading = onImageLoading, + onImageError = onImageError, + onImageLoaded = onImageLoaded, + ) + } + } +} + +@Composable +private fun PhotoViewerAsyncImage( + item: PhotoViewerItem, + imageDecodeSize: IntSize, + onImageLoading: () -> Unit, + onImageError: () -> Unit, + onImageLoaded: (IntSize) -> Unit, +) { + val imageRequest = rememberPhotoViewerImageRequest( + item = item, + imageDecodeSize = imageDecodeSize, + ) + + AsyncImage( + model = imageRequest, + contentDescription = stringResource( + id = R.string.photo_viewer_image_content_description, + ), + contentScale = ContentScale.Fit, + onError = { + onImageError() + }, + onLoading = { + onImageLoading() + }, + onSuccess = { state -> + val loadedImage = state.result.image + onImageLoaded( + IntSize( + width = loadedImage.width.coerceAtLeast(minimumValue = 1), + height = loadedImage.height.coerceAtLeast(minimumValue = 1), + ), + ) + }, + modifier = Modifier + .fillMaxSize(), + ) +} + +@Composable +private fun rememberPhotoViewerImageRequest( + item: PhotoViewerItem, + imageDecodeSize: IntSize, +): ImageRequest { + val context = LocalContext.current + return remember( + context, + item.contentUri, + imageDecodeSize, + ) { + ImageRequest.Builder(context) + .data(data = item.contentUri) + .size( + width = imageDecodeSize.width, + height = imageDecodeSize.height, + ) + .build() + } +} + +@Composable +private fun rememberPhotoViewerImageLayout( + maxWidth: Dp, + maxHeight: Dp, + imageSize: IntSize?, +): PhotoViewerImageLayout { + return remember(maxWidth, maxHeight, imageSize) { + val aspectRatio = resolvePhotoViewerImageAspectRatio(imageSize = imageSize) + val widthFromHeight = maxHeight * aspectRatio + + val width = when { + maxWidth <= widthFromHeight -> maxWidth + else -> widthFromHeight + } + + PhotoViewerImageLayout( + width = width, + height = width / aspectRatio, + ) + } +} + +private fun resolvePhotoViewerImageAspectRatio(imageSize: IntSize?): Float { + return when { + imageSize == null -> PHOTO_VIEWER_DEFAULT_IMAGE_ASPECT_RATIO + imageSize.width <= 0 -> PHOTO_VIEWER_DEFAULT_IMAGE_ASPECT_RATIO + imageSize.height <= 0 -> PHOTO_VIEWER_DEFAULT_IMAGE_ASPECT_RATIO + else -> { + imageSize.width.toFloat() / imageSize.height.toFloat() + } + } +} + +private fun Modifier.photoViewerPageTransform( + page: Int, + pagerState: PagerState, + displayMode: PhotoViewerDisplayMode, +): Modifier { + return when (displayMode) { + PhotoViewerDisplayMode.Carousel -> { + mediaReviewPageTransform( + page = page, + pagerState = pagerState, + removalProgress = 1f, + ) + } + + PhotoViewerDisplayMode.Immersive -> this + } +} + +private data class PhotoViewerImageLayout( + val width: Dp, + val height: Dp, +) diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt new file mode 100644 index 000000000..513150a5a --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt @@ -0,0 +1,217 @@ +package com.android.messaging.ui.photoviewer.component + +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.fillMaxSize +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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.core.MessagingPreviewTheme +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG +import com.android.messaging.ui.photoviewer.preview.previewPhotoViewerItems +import com.android.messaging.util.Dates + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PhotoViewerMetadataSheet( + isVisible: Boolean, + item: PhotoViewerItem?, + actionsEnabled: Boolean, + onDismissRequest: () -> Unit, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + if (!isVisible || item == null) { + return + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_METADATA_SHEET_TEST_TAG), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + dragHandle = { + PhotoViewerMetadataSheetDragHandle() + }, + ) { + PhotoViewerMetadataSheetContent( + item = item, + actionsEnabled = actionsEnabled, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } +} + +@Composable +private fun PhotoViewerMetadataSheetDragHandle() { + Surface( + modifier = Modifier + .padding(top = 12.dp, bottom = 8.dp) + .width(width = 36.dp) + .height(height = 4.dp), + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) { + Spacer(modifier = Modifier.fillMaxSize()) + } +} + +@Composable +private fun PhotoViewerMetadataSheetContent( + item: PhotoViewerItem, + actionsEnabled: Boolean, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(space = 24.dp), + ) { + PhotoViewerMetadataGroup(item = item) + PhotoViewerMetadataActions( + actionsEnabled = actionsEnabled, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } +} + +@Composable +private fun PhotoViewerMetadataGroup( + item: PhotoViewerItem, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + PhotoViewerMetadataRow( + label = stringResource(id = R.string.message_details_from_label), + value = item.title.takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.unknown_sender), + ) + PhotoViewerMetadataRow( + label = stringResource(id = R.string.message_details_received_label), + value = Dates.getMessageTimeString(item.receivedTimestampMillis).toString(), + ) + PhotoViewerMetadataRow( + label = stringResource(id = R.string.message_details_type_label), + value = item.contentType, + ) + } +} + +@Composable +private fun PhotoViewerMetadataActions( + actionsEnabled: Boolean, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + FilledTonalButton( + modifier = Modifier + .weight(weight = 1f) + .height(height = 56.dp), + enabled = actionsEnabled, + shape = CircleShape, + onClick = onShareClick, + ) { + Icon( + imageVector = Icons.Rounded.Share, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(width = 8.dp)) + Text(text = stringResource(id = R.string.action_share)) + } + + Button( + modifier = Modifier + .weight(weight = 1f) + .height(height = 56.dp), + enabled = actionsEnabled, + shape = CircleShape, + onClick = onSaveClick, + ) { + Icon( + imageVector = Icons.Rounded.FileDownload, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(width = 8.dp)) + Text(text = stringResource(id = R.string.save)) + } + } +} + +@Composable +private fun PhotoViewerMetadataRow( + label: String, + value: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@PreviewLightDark +@Composable +private fun PhotoViewerMetadataSheetContentPreview() { + MessagingPreviewTheme { + Surface { + PhotoViewerMetadataSheetContent( + item = previewPhotoViewerItems().first(), + actionsEnabled = true, + onSaveClick = {}, + onShareClick = {}, + ) + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPage.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPage.kt new file mode 100644 index 000000000..8fe409918 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPage.kt @@ -0,0 +1,138 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.CircularProgressIndicator +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntSize +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode + +@Composable +internal fun PhotoViewerPage( + modifier: Modifier, + item: PhotoViewerItem, + page: Int, + pagerState: PagerState, + displayMode: PhotoViewerDisplayMode, + imageDecodeSize: IntSize, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + onCloseClick: () -> Unit, + dismissDragState: PhotoViewerDismissDragState, + onZoomChanged: (Boolean) -> Unit, +) { + ZoomablePhoto( + modifier = modifier, + item = item, + page = page, + pagerState = pagerState, + displayMode = displayMode, + imageDecodeSize = imageDecodeSize, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + onCloseClick = onCloseClick, + dismissDragState = dismissDragState, + onZoomChanged = onZoomChanged, + ) +} + +@Composable +private fun ZoomablePhoto( + modifier: Modifier, + item: PhotoViewerItem, + page: Int, + pagerState: PagerState, + displayMode: PhotoViewerDisplayMode, + imageDecodeSize: IntSize, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + onCloseClick: () -> Unit, + dismissDragState: PhotoViewerDismissDragState, + onZoomChanged: (Boolean) -> Unit, +) { + val contentKey = PhotoViewerContentKey( + page = page, + contentUri = item.contentUri.toString(), + ) + var isLoading by remember(contentKey) { mutableStateOf(value = true) } + var isFailed by remember(contentKey) { mutableStateOf(value = false) } + var imageSize by remember(contentKey) { mutableStateOf(value = null) } + val isCurrentPage = page == pagerState.currentPage + + Box( + modifier = modifier, + ) { + ZoomablePhotoContainer( + modifier = Modifier.photoViewerZoomableModifier(isCurrentPage = isCurrentPage), + contentKey = contentKey, + displayMode = displayMode, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + onCloseClick = onCloseClick, + dismissDragState = dismissDragState.takeIf { isCurrentPage }, + onZoomChanged = onZoomChanged, + ) { + PhotoViewerImage( + item = item, + page = page, + pagerState = pagerState, + displayMode = displayMode, + imageSize = imageSize, + imageDecodeSize = imageDecodeSize, + onImageLoading = { + isLoading = true + isFailed = false + }, + onImageError = { + isLoading = false + isFailed = true + }, + onImageLoaded = { loadedImageSize -> + imageSize = loadedImageSize + isLoading = false + isFailed = false + }, + ) + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(alignment = Alignment.Center), + ) + } + + if (isFailed) { + PhotoViewerLoadError( + modifier = Modifier + .align(alignment = Alignment.Center), + ) + } + } +} + +private fun Modifier.photoViewerZoomableModifier(isCurrentPage: Boolean): Modifier { + return this + .fillMaxSize() + .then( + when { + isCurrentPage -> { + Modifier.testTag( + tag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG, + ) + } + + else -> Modifier + }, + ) +} diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt new file mode 100644 index 000000000..b1cafe3ef --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt @@ -0,0 +1,214 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +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.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.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.common.components.mediapreview.MediaPreviewItem +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGER_TEST_TAG +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import kotlin.math.roundToInt +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +private const val PHOTO_VIEWER_CAROUSEL_PAGE_WIDTH_FRACTION = 0.9f +private const val PHOTO_VIEWER_PAGER_LAYOUT_ANIMATION_MILLIS = 250 +private val PhotoViewerCarouselPageSpacing = 12.dp + +@Composable +internal fun rememberPhotoViewerPagerState( + items: ImmutableList, + currentPage: Int, + onPageSettled: (Int) -> Unit, +): PagerState { + val pagerState = rememberPagerState( + initialPage = currentPage.coerceIn( + minimumValue = 0, + maximumValue = items.lastIndex, + ), + pageCount = { items.size }, + ) + + LaunchedEffect(items.size, currentPage) { + val targetPage = currentPage.coerceIn( + minimumValue = 0, + maximumValue = items.lastIndex, + ) + + if (pagerState.currentPage != targetPage) { + pagerState.animateScrollToPage(page = targetPage) + } + } + + LaunchedEffect(pagerState.settledPage, items.size) { + if (items.isNotEmpty()) { + onPageSettled(pagerState.settledPage) + } + } + + return pagerState +} + +@Composable +internal fun rememberPhotoViewerPreviewItems( + items: ImmutableList, +): ImmutableList { + return remember(items) { + items + .map { item -> + MediaPreviewItem( + contentUri = item.contentUri.toString(), + contentType = item.contentType, + isVideo = false, + ) + } + .toImmutableList() + } +} + +@Composable +internal fun PhotoViewerPager( + modifier: Modifier, + items: ImmutableList, + pagerState: PagerState, + displayMode: PhotoViewerDisplayMode, + isClosing: Boolean, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + dismissDragState: PhotoViewerDismissDragState, + onCloseClick: () -> Unit, +) { + var isCurrentPageZoomed by remember(items) { mutableStateOf(value = false) } + + LaunchedEffect(pagerState.currentPage) { + isCurrentPageZoomed = false + dismissDragState.reset() + } + + BoxWithConstraints(modifier = modifier) { + val imageDecodeSize = rememberPhotoViewerImageDecodeSize( + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + val pagerLayout = rememberPhotoViewerPagerLayout( + maxWidth = maxWidth, + displayMode = displayMode, + ) + + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .testTag(tag = PHOTO_VIEWER_PAGER_TEST_TAG), + state = pagerState, + beyondViewportPageCount = 1, + contentPadding = PaddingValues(horizontal = pagerLayout.horizontalInset), + pageSize = PageSize.Fixed(pageSize = pagerLayout.pageWidth), + pageSpacing = pagerLayout.pageSpacing, + key = { page -> + photoViewerPagerItemKey( + index = page, + item = items[page], + ) + }, + userScrollEnabled = !isCurrentPageZoomed && !isClosing, + ) { page -> + PhotoViewerPage( + modifier = Modifier.fillMaxSize(), + item = items[page], + page = page, + pagerState = pagerState, + displayMode = displayMode, + imageDecodeSize = imageDecodeSize, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + onCloseClick = onCloseClick, + dismissDragState = dismissDragState, + onZoomChanged = { isZoomed -> + if (page == pagerState.currentPage) { + isCurrentPageZoomed = isZoomed + } + }, + ) + } + } +} + +@Composable +private fun rememberPhotoViewerImageDecodeSize(maxWidth: Dp, maxHeight: Dp): IntSize { + val density = LocalDensity.current + return remember(maxWidth, maxHeight, density) { + with(density) { + resolvePhotoViewerDecodeSize( + displayedImageSize = IntSize( + width = maxWidth.toPx().roundToInt(), + height = maxHeight.toPx().roundToInt(), + ), + ) + } + } +} + +@Composable +private fun rememberPhotoViewerPagerLayout( + maxWidth: Dp, + displayMode: PhotoViewerDisplayMode, +): PhotoViewerPagerLayout { + val targetPageWidth = when (displayMode) { + PhotoViewerDisplayMode.Carousel -> maxWidth * PHOTO_VIEWER_CAROUSEL_PAGE_WIDTH_FRACTION + PhotoViewerDisplayMode.Immersive -> maxWidth + } + + val targetPageSpacing = when (displayMode) { + PhotoViewerDisplayMode.Carousel -> PhotoViewerCarouselPageSpacing + PhotoViewerDisplayMode.Immersive -> 0.dp + } + + val pageWidth by animateDpAsState( + targetValue = targetPageWidth, + animationSpec = tween(durationMillis = PHOTO_VIEWER_PAGER_LAYOUT_ANIMATION_MILLIS), + label = "photoViewerPageWidth", + ) + + val pageSpacing by animateDpAsState( + targetValue = targetPageSpacing, + animationSpec = tween(durationMillis = PHOTO_VIEWER_PAGER_LAYOUT_ANIMATION_MILLIS), + label = "photoViewerPageSpacing", + ) + + return PhotoViewerPagerLayout( + pageWidth = pageWidth, + pageSpacing = pageSpacing, + horizontalInset = (maxWidth - pageWidth) / 2, + ) +} + +private fun photoViewerPagerItemKey( + index: Int, + item: PhotoViewerItem, +): String { + return "$index|${item.contentUri}" +} + +private data class PhotoViewerPagerLayout( + val pageWidth: Dp, + val pageSpacing: Dp, + val horizontalInset: Dp, +) diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerStatusMessage.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerStatusMessage.kt new file mode 100644 index 000000000..49bfd8199 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerStatusMessage.kt @@ -0,0 +1,57 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BrokenImage +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R + +@Composable +internal fun PhotoViewerEmptyState(modifier: Modifier) { + PhotoViewerStatusMessage( + modifier = modifier, + text = stringResource(id = R.string.photo_viewer_empty), + ) +} + +@Composable +internal fun PhotoViewerLoadError(modifier: Modifier) { + PhotoViewerStatusMessage( + modifier = modifier, + text = stringResource(id = R.string.photo_viewer_load_error), + ) +} + +@Composable +private fun PhotoViewerStatusMessage( + modifier: Modifier, + text: String, +) { + Column( + modifier = modifier.padding(all = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + modifier = Modifier.size(size = 40.dp), + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + Text( + text = text, + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt new file mode 100644 index 000000000..7cffcd3d1 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt @@ -0,0 +1,265 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.displayCutoutPadding +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.Close +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Share +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.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.data.media.model.PhotoViewerItem +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG +import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG +import com.android.messaging.util.Dates + +@Composable +internal fun PhotoViewerTopBar( + isVisible: Boolean, + item: PhotoViewerItem?, + actionsEnabled: Boolean, + onMetadataClick: () -> Unit, + onCloseClick: () -> Unit, + onForwardClick: () -> Unit, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutPadding(), + ) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn() + slideInVertically { -it }, + exit = fadeOut() + slideOutVertically { -it }, + modifier = Modifier.align(alignment = Alignment.TopCenter), + ) { + PhotoViewerTopBarContent( + item = item, + actionsEnabled = actionsEnabled, + onMetadataClick = onMetadataClick, + onCloseClick = onCloseClick, + onForwardClick = onForwardClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } + } +} + +@Composable +private fun PhotoViewerTopBarContent( + item: PhotoViewerItem?, + actionsEnabled: Boolean, + onMetadataClick: () -> Unit, + onCloseClick: () -> Unit, + onForwardClick: () -> Unit, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + val contentColor = pickerOverlayContentColor() + val secondaryContentColor = contentColor.copy(alpha = 0.8f) + + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + PhotoViewerTopIconButton( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG), + contentDescription = stringResource(id = R.string.action_close), + imageVector = Icons.Rounded.Close, + tint = contentColor, + onClick = onCloseClick, + ) + + PhotoViewerTitleBlock( + modifier = Modifier + .weight(weight = 1f) + .padding(start = 8.dp, end = 4.dp), + item = item, + contentColor = contentColor, + secondaryContentColor = secondaryContentColor, + ) + + PhotoViewerTopIconButton( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG), + contentDescription = stringResource(id = R.string.save), + enabled = actionsEnabled, + imageVector = Icons.Rounded.FileDownload, + tint = contentColor, + onClick = onSaveClick, + ) + PhotoViewerTopIconButton( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG), + contentDescription = stringResource(id = R.string.action_share), + enabled = actionsEnabled, + imageVector = Icons.Rounded.Share, + tint = contentColor, + onClick = onShareClick, + ) + PhotoViewerOverflowMenu( + actionsEnabled = actionsEnabled, + contentColor = contentColor, + onForwardClick = onForwardClick, + onMetadataClick = onMetadataClick, + ) + } +} + +@Composable +private fun PhotoViewerTitleBlock( + modifier: Modifier, + item: PhotoViewerItem?, + contentColor: Color, + secondaryContentColor: Color, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_TITLE_TEST_TAG), + text = item + ?.title + ?.takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.unknown_sender), + style = MaterialTheme.typography.titleSmall, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = photoViewerTimestamp(item = item), + style = MaterialTheme.typography.bodySmall, + color = secondaryContentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun PhotoViewerTopIconButton( + modifier: Modifier = Modifier, + contentDescription: String, + imageVector: ImageVector, + tint: Color, + enabled: Boolean = true, + onClick: () -> Unit, +) { + IconButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = when { + enabled -> tint + else -> tint.copy(alpha = 0.4f) + }, + ) + } +} + +@Composable +private fun PhotoViewerOverflowMenu( + actionsEnabled: Boolean, + contentColor: Color, + onForwardClick: () -> Unit, + onMetadataClick: () -> Unit, +) { + var isExpanded by remember { mutableStateOf(value = false) } + + Box { + PhotoViewerTopIconButton( + modifier = Modifier + .testTag(tag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG), + contentDescription = stringResource(id = R.string.more_options), + imageVector = Icons.Rounded.MoreVert, + tint = contentColor, + onClick = { + isExpanded = true + }, + ) + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { + isExpanded = false + }, + ) { + DropdownMenuItem( + modifier = Modifier + .testTag(tag = PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.message_context_menu_forward_message)) + }, + enabled = actionsEnabled, + onClick = { + isExpanded = false + onForwardClick() + }, + ) + DropdownMenuItem( + modifier = Modifier.testTag(tag = PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.photo_viewer_details_title)) + }, + onClick = { + isExpanded = false + onMetadataClick() + }, + ) + } + } +} + +private fun photoViewerTimestamp(item: PhotoViewerItem?): String { + return item + ?.let { Dates.getMessageTimeString(it.receivedTimestampMillis).toString() } + .orEmpty() +} diff --git a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt new file mode 100644 index 000000000..a60ce2e9f --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt @@ -0,0 +1,238 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.TransformableState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +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.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import kotlinx.coroutines.flow.distinctUntilChanged + +private const val PHOTO_VIEWER_DOUBLE_TAP_ZOOM_ANIMATION_DURATION_MILLIS = 200 + +@Composable +internal fun ZoomablePhotoContainer( + modifier: Modifier, + contentKey: PhotoViewerContentKey, + displayMode: PhotoViewerDisplayMode, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + onCloseClick: () -> Unit, + dismissDragState: PhotoViewerDismissDragState?, + onZoomChanged: (Boolean) -> Unit, + content: @Composable () -> Unit, +) { + val gestureState = remember(contentKey) { ZoomablePhotoGestureState() } + val currentOnToggleDisplayMode = rememberUpdatedState(newValue = onToggleDisplayMode) + val currentOnEnterImmersiveMode = rememberUpdatedState(newValue = onEnterImmersiveMode) + val currentOnCloseClick = rememberUpdatedState(newValue = onCloseClick) + val transformableState = rememberTransformableState { _, zoomChange, panChange, _ -> + if (gestureState.applyTransform(zoomChange = zoomChange, panChange = panChange)) { + currentOnEnterImmersiveMode.value() + } + } + val zoomAnimationSpec = photoViewerZoomAnimationSpec( + isEnabled = gestureState.isZoomAnimationEnabled, + ) + val offsetAnimationSpec = photoViewerZoomAnimationSpec( + isEnabled = gestureState.isZoomAnimationEnabled, + ) + val animatedScale = animateFloatAsState( + targetValue = gestureState.scale, + animationSpec = zoomAnimationSpec, + label = "PhotoViewerZoomScale", + ) + val animatedOffset = animateOffsetAsState( + targetValue = gestureState.offset, + animationSpec = offsetAnimationSpec, + label = "PhotoViewerZoomOffset", + ) + + PhotoViewerGestureEffects( + contentKey = contentKey, + displayMode = displayMode, + gestureState = gestureState, + dismissDragState = dismissDragState, + onZoomChanged = onZoomChanged, + ) + + Box( + modifier = modifier + .photoViewerGestureModifier( + contentKey = contentKey, + gestureState = gestureState, + transformableState = transformableState, + animatedScale = animatedScale, + animatedOffset = animatedOffset, + dismissDragState = dismissDragState, + currentOnToggleDisplayMode = currentOnToggleDisplayMode, + currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, + currentOnCloseClick = currentOnCloseClick, + ), + ) { + content() + } +} + +@Composable +private fun PhotoViewerGestureEffects( + contentKey: PhotoViewerContentKey, + displayMode: PhotoViewerDisplayMode, + gestureState: ZoomablePhotoGestureState, + dismissDragState: PhotoViewerDismissDragState?, + onZoomChanged: (Boolean) -> Unit, +) { + val currentOnZoomChanged by rememberUpdatedState(newValue = onZoomChanged) + + LaunchedEffect(gestureState) { + snapshotFlow { gestureState.isZoomed() } + .distinctUntilChanged() + .collect { isZoomed -> + currentOnZoomChanged(isZoomed) + } + } + + LaunchedEffect(displayMode, contentKey) { + if (displayMode == PhotoViewerDisplayMode.Carousel) { + gestureState.resetAll() + dismissDragState?.reset() + } + } +} + +private fun Modifier.photoViewerGestureModifier( + contentKey: PhotoViewerContentKey, + gestureState: ZoomablePhotoGestureState, + transformableState: TransformableState, + animatedScale: State, + animatedOffset: State, + dismissDragState: PhotoViewerDismissDragState?, + currentOnToggleDisplayMode: State<() -> Unit>, + currentOnEnterImmersiveMode: State<() -> Unit>, + currentOnCloseClick: State<() -> Unit>, +): Modifier { + return onSizeChanged { size -> + gestureState.updateContainerSize(containerSize = size) + } + .photoViewerTapInput( + contentKey = contentKey, + gestureState = gestureState, + currentOnToggleDisplayMode = currentOnToggleDisplayMode, + currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, + ) + .photoViewerDismissDragInput( + contentKey = contentKey, + gestureState = gestureState, + dismissDragState = dismissDragState, + currentOnCloseClick = currentOnCloseClick, + ) + .transformable( + state = transformableState, + canPan = { + gestureState.isZoomed() + }, + ) + .graphicsLayer { + val dismissDragOffset = dismissDragState?.animatedDragOffset ?: 0f + val zoomScale = animatedScale.value + val zoomOffset = animatedOffset.value + + alpha = dismissDragState?.imageAlpha ?: 1f + scaleX = zoomScale + scaleY = zoomScale + translationX = zoomOffset.x + translationY = zoomOffset.y + dismissDragOffset + } +} + +private fun Modifier.photoViewerTapInput( + contentKey: PhotoViewerContentKey, + gestureState: ZoomablePhotoGestureState, + currentOnToggleDisplayMode: State<() -> Unit>, + currentOnEnterImmersiveMode: State<() -> Unit>, +): Modifier { + return pointerInput(contentKey) { + detectTapGestures( + onTap = { + currentOnToggleDisplayMode.value() + }, + onDoubleTap = { tapOffset -> + currentOnEnterImmersiveMode.value() + gestureState.applyDoubleTap(tapOffset = tapOffset) + }, + ) + } +} + +private fun Modifier.photoViewerDismissDragInput( + contentKey: PhotoViewerContentKey, + gestureState: ZoomablePhotoGestureState, + dismissDragState: PhotoViewerDismissDragState?, + currentOnCloseClick: State<() -> Unit>, +): Modifier { + return pointerInput( + contentKey, + dismissDragState, + ) { + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + dismissDragState?.let { activeDismissDragState -> + val canHandleDismissDrag = activeDismissDragState.canHandleDrag( + isZoomed = gestureState.isZoomed(), + dragAmount = dragAmount, + ) + + if (canHandleDismissDrag) { + change.consume() + activeDismissDragState.activate() + activeDismissDragState.applyDrag(dragAmount = dragAmount) + } + } + }, + onDragEnd = { + when { + dismissDragState?.shouldCloseOnDragEnd() == true -> { + currentOnCloseClick.value() + } + + else -> { + dismissDragState?.reset() + } + } + }, + onDragCancel = { + dismissDragState?.reset() + }, + ) + } +} + +private fun photoViewerZoomAnimationSpec(isEnabled: Boolean): AnimationSpec { + return when { + isEnabled -> tween( + durationMillis = PHOTO_VIEWER_DOUBLE_TAP_ZOOM_ANIMATION_DURATION_MILLIS, + easing = FastOutSlowInEasing, + ) + + else -> snap() + } +} diff --git a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureState.kt b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureState.kt new file mode 100644 index 000000000..491855a21 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoGestureState.kt @@ -0,0 +1,115 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize + +private const val PHOTO_VIEWER_DOUBLE_TAP_SCALE = 2.5f +private const val PHOTO_VIEWER_MAX_SCALE = 5f +private const val PHOTO_VIEWER_ZOOM_EPSILON = 0.01f + +@Stable +internal class ZoomablePhotoGestureState { + var containerSize by mutableStateOf(value = IntSize.Zero) + private set + + var scale by mutableFloatStateOf(value = 1f) + private set + + var offset by mutableStateOf(value = Offset.Zero) + private set + + var isZoomAnimationEnabled by mutableStateOf(value = false) + private set + + fun updateContainerSize(containerSize: IntSize) { + this.containerSize = containerSize + offset = coerceOffset( + candidateOffset = offset, + candidateScale = scale, + ) + } + + fun isZoomed(): Boolean { + return scale > 1f + PHOTO_VIEWER_ZOOM_EPSILON + } + + fun applyTransform(zoomChange: Float, panChange: Offset): Boolean { + isZoomAnimationEnabled = false + val nextScale = (scale * zoomChange).coerceIn( + minimumValue = 1f, + maximumValue = PHOTO_VIEWER_MAX_SCALE, + ) + + scale = nextScale + offset = coerceOffset( + candidateOffset = offset + panChange, + candidateScale = nextScale, + ) + + return zoomChange != 1f + } + + fun applyDoubleTap(tapOffset: Offset) { + isZoomAnimationEnabled = true + val nextScale = when { + isZoomed() -> 1f + else -> PHOTO_VIEWER_DOUBLE_TAP_SCALE + } + + scale = nextScale + offset = doubleTapOffset( + tapOffset = tapOffset, + nextScale = nextScale, + ) + } + + fun resetAll() { + isZoomAnimationEnabled = false + scale = 1f + offset = Offset.Zero + } + + private fun coerceOffset(candidateOffset: Offset, candidateScale: Float): Offset { + if (candidateScale <= 1f || containerSize == IntSize.Zero) { + return Offset.Zero + } + + val maxX = containerSize.width * (candidateScale - 1f) / 2f + val maxY = containerSize.height * (candidateScale - 1f) / 2f + + return Offset( + x = candidateOffset.x.coerceIn( + minimumValue = -maxX, + maximumValue = maxX, + ), + y = candidateOffset.y.coerceIn( + minimumValue = -maxY, + maximumValue = maxY, + ), + ) + } + + private fun doubleTapOffset(tapOffset: Offset, nextScale: Float): Offset { + val containerCenter = Offset( + x = containerSize.width / 2f, + y = containerSize.height / 2f, + ) + val tapOffsetFromCenter = tapOffset - containerCenter + + return when { + nextScale <= 1f -> Offset.Zero + containerSize == IntSize.Zero -> Offset.Zero + else -> { + coerceOffset( + candidateOffset = -tapOffsetFromCenter * (nextScale - 1f), + candidateScale = nextScale, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt new file mode 100644 index 000000000..005c8d74b --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt @@ -0,0 +1,25 @@ +package com.android.messaging.ui.photoviewer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PhotoViewerLaunchRequest( + val initialPhotoUri: String, + val photosUri: String, + val sourceBounds: PhotoViewerSourceBounds, +) + +@Immutable +internal data class PhotoViewerLaunchRequestKey( + val initialPhotoUri: String, + val photosUri: String, +) + +internal fun photoViewerLaunchRequestKey( + launchRequest: PhotoViewerLaunchRequest, +): PhotoViewerLaunchRequestKey { + return PhotoViewerLaunchRequestKey( + initialPhotoUri = launchRequest.initialPhotoUri, + photosUri = launchRequest.photosUri, + ) +} diff --git a/src/com/android/messaging/ui/photoviewer/model/PhotoViewerSourceBounds.kt b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerSourceBounds.kt new file mode 100644 index 000000000..abd403d3a --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerSourceBounds.kt @@ -0,0 +1,23 @@ +package com.android.messaging.ui.photoviewer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PhotoViewerSourceBounds( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0, +) { + val width: Int + get() = right - left + + val height: Int + get() = bottom - top + + val centerX: Float + get() = (left + right) / 2f + + val centerY: Float + get() = (top + bottom) / 2f +} diff --git a/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt b/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt new file mode 100644 index 000000000..99286848c --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt @@ -0,0 +1,25 @@ +package com.android.messaging.ui.photoviewer.preview + +import androidx.core.net.toUri +import com.android.messaging.data.media.model.PhotoViewerItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal fun previewPhotoViewerItems(): ImmutableList { + return persistentListOf( + previewPhotoViewerItem(index = 1), + previewPhotoViewerItem(index = 2), + previewPhotoViewerItem(index = 3), + ) +} + +internal fun previewPhotoViewerItem(index: Int): PhotoViewerItem { + return PhotoViewerItem( + contentUri = "content://example/content/$index".toUri(), + contentType = "image/jpeg", + senderName = "Ada Lovelace", + senderDestination = "+1555123000$index", + receivedTimestampMillis = 1_735_689_600_000L, + isDraft = false, + ) +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt new file mode 100644 index 000000000..1703dc656 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt @@ -0,0 +1,221 @@ +package com.android.messaging.ui.photoviewer.screen + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.ui.common.components.mediapreview.MediaPreviewBackground +import com.android.messaging.ui.common.components.mediapreview.MediaPreviewItem +import com.android.messaging.ui.photoviewer.component.PhotoViewerDismissDragState +import com.android.messaging.ui.photoviewer.component.PhotoViewerEmptyState +import com.android.messaging.ui.photoviewer.component.PhotoViewerLoadError +import com.android.messaging.ui.photoviewer.component.PhotoViewerPager +import com.android.messaging.ui.photoviewer.component.PhotoViewerTopBar +import com.android.messaging.ui.photoviewer.component.rememberPhotoViewerPagerState +import com.android.messaging.ui.photoviewer.component.rememberPhotoViewerPreviewItems +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState +import kotlinx.collections.immutable.ImmutableList + +private const val PHOTO_VIEWER_CLOSE_ANIMATION_MILLIS = 250 +private const val PHOTO_VIEWER_ENTER_ANIMATION_MILLIS = 350 + +@Composable +internal fun PhotoViewerAnimatedContent( + launchRequest: PhotoViewerLaunchRequest, + isClosing: Boolean, + onCloseAnimationFinished: () -> Unit, + content: @Composable () -> Unit, +) { + var rootSize by remember { mutableStateOf(value = IntSize.Zero) } + var hasEntered by remember { mutableStateOf(value = false) } + var hasClosed by remember { mutableStateOf(value = false) } + val transitionProgress = remember { Animatable(initialValue = 0f) } + + LaunchedEffect(rootSize, launchRequest.sourceBounds) { + if (rootSize != IntSize.Zero && !hasEntered) { + transitionProgress.snapTo(targetValue = 0f) + transitionProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = PHOTO_VIEWER_ENTER_ANIMATION_MILLIS), + ) + hasEntered = true + } + } + + LaunchedEffect(isClosing, rootSize) { + if (isClosing && !hasClosed) { + transitionProgress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = PHOTO_VIEWER_CLOSE_ANIMATION_MILLIS), + ) + hasClosed = true + onCloseAnimationFinished() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { size -> rootSize = size } + .graphicsLayer { + val transform = resolvePhotoViewerTransform( + sourceBounds = launchRequest.sourceBounds, + rootSize = rootSize, + progress = transitionProgress.value, + ) + + alpha = transform.alpha + scaleX = transform.scale + scaleY = transform.scale + translationX = transform.translationX + translationY = transform.translationY + transformOrigin = TransformOrigin.Center + }, + ) { + content() + } +} + +@Composable +internal fun PhotoViewerContent( + uiState: PhotoViewerUiState, + currentItem: PhotoViewerItem?, + actionsEnabled: Boolean, + onPageSettled: (Int) -> Unit, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + dismissDragState: PhotoViewerDismissDragState, + onMetadataClick: () -> Unit, + onCloseClick: () -> Unit, + onForwardClick: () -> Unit, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + val shouldShowTopBar by remember(dismissDragState) { + derivedStateOf { dismissDragState.shouldShowTopBar } + } + + Box(modifier = Modifier.fillMaxSize()) { + PhotoViewerMediaContent( + uiState = uiState, + onPageSettled = onPageSettled, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + dismissDragState = dismissDragState, + onCloseClick = onCloseClick, + ) + + when (uiState.loadState) { + PhotoViewerLoadState.Loading -> { + CircularProgressIndicator( + modifier = Modifier + .align(alignment = Alignment.Center), + ) + } + + PhotoViewerLoadState.Empty -> { + PhotoViewerEmptyState( + modifier = Modifier + .align(alignment = Alignment.Center), + ) + } + + PhotoViewerLoadState.Error -> { + PhotoViewerLoadError( + modifier = Modifier + .align(alignment = Alignment.Center), + ) + } + + else -> Unit + } + + PhotoViewerTopBar( + isVisible = uiState.displayMode == PhotoViewerDisplayMode.Carousel && + currentItem != null && + !uiState.isClosing && + shouldShowTopBar, + item = currentItem, + actionsEnabled = actionsEnabled, + onMetadataClick = onMetadataClick, + onCloseClick = onCloseClick, + onForwardClick = onForwardClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } +} + +@Composable +private fun PhotoViewerMediaContent( + uiState: PhotoViewerUiState, + onPageSettled: (Int) -> Unit, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + dismissDragState: PhotoViewerDismissDragState, + onCloseClick: () -> Unit, +) { + if (uiState.items.isEmpty()) { + return + } + + val pagerState = rememberPhotoViewerPagerState( + items = uiState.items, + currentPage = uiState.currentPage, + onPageSettled = onPageSettled, + ) + + PhotoViewerBlurredBackground( + dismissDragState = dismissDragState, + items = rememberPhotoViewerPreviewItems(items = uiState.items), + pagerState = pagerState, + ) + + PhotoViewerPager( + modifier = Modifier.fillMaxSize(), + items = uiState.items, + pagerState = pagerState, + displayMode = uiState.displayMode, + isClosing = uiState.isClosing, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + dismissDragState = dismissDragState, + onCloseClick = onCloseClick, + ) +} + +@Composable +private fun PhotoViewerBlurredBackground( + dismissDragState: PhotoViewerDismissDragState, + items: ImmutableList, + pagerState: PagerState, +) { + MediaPreviewBackground( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = dismissDragState.backgroundAlpha + }, + items = items, + pagerState = pagerState, + ) +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt new file mode 100644 index 000000000..a179ecc6f --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt @@ -0,0 +1,161 @@ +package com.android.messaging.ui.photoviewer.screen + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.ui.core.MessagingPreviewTheme +import com.android.messaging.ui.photoviewer.component.PhotoViewerMetadataSheet +import com.android.messaging.ui.photoviewer.component.rememberPhotoViewerDismissDragState +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds +import com.android.messaging.ui.photoviewer.model.photoViewerLaunchRequestKey +import com.android.messaging.ui.photoviewer.preview.previewPhotoViewerItems +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState + +@Composable +internal fun PhotoViewerScreen( + modifier: Modifier = Modifier, + launchRequest: PhotoViewerLaunchRequest, + onFinish: () -> Unit, + screenModel: PhotoViewerScreenModel = hiltViewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(launchRequest) { + screenModel.onLaunchRequest(launchRequest = launchRequest) + } + + PhotoViewerScreenEffects( + screenModel = screenModel, + onFinish = onFinish, + ) + + BackHandler(enabled = uiState.isMetadataSheetVisible) { + screenModel.onMetadataDismissed() + } + + BackHandler(enabled = !uiState.isMetadataSheetVisible) { + screenModel.onCloseClick() + } + + PhotoViewerScreenContent( + modifier = modifier, + launchRequest = launchRequest, + uiState = uiState, + onPageSettled = screenModel::onPageSettled, + onToggleDisplayMode = screenModel::onToggleDisplayMode, + onEnterImmersiveMode = screenModel::onEnterImmersiveMode, + onMetadataClick = screenModel::onMetadataClick, + onMetadataDismissed = screenModel::onMetadataDismissed, + onCloseClick = screenModel::onCloseClick, + onCloseAnimationFinished = screenModel::onCloseAnimationFinished, + onForwardClick = screenModel::onForwardClick, + onSaveClick = screenModel::onSaveClick, + onShareClick = screenModel::onShareClick, + ) +} + +@Composable +internal fun PhotoViewerScreenContent( + modifier: Modifier = Modifier, + launchRequest: PhotoViewerLaunchRequest, + uiState: PhotoViewerUiState, + onPageSettled: (Int) -> Unit, + onToggleDisplayMode: () -> Unit, + onEnterImmersiveMode: () -> Unit, + onMetadataClick: () -> Unit, + onMetadataDismissed: () -> Unit, + onCloseClick: () -> Unit, + onCloseAnimationFinished: () -> Unit, + onForwardClick: () -> Unit, + onSaveClick: () -> Unit, + onShareClick: () -> Unit, +) { + val currentItem = uiState.items.getOrNull(index = uiState.currentPage) + val actionsEnabled = currentItem != null + val dismissDragState = rememberPhotoViewerDismissDragState( + resetKey = photoViewerLaunchRequestKey(launchRequest = launchRequest), + ) + val scrimColor = MaterialTheme.colorScheme.scrim + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + drawRect( + color = scrimColor.copy( + alpha = dismissDragState.backgroundAlpha, + ), + ) + }, + ) { + PhotoViewerAnimatedContent( + launchRequest = launchRequest, + isClosing = uiState.isClosing, + onCloseAnimationFinished = onCloseAnimationFinished, + ) { + PhotoViewerContent( + uiState = uiState, + currentItem = currentItem, + actionsEnabled = actionsEnabled, + onPageSettled = onPageSettled, + onToggleDisplayMode = onToggleDisplayMode, + onEnterImmersiveMode = onEnterImmersiveMode, + dismissDragState = dismissDragState, + onMetadataClick = onMetadataClick, + onCloseClick = onCloseClick, + onForwardClick = onForwardClick, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) + } + } + + PhotoViewerMetadataSheet( + isVisible = uiState.isMetadataSheetVisible && currentItem != null, + item = currentItem, + actionsEnabled = actionsEnabled, + onDismissRequest = onMetadataDismissed, + onSaveClick = onSaveClick, + onShareClick = onShareClick, + ) +} + +@PreviewLightDark +@Composable +private fun PhotoViewerScreenContentPreview() { + MessagingPreviewTheme { + PhotoViewerScreenContent( + launchRequest = PhotoViewerLaunchRequest( + initialPhotoUri = "content://example/content/1", + photosUri = "content://example/photos", + sourceBounds = PhotoViewerSourceBounds(), + ), + uiState = PhotoViewerUiState( + loadState = PhotoViewerLoadState.Loaded, + items = previewPhotoViewerItems(), + currentPage = 0, + ), + onPageSettled = { _ -> }, + onToggleDisplayMode = {}, + onEnterImmersiveMode = {}, + onMetadataClick = {}, + onMetadataDismissed = {}, + onCloseClick = {}, + onCloseAnimationFinished = {}, + onForwardClick = {}, + onSaveClick = {}, + onShareClick = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt new file mode 100644 index 000000000..d83deacf0 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt @@ -0,0 +1,138 @@ +package com.android.messaging.ui.photoviewer.screen + +import android.content.ActivityNotFoundException +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import com.android.messaging.R +import com.android.messaging.ui.AttachmentSaveTask +import com.android.messaging.ui.conversationpicker.host.share.ShareIntentActivity +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerEffect +import com.android.messaging.util.LogUtil +import com.android.messaging.util.UiUtils +import com.android.messaging.util.UriUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val LOG_TAG = "PhotoViewerScreenEffects" + +@Composable +internal fun PhotoViewerScreenEffects( + screenModel: PhotoViewerScreenModel, + onFinish: () -> Unit, +) { + val context = LocalContext.current + val currentContext = rememberUpdatedState(context) + val currentOnFinish = rememberUpdatedState(onFinish) + + LaunchedEffect(screenModel) { + screenModel.effects.collect { effect -> + handlePhotoViewerEffect( + context = currentContext.value, + effect = effect, + onFinish = currentOnFinish.value, + ) + } + } +} + +private suspend fun handlePhotoViewerEffect( + context: Context, + effect: PhotoViewerEffect, + onFinish: () -> Unit, +) { + when (effect) { + PhotoViewerEffect.Finish -> onFinish() + + is PhotoViewerEffect.Save -> { + AttachmentSaveTask( + context, + effect.uri, + effect.contentType, + ).executeOnThreadPool() + } + + is PhotoViewerEffect.Share -> { + sharePhoto( + context = context, + uri = effect.uri, + contentType = effect.contentType, + ) + } + + is PhotoViewerEffect.Forward -> { + forwardPhoto( + context = context, + uri = effect.uri, + contentType = effect.contentType, + ) + } + } +} + +private suspend fun sharePhoto( + context: Context, + uri: Uri, + contentType: String, +) { + val shareUri = normalizeUriForSendIntent(uri = uri) + + try { + Intent(Intent.ACTION_SEND) + .apply { + type = contentType + putExtra(Intent.EXTRA_STREAM, shareUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + .let { intent -> + Intent.createChooser( + intent, + context.getText(R.string.action_share), + ) + } + .let(context::startActivity) + } catch (e: ActivityNotFoundException) { + LogUtil.w(LOG_TAG, "No activity found for photo share intent", e) + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } +} + +private suspend fun forwardPhoto( + context: Context, + uri: Uri, + contentType: String, +) { + val forwardUri = normalizeUriForSendIntent(uri = uri) + + try { + Intent(context, ShareIntentActivity::class.java) + .apply { + action = Intent.ACTION_SEND + type = contentType + putExtra(Intent.EXTRA_STREAM, forwardUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + .let(context::startActivity) + } catch (e: ActivityNotFoundException) { + LogUtil.w(LOG_TAG, "No activity found for photo forward intent", e) + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } +} + +@Suppress("InjectDispatcher") +private suspend fun normalizeUriForSendIntent(uri: Uri): Uri { + return when { + uri.scheme != ContentResolver.SCHEME_FILE -> uri + + else -> { + withContext(context = Dispatchers.IO) { + UriUtil.persistContentToScratchSpace(uri) ?: uri + } + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransform.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransform.kt new file mode 100644 index 000000000..add49f31e --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerTransform.kt @@ -0,0 +1,113 @@ +package com.android.messaging.ui.photoviewer.screen + +import androidx.compose.ui.unit.IntSize +import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds + +private const val PHOTO_VIEWER_FALLBACK_ENTRY_SCALE = 0.95f +private const val PHOTO_VIEWER_FALLBACK_ENTRY_TRANSLATION_Y = 24f +private const val PHOTO_VIEWER_MIN_SOURCE_BOUNDS_SCALE = 0.1f + +internal fun resolvePhotoViewerTransform( + sourceBounds: PhotoViewerSourceBounds, + rootSize: IntSize, + progress: Float, +): PhotoViewerTransform { + if (rootSize == IntSize.Zero) { + return PhotoViewerTransform( + scale = PHOTO_VIEWER_FALLBACK_ENTRY_SCALE, + alpha = 0f, + ) + } + + val hasValidSourceBounds = sourceBounds.width > 0 && sourceBounds.height > 0 + + val initialScale = when { + hasValidSourceBounds -> { + maxOf( + sourceBounds.width.toFloat() / rootSize.width.toFloat(), + sourceBounds.height.toFloat() / rootSize.height.toFloat(), + ).coerceIn( + minimumValue = PHOTO_VIEWER_MIN_SOURCE_BOUNDS_SCALE, + maximumValue = 1f, + ) + } + + else -> PHOTO_VIEWER_FALLBACK_ENTRY_SCALE + } + + return PhotoViewerTransform( + scale = interpolatePhotoViewerTransformValue( + start = initialScale, + stop = 1f, + fraction = progress, + ), + translationX = initialPhotoViewerTranslationX( + sourceBounds = sourceBounds, + rootSize = rootSize, + hasValidSourceBounds = hasValidSourceBounds, + ).let { translation -> + interpolatePhotoViewerTransformValue( + start = translation, + stop = 0f, + fraction = progress, + ) + }, + translationY = initialPhotoViewerTranslationY( + sourceBounds = sourceBounds, + rootSize = rootSize, + hasValidSourceBounds = hasValidSourceBounds, + ).let { translation -> + interpolatePhotoViewerTransformValue( + start = translation, + stop = 0f, + fraction = progress, + ) + }, + alpha = interpolatePhotoViewerTransformValue( + start = 0f, + stop = 1f, + fraction = progress, + ), + ) +} + +private fun initialPhotoViewerTranslationX( + sourceBounds: PhotoViewerSourceBounds, + rootSize: IntSize, + hasValidSourceBounds: Boolean, +): Float { + val rootCenterX = rootSize.width / 2f + + return when { + hasValidSourceBounds -> sourceBounds.centerX - rootCenterX + else -> 0f + } +} + +private fun initialPhotoViewerTranslationY( + sourceBounds: PhotoViewerSourceBounds, + rootSize: IntSize, + hasValidSourceBounds: Boolean, +): Float { + val rootCenterY = rootSize.height / 2f + + return when { + hasValidSourceBounds -> sourceBounds.centerY - rootCenterY + else -> PHOTO_VIEWER_FALLBACK_ENTRY_TRANSLATION_Y + } +} + +private fun interpolatePhotoViewerTransformValue( + start: Float, + stop: Float, + fraction: Float, +): Float { + return start + (stop - start) * fraction +} + +internal data class PhotoViewerTransform( + val scale: Float = 1f, + val translationX: Float = 0f, + val translationY: Float = 0f, + val alpha: Float = 1f, +) diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt new file mode 100644 index 000000000..81fa41b1b --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt @@ -0,0 +1,376 @@ +package com.android.messaging.ui.photoviewer.screen + +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.data.media.model.PhotoViewerItem +import com.android.messaging.data.media.model.PhotoViewerItems +import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult +import com.android.messaging.data.media.repository.PhotoViewerRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest +import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequestKey +import com.android.messaging.ui.photoviewer.model.photoViewerLaunchRequestKey +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerEffect +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState +import com.android.messaging.util.LogUtil +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface PhotoViewerScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onLaunchRequest(launchRequest: PhotoViewerLaunchRequest) + fun onPageSettled(page: Int) + fun onToggleDisplayMode() + fun onEnterImmersiveMode() + fun onMetadataClick() + fun onMetadataDismissed() + fun onCloseClick() + fun onCloseAnimationFinished() + fun onSaveClick() + fun onShareClick() + fun onForwardClick() +} + +@HiltViewModel +internal class PhotoViewerViewModel @Inject constructor( + private val photoViewerRepository: PhotoViewerRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ViewModel(), + PhotoViewerScreenModel { + + private val _uiState = MutableStateFlow(PhotoViewerUiState()) + private val effectsChannel = Channel( + capacity = Channel.BUFFERED, + ) + private var launchRequestKey: PhotoViewerLaunchRequestKey? = null + private var loadJob: Job? = null + private var hasFinished = false + + override val effects = effectsChannel.receiveAsFlow() + override val uiState = _uiState.asStateFlow() + + override fun onLaunchRequest(launchRequest: PhotoViewerLaunchRequest) { + val nextLaunchRequestKey = photoViewerLaunchRequestKey(launchRequest = launchRequest) + if (launchRequestKey == nextLaunchRequestKey) { + return + } + + launchRequestKey = nextLaunchRequestKey + hasFinished = false + loadJob?.cancel() + _uiState.value = PhotoViewerUiState(loadState = PhotoViewerLoadState.Loading) + + loadJob = viewModelScope.launch(defaultDispatcher) { + var isInitialEmission = true + + photoViewerRepository + .getPhotoViewerItems( + photosUri = launchRequest.photosUri.toUri(), + initialPhotoUri = launchRequest.initialPhotoUri.toUri(), + ) + .catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.e(TAG, "Photo viewer item flow failed", throwable) + setPhotoViewerItemsLoadError() + } + .collect { loadResult -> + val didApplySuccessfulResult = applyPhotoViewerItemsLoadResult( + loadResult = loadResult, + useInitialIndex = isInitialEmission, + ) + + if (didApplySuccessfulResult) { + isInitialEmission = false + } + } + } + } + + override fun onPageSettled(page: Int) { + setCurrentPage(page = page) + } + + override fun onToggleDisplayMode() { + _uiState.update { state -> + when { + state.isClosing -> state + state.displayMode == PhotoViewerDisplayMode.Carousel -> { + state.copy(displayMode = PhotoViewerDisplayMode.Immersive) + } + + else -> { + state.copy(displayMode = PhotoViewerDisplayMode.Carousel) + } + } + } + } + + override fun onEnterImmersiveMode() { + _uiState.update { state -> + when { + state.isClosing -> state + state.displayMode == PhotoViewerDisplayMode.Immersive -> state + else -> { + state.copy(displayMode = PhotoViewerDisplayMode.Immersive) + } + } + } + } + + override fun onMetadataClick() { + _uiState.update { state -> + when { + state.isClosing -> state + state.items.isEmpty() -> state + else -> { + state.copy( + displayMode = PhotoViewerDisplayMode.Carousel, + isMetadataSheetVisible = true, + ) + } + } + } + } + + override fun onMetadataDismissed() { + _uiState.update { it.copy(isMetadataSheetVisible = false) } + } + + override fun onCloseClick() { + _uiState.update { state -> + when { + state.isClosing -> state + else -> { + state.copy( + displayMode = PhotoViewerDisplayMode.Immersive, + isMetadataSheetVisible = false, + isClosing = true, + ) + } + } + } + } + + override fun onCloseAnimationFinished() { + if (hasFinished) { + return + } + + hasFinished = true + sendEffect(effect = PhotoViewerEffect.Finish) + } + + override fun onSaveClick() { + currentItemForAction()?.let { item -> + sendEffect( + effect = PhotoViewerEffect.Save( + uri = item.contentUri, + contentType = item.contentType, + ), + ) + } + } + + override fun onShareClick() { + currentItemForAction()?.let { item -> + sendEffect( + effect = PhotoViewerEffect.Share( + uri = item.contentUri, + contentType = item.contentType, + ), + ) + } + } + + override fun onForwardClick() { + currentItemForAction()?.let { item -> + sendEffect( + effect = PhotoViewerEffect.Forward( + uri = item.contentUri, + contentType = item.contentType, + ), + ) + } + } + + private fun sendEffect(effect: PhotoViewerEffect) { + viewModelScope.launch(defaultDispatcher) { + effectsChannel.send(element = effect) + } + } + + private fun setCurrentPage(page: Int) { + _uiState.update { currentState -> + val coercedPage = page.coerceIn( + minimumValue = 0, + maximumValue = currentState.items.lastIndex.coerceAtLeast(minimumValue = 0), + ) + + currentState.copy(currentPage = coercedPage) + } + } + + private fun currentItemForAction(): PhotoViewerItem? { + val state = uiState.value + + return state + .items + .getOrNull(index = state.currentPage) + } + + private fun updatedStateForPhotoViewerItems( + currentState: PhotoViewerUiState, + result: PhotoViewerItems, + useInitialIndex: Boolean, + ): PhotoViewerUiState { + return currentState.copy( + loadState = loadStateForItems(items = result.items), + items = result.items, + currentPage = resolveCurrentPage( + currentState = currentState, + items = result.items, + initialIndex = result.initialIndex, + useInitialIndex = useInitialIndex, + ), + displayMode = when { + useInitialIndex -> PhotoViewerDisplayMode.Carousel + else -> currentState.displayMode + }, + isMetadataSheetVisible = when { + useInitialIndex -> false + result.items.isEmpty() -> false + else -> currentState.isMetadataSheetVisible + }, + isClosing = when { + useInitialIndex -> false + else -> currentState.isClosing + }, + ) + } + + private fun applyPhotoViewerItemsLoadResult( + loadResult: PhotoViewerItemsLoadResult, + useInitialIndex: Boolean, + ): Boolean { + var didApplySuccessfulResult = false + + when (loadResult) { + is PhotoViewerItemsLoadResult.Loaded -> { + _uiState.update { currentState -> + updatedStateForPhotoViewerItems( + currentState = currentState, + result = loadResult.photoViewerItems, + useInitialIndex = useInitialIndex, + ) + } + didApplySuccessfulResult = true + } + + PhotoViewerItemsLoadResult.Error -> { + setPhotoViewerItemsLoadError() + } + } + + return didApplySuccessfulResult + } + + private fun loadStateForItems(items: ImmutableList): PhotoViewerLoadState { + return when { + items.isEmpty() -> PhotoViewerLoadState.Empty + else -> PhotoViewerLoadState.Loaded + } + } + + private fun setPhotoViewerItemsLoadError() { + _uiState.update { currentState -> + currentState.copy( + loadState = PhotoViewerLoadState.Error, + items = persistentListOf(), + currentPage = 0, + displayMode = PhotoViewerDisplayMode.Carousel, + isMetadataSheetVisible = false, + ) + } + } + + private fun resolveCurrentPage( + currentState: PhotoViewerUiState, + items: ImmutableList, + initialIndex: Int, + useInitialIndex: Boolean, + ): Int { + if (items.isEmpty()) { + return 0 + } + + val resolvedPage = when { + useInitialIndex -> initialIndex + else -> { + currentState + .items + .getOrNull(index = currentState.currentPage) + ?.let { currentItem -> + findMatchingItemIndex( + items = items, + currentItem = currentItem, + ) + } + ?.takeIf { it >= 0 } + ?: currentState.currentPage + } + } + + return resolvedPage.coerceIn( + minimumValue = 0, + maximumValue = items.lastIndex, + ) + } + + private fun findMatchingItemIndex( + items: ImmutableList, + currentItem: PhotoViewerItem, + ): Int { + val currentItemUri = normalizePhotoViewerUri(uri = currentItem.contentUri) + + return items.indexOfFirst { candidate -> + normalizePhotoViewerUri(uri = candidate.contentUri) == currentItemUri + } + } + + private fun normalizePhotoViewerUri(uri: Uri): String { + return uri + .buildUpon() + .clearQuery() + .fragment(null) + .build() + .toString() + } + + private companion object { + const val TAG = "PhotoViewerViewModel" + } +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerEffect.kt b/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerEffect.kt new file mode 100644 index 000000000..917971607 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerEffect.kt @@ -0,0 +1,22 @@ +package com.android.messaging.ui.photoviewer.screen.model + +import android.net.Uri + +internal sealed interface PhotoViewerEffect { + data object Finish : PhotoViewerEffect + + data class Save( + val uri: Uri, + val contentType: String, + ) : PhotoViewerEffect + + data class Share( + val uri: Uri, + val contentType: String, + ) : PhotoViewerEffect + + data class Forward( + val uri: Uri, + val contentType: String, + ) : PhotoViewerEffect +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerUiState.kt b/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerUiState.kt new file mode 100644 index 000000000..cea80a255 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/model/PhotoViewerUiState.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.photoviewer.screen.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.media.model.PhotoViewerItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class PhotoViewerUiState( + val loadState: PhotoViewerLoadState = PhotoViewerLoadState.Loading, + val items: ImmutableList = persistentListOf(), + val currentPage: Int = 0, + val displayMode: PhotoViewerDisplayMode = PhotoViewerDisplayMode.Carousel, + val isMetadataSheetVisible: Boolean = false, + val isClosing: Boolean = false, +) + +internal enum class PhotoViewerLoadState { + Loading, + Loaded, + Empty, + Error, +} + +internal enum class PhotoViewerDisplayMode { + Carousel, + Immersive, +} diff --git a/src/com/android/messaging/util/db/ext/CursorExtensions.kt b/src/com/android/messaging/util/db/ext/CursorExtensions.kt index 49bf3d6e9..924a5084b 100644 --- a/src/com/android/messaging/util/db/ext/CursorExtensions.kt +++ b/src/com/android/messaging/util/db/ext/CursorExtensions.kt @@ -8,6 +8,10 @@ fun Cursor.getStringOrNull(columnName: String): String? { .let(::getStringOrNull) } +fun Cursor.getNonBlankStringOrNull(columnIndex: Int): String? { + return getStringOrNull(columnIndex)?.takeIf { it.isNotBlank() } +} + fun Cursor.getStringOrEmpty(columnName: String): String { return getStringOrNull(columnName = columnName).orEmpty() } From f7bfe8e0a95ad43be1accdb6c6de01f3262fe5c5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 1 Jul 2026 21:07:33 +0300 Subject: [PATCH 02/19] Fix pinch-to-zoom issues --- .../screen/PhotoViewerScreenContentTest.kt | 106 +++++++++++ .../component/PhotoViewerTransformInput.kt | 131 ++++++++++++++ .../component/ZoomablePhotoContainer.kt | 169 +++++++++++++----- 3 files changed, 361 insertions(+), 45 deletions(-) create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt index 349181223..edf882788 100644 --- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt +++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.TouchInjectionScope import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.doubleClick @@ -166,6 +167,81 @@ internal class PhotoViewerScreenContentTest { } } + @Test + fun simultaneousPinchOutPhoto_entersImmersiveMode() { + val enterImmersiveCount = AtomicInteger() + val uiState = setScreenContent( + onEnterImmersiveMode = { + enterImmersiveCount.incrementAndGet() + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG) + .performTouchInput { + pinchOutPhoto(moveFirstPointerBeforeSecondDown = false) + } + + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + enterImmersiveCount.get() >= 1 && + uiState.value.displayMode == PhotoViewerDisplayMode.Immersive + } + } + + @Test + fun staggeredPinchOutPhoto_entersImmersiveMode() { + val enterImmersiveCount = AtomicInteger() + val uiState = setScreenContent( + onEnterImmersiveMode = { + enterImmersiveCount.incrementAndGet() + }, + ) + + composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG) + .performTouchInput { + pinchOutPhoto(moveFirstPointerBeforeSecondDown = true) + } + + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + enterImmersiveCount.get() >= 1 && + uiState.value.displayMode == PhotoViewerDisplayMode.Immersive + } + } + + @Test + fun panZoomedPhoto_doesNotDismissViewer() { + val closeClickCount = AtomicInteger() + val enterImmersiveCount = AtomicInteger() + val uiState = setScreenContent( + onEnterImmersiveMode = { + enterImmersiveCount.incrementAndGet() + }, + onCloseClick = { + closeClickCount.incrementAndGet() + }, + ) + + val photoNode = composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG) + photoNode.performTouchInput { + doubleClick() + } + composeRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + enterImmersiveCount.get() == 1 && + uiState.value.displayMode == PhotoViewerDisplayMode.Immersive + } + + photoNode.performTouchInput { + swipe( + start = center, + end = Offset(x = center.x + 120f, y = center.y + 80f), + durationMillis = 200, + ) + } + + composeRule.runOnIdle { + assertEquals(0, closeClickCount.get()) + } + } + @Test fun swipeDownPhotoPastDismissThreshold_emitsCloseCallback() { val closeClickCount = AtomicInteger() @@ -333,6 +409,36 @@ internal class PhotoViewerScreenContentTest { } } + private fun TouchInjectionScope.pinchOutPhoto(moveFirstPointerBeforeSecondDown: Boolean) { + val firstPointerStart = Offset( + x = center.x - 40f, + y = center.y, + ) + val secondPointerStart = Offset( + x = center.x + 40f, + y = center.y, + ) + + down(pointerId = 0, position = firstPointerStart) + if (moveFirstPointerBeforeSecondDown) { + moveBy( + pointerId = 0, + delta = Offset(x = 0f, y = viewConfiguration.touchSlop + 8f), + delayMillis = 40, + ) + } + down(pointerId = 1, position = secondPointerStart) + + repeat(times = 8) { + updatePointerBy(pointerId = 0, delta = Offset(x = -32f, y = -8f)) + updatePointerBy(pointerId = 1, delta = Offset(x = 32f, y = 8f)) + move(delayMillis = 16) + } + + up(pointerId = 0) + up(pointerId = 1) + } + private companion object { const val FIRST_SENDER = "Ada Lovelace" const val IMAGE_JPEG = "image/jpeg" diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt new file mode 100644 index 000000000..352476915 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt @@ -0,0 +1,131 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroidSize +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import kotlin.math.abs + +internal fun Modifier.photoViewerTransformInput( + contentKey: PhotoViewerContentKey, + gestureState: ZoomablePhotoGestureState, + currentOnEnterImmersiveMode: State<() -> Unit>, +): Modifier { + return pointerInput(contentKey) { + detectPhotoViewerTransformGestures( + gestureState = gestureState, + currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, + ) + } +} + +private suspend fun PointerInputScope.detectPhotoViewerTransformGestures( + gestureState: ZoomablePhotoGestureState, + currentOnEnterImmersiveMode: State<() -> Unit>, +) { + val touchSlop = viewConfiguration.touchSlop + + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var accumulatedZoom = 1f + var accumulatedPan = Offset.Zero + var isTransformActive = false + var pressedPointerCount: Int + + do { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + pressedPointerCount = event.pressedPointerCount() + val zoomChange = event.photoViewerZoomChange(pressedPointerCount = pressedPointerCount) + val panChange = event.photoViewerPanChange(gestureState = gestureState) + + if (!isTransformActive) { + accumulatedZoom *= zoomChange + accumulatedPan += panChange + isTransformActive = event.shouldStartPhotoViewerTransform( + gestureState = gestureState, + accumulatedZoom = accumulatedZoom, + accumulatedPan = accumulatedPan, + touchSlop = touchSlop, + pressedPointerCount = pressedPointerCount, + ) + } + + if (isTransformActive) { + handlePhotoViewerTransform( + event = event, + gestureState = gestureState, + zoomChange = zoomChange, + panChange = panChange, + currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, + ) + } + } while (pressedPointerCount > 0) + } +} + +private fun PointerEvent.pressedPointerCount(): Int { + return changes.count { change -> + change.pressed + } +} + +private fun PointerEvent.photoViewerZoomChange(pressedPointerCount: Int): Float { + return when { + pressedPointerCount > 1 -> calculateZoom() + else -> 1f + } +} + +private fun PointerEvent.photoViewerPanChange( + gestureState: ZoomablePhotoGestureState, +): Offset { + return when { + gestureState.isZoomed() -> calculatePan() + else -> Offset.Zero + } +} + +private fun PointerEvent.shouldStartPhotoViewerTransform( + gestureState: ZoomablePhotoGestureState, + accumulatedZoom: Float, + accumulatedPan: Offset, + touchSlop: Float, + pressedPointerCount: Int, +): Boolean { + val centroidSize = calculateCentroidSize(useCurrent = false) + val zoomMotion = abs(1f - accumulatedZoom) * centroidSize + val panMotion = accumulatedPan.getDistance() + + return when { + pressedPointerCount > 1 && zoomMotion > touchSlop -> true + gestureState.isZoomed() && panMotion > touchSlop -> true + else -> false + } +} + +private fun handlePhotoViewerTransform( + event: PointerEvent, + gestureState: ZoomablePhotoGestureState, + zoomChange: Float, + panChange: Offset, + currentOnEnterImmersiveMode: State<() -> Unit>, +) { + if (gestureState.applyTransform(zoomChange = zoomChange, panChange = panChange)) { + currentOnEnterImmersiveMode.value() + } + + event.changes.forEach { change -> + if (change.positionChanged()) { + change.consume() + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt index a60ce2e9f..c5ab3033c 100644 --- a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt +++ b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt @@ -6,11 +6,9 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.TransformableState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.gestures.rememberTransformableState -import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,9 +20,12 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.onSizeChanged import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode +import kotlin.math.abs import kotlinx.coroutines.flow.distinctUntilChanged private const val PHOTO_VIEWER_DOUBLE_TAP_ZOOM_ANIMATION_DURATION_MILLIS = 200 @@ -45,11 +46,6 @@ internal fun ZoomablePhotoContainer( val currentOnToggleDisplayMode = rememberUpdatedState(newValue = onToggleDisplayMode) val currentOnEnterImmersiveMode = rememberUpdatedState(newValue = onEnterImmersiveMode) val currentOnCloseClick = rememberUpdatedState(newValue = onCloseClick) - val transformableState = rememberTransformableState { _, zoomChange, panChange, _ -> - if (gestureState.applyTransform(zoomChange = zoomChange, panChange = panChange)) { - currentOnEnterImmersiveMode.value() - } - } val zoomAnimationSpec = photoViewerZoomAnimationSpec( isEnabled = gestureState.isZoomAnimationEnabled, ) @@ -80,7 +76,6 @@ internal fun ZoomablePhotoContainer( .photoViewerGestureModifier( contentKey = contentKey, gestureState = gestureState, - transformableState = transformableState, animatedScale = animatedScale, animatedOffset = animatedOffset, dismissDragState = dismissDragState, @@ -122,7 +117,6 @@ private fun PhotoViewerGestureEffects( private fun Modifier.photoViewerGestureModifier( contentKey: PhotoViewerContentKey, gestureState: ZoomablePhotoGestureState, - transformableState: TransformableState, animatedScale: State, animatedOffset: State, dismissDragState: PhotoViewerDismissDragState?, @@ -139,18 +133,17 @@ private fun Modifier.photoViewerGestureModifier( currentOnToggleDisplayMode = currentOnToggleDisplayMode, currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, ) + .photoViewerTransformInput( + contentKey = contentKey, + gestureState = gestureState, + currentOnEnterImmersiveMode = currentOnEnterImmersiveMode, + ) .photoViewerDismissDragInput( contentKey = contentKey, gestureState = gestureState, dismissDragState = dismissDragState, currentOnCloseClick = currentOnCloseClick, ) - .transformable( - state = transformableState, - canPan = { - gestureState.isZoomed() - }, - ) .graphicsLayer { val dismissDragOffset = dismissDragState?.animatedDragOffset ?: 0f val zoomScale = animatedScale.value @@ -189,40 +182,126 @@ private fun Modifier.photoViewerDismissDragInput( dismissDragState: PhotoViewerDismissDragState?, currentOnCloseClick: State<() -> Unit>, ): Modifier { - return pointerInput( - contentKey, - dismissDragState, - ) { - detectVerticalDragGestures( - onVerticalDrag = { change, dragAmount -> - dismissDragState?.let { activeDismissDragState -> - val canHandleDismissDrag = activeDismissDragState.canHandleDrag( - isZoomed = gestureState.isZoomed(), - dragAmount = dragAmount, + return when { + dismissDragState == null -> this + else -> { + pointerInput( + contentKey, + dismissDragState, + ) { + detectPhotoViewerDismissDrag( + gestureState = gestureState, + dismissDragState = dismissDragState, + currentOnCloseClick = currentOnCloseClick, + ) + } + } + } +} + +private suspend fun PointerInputScope.detectPhotoViewerDismissDrag( + gestureState: ZoomablePhotoGestureState, + dismissDragState: PhotoViewerDismissDragState, + currentOnCloseClick: State<() -> Unit>, +) { + val touchSlop = viewConfiguration.touchSlop + + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var accumulatedDrag = Offset.Zero + var isDismissDragActive = false + var isGestureActive = true + + while (isGestureActive) { + val event = awaitPointerEvent() + val pressedChanges = event.changes.filter { change -> + change.pressed + } + + when { + pressedChanges.isEmpty() -> { + finishPhotoViewerDismissDrag( + dismissDragState = dismissDragState, + currentOnCloseClick = currentOnCloseClick, ) + isGestureActive = false + } - if (canHandleDismissDrag) { - change.consume() - activeDismissDragState.activate() - activeDismissDragState.applyDrag(dragAmount = dragAmount) - } + pressedChanges.size > 1 -> { + dismissDragState.reset() + isGestureActive = false } - }, - onDragEnd = { - when { - dismissDragState?.shouldCloseOnDragEnd() == true -> { - currentOnCloseClick.value() - } - else -> { - dismissDragState?.reset() + else -> { + val change = pressedChanges.first() + val positionChange = change.positionChange() + accumulatedDrag += positionChange + + isDismissDragActive = handlePhotoViewerDismissDrag( + gestureState = gestureState, + dismissDragState = dismissDragState, + accumulatedDrag = accumulatedDrag, + dragAmount = positionChange.y, + isDismissDragActive = isDismissDragActive, + touchSlop = touchSlop, + ) + + if (isDismissDragActive) { + change.consume() } } - }, - onDragCancel = { - dismissDragState?.reset() - }, - ) + } + } + } +} + +private fun handlePhotoViewerDismissDrag( + gestureState: ZoomablePhotoGestureState, + dismissDragState: PhotoViewerDismissDragState, + accumulatedDrag: Offset, + dragAmount: Float, + isDismissDragActive: Boolean, + touchSlop: Float, +): Boolean { + val canHandleDismissDrag = dismissDragState.canHandleDrag( + isZoomed = gestureState.isZoomed(), + dragAmount = dragAmount, + ) + val shouldStartDismissDrag = isDismissDragActive || + accumulatedDrag.shouldStartPhotoViewerDismissDrag(touchSlop = touchSlop) + + val dismissDragAmount = when { + isDismissDragActive -> dragAmount + else -> { + (accumulatedDrag.y - touchSlop) + .coerceAtLeast(minimumValue = 0f) + } + } + + if (canHandleDismissDrag && shouldStartDismissDrag) { + dismissDragState.activate() + dismissDragState.applyDrag(dragAmount = dismissDragAmount) + } + + return canHandleDismissDrag && shouldStartDismissDrag +} + +private fun Offset.shouldStartPhotoViewerDismissDrag(touchSlop: Float): Boolean { + return y > touchSlop && abs(y) >= abs(x) +} + +private fun finishPhotoViewerDismissDrag( + dismissDragState: PhotoViewerDismissDragState, + currentOnCloseClick: State<() -> Unit>, +) { + when { + dismissDragState.shouldCloseOnDragEnd() -> { + currentOnCloseClick.value() + } + + else -> { + dismissDragState.reset() + } } } From 06a796f05c5cd33a17a0f86c37350a825e13af7b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Jul 2026 00:01:40 +0300 Subject: [PATCH 03/19] Fix photo viewer gestures and system bars --- .../PhotoViewerGestureDecisionTest.kt | 130 ++++++++++++++++++ .../screen/PhotoViewerViewModelTest.kt | 40 ++++++ .../messaging/ui/core/ContextExtensions.kt | 14 ++ .../ui/photoviewer/PhotoViewerActivity.kt | 7 +- .../component/PhotoViewerTransformInput.kt | 13 +- .../component/ZoomablePhotoContainer.kt | 51 +++++-- .../photoviewer/screen/PhotoViewerScreen.kt | 1 + .../screen/PhotoViewerSystemBarsEffect.kt | 39 ++++++ .../screen/PhotoViewerViewModel.kt | 1 - 9 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerGestureDecisionTest.kt create mode 100644 src/com/android/messaging/ui/core/ContextExtensions.kt create mode 100644 src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerGestureDecisionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerGestureDecisionTest.kt new file mode 100644 index 000000000..b396a7f7b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/component/PhotoViewerGestureDecisionTest.kt @@ -0,0 +1,130 @@ +package com.android.messaging.ui.photoviewer.component + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val FLOAT_DELTA = 0.001f +private const val TEST_TOUCH_SLOP = 18f + +internal class PhotoViewerGestureDecisionTest { + + @Test + fun shouldStartPhotoViewerTransform_whenPinchMotionExceedsTouchSlop_returnsTrue() { + val shouldStart = shouldStartPhotoViewerTransform( + isZoomed = false, + accumulatedZoom = 1.2f, + centroidSize = 100f, + accumulatedPan = Offset.Zero, + touchSlop = TEST_TOUCH_SLOP, + pressedPointerCount = 2, + ) + + assertTrue(shouldStart) + } + + @Test + fun shouldStartPhotoViewerTransform_whenPinchMotionIsBelowTouchSlop_returnsFalse() { + val shouldStart = shouldStartPhotoViewerTransform( + isZoomed = false, + accumulatedZoom = 1.1f, + centroidSize = 100f, + accumulatedPan = Offset.Zero, + touchSlop = TEST_TOUCH_SLOP, + pressedPointerCount = 2, + ) + + assertFalse(shouldStart) + } + + @Test + fun shouldStartPhotoViewerTransform_whenZoomedPanExceedsTouchSlop_returnsTrue() { + val shouldStart = shouldStartPhotoViewerTransform( + isZoomed = true, + accumulatedZoom = 1f, + centroidSize = 0f, + accumulatedPan = Offset(x = TEST_TOUCH_SLOP + 1f, y = 0f), + touchSlop = TEST_TOUCH_SLOP, + pressedPointerCount = 1, + ) + + assertTrue(shouldStart) + } + + @Test + fun shouldStartPhotoViewerTransform_whenUnzoomedSinglePointerPans_returnsFalse() { + val shouldStart = shouldStartPhotoViewerTransform( + isZoomed = false, + accumulatedZoom = 1f, + centroidSize = 0f, + accumulatedPan = Offset(x = TEST_TOUCH_SLOP + 1f, y = 0f), + touchSlop = TEST_TOUCH_SLOP, + pressedPointerCount = 1, + ) + + assertFalse(shouldStart) + } + + @Test + fun resolvePhotoViewerDismissDragDecision_whenDownwardDragStarts_appliesSlopSubtraction() { + val decision = resolvePhotoViewerDismissDragDecision( + canHandleDismissDrag = true, + accumulatedDrag = Offset(x = 4f, y = 30f), + dragAmount = 30f, + isDismissDragActive = false, + touchSlop = TEST_TOUCH_SLOP, + ) + + assertTrue(decision.isActive) + assertEquals(12f, decision.dragAmount, FLOAT_DELTA) + } + + @Test + fun resolvePhotoViewerDismissDragDecision_whenDismissDragIsActive_appliesPerEventDelta() { + val decision = resolvePhotoViewerDismissDragDecision( + canHandleDismissDrag = true, + accumulatedDrag = Offset(x = 0f, y = 200f), + dragAmount = -8f, + isDismissDragActive = true, + touchSlop = TEST_TOUCH_SLOP, + ) + + assertTrue(decision.isActive) + assertEquals(-8f, decision.dragAmount, FLOAT_DELTA) + } + + @Test + fun resolvePhotoViewerDismissDragDecision_whenImageIsZoomed_returnsInactiveDecision() { + val decision = resolvePhotoViewerDismissDragDecision( + canHandleDismissDrag = false, + accumulatedDrag = Offset(x = 0f, y = TEST_TOUCH_SLOP + 20f), + dragAmount = TEST_TOUCH_SLOP + 20f, + isDismissDragActive = false, + touchSlop = TEST_TOUCH_SLOP, + ) + + assertFalse(decision.isActive) + } + + @Test + fun shouldStartPhotoViewerDismissDrag_whenHorizontalDragDominates_returnsFalse() { + val shouldStart = shouldStartPhotoViewerDismissDrag( + accumulatedDrag = Offset(x = TEST_TOUCH_SLOP + 20f, y = TEST_TOUCH_SLOP + 1f), + touchSlop = TEST_TOUCH_SLOP, + ) + + assertFalse(shouldStart) + } + + @Test + fun shouldStartPhotoViewerDismissDrag_whenDragMovesUp_returnsFalse() { + val shouldStart = shouldStartPhotoViewerDismissDrag( + accumulatedDrag = Offset(x = 0f, y = -TEST_TOUCH_SLOP - 20f), + touchSlop = TEST_TOUCH_SLOP, + ) + + assertFalse(shouldStart) + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt index ac7840969..9820477f0 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt @@ -238,6 +238,46 @@ internal class PhotoViewerViewModelTest { } } + @Test + fun closeClick_whenInCarousel_preservesCarouselDisplayMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + loadInitialItems( + repositoryResults = repositoryResults, + viewModel = viewModel, + ) + viewModel.onMetadataClick() + viewModel.onCloseClick() + + assertEquals(PhotoViewerDisplayMode.Carousel, viewModel.uiState.value.displayMode) + assertEquals(false, viewModel.uiState.value.isMetadataSheetVisible) + assertEquals(true, viewModel.uiState.value.isClosing) + } + } + + @Test + fun closeClick_whenInImmersive_preservesImmersiveDisplayMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repositoryResults = MutableSharedFlow(replay = 1) + val repository = mockPhotoViewerRepository(results = repositoryResults) + val viewModel = createViewModel(repository = repository) + + loadInitialItems( + repositoryResults = repositoryResults, + viewModel = viewModel, + ) + viewModel.onEnterImmersiveMode() + viewModel.onCloseClick() + + assertEquals(PhotoViewerDisplayMode.Immersive, viewModel.uiState.value.displayMode) + assertEquals(false, viewModel.uiState.value.isMetadataSheetVisible) + assertEquals(true, viewModel.uiState.value.isClosing) + } + } + @Test fun closeAnimationFinished_whenCalledTwice_emitsFinishOnce() { runTest(context = mainDispatcherRule.testDispatcher) { diff --git a/src/com/android/messaging/ui/core/ContextExtensions.kt b/src/com/android/messaging/ui/core/ContextExtensions.kt new file mode 100644 index 000000000..7ab0dbc4b --- /dev/null +++ b/src/com/android/messaging/ui/core/ContextExtensions.kt @@ -0,0 +1,14 @@ +package com.android.messaging.ui.core + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.Window + +internal fun Context.findActivityWindow(): Window? { + return when (this) { + is Activity -> window + is ContextWrapper -> baseContext.findActivityWindow() + else -> null + } +} diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt index 4bc0cbd4e..1e75b04aa 100644 --- a/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt +++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt @@ -2,10 +2,12 @@ package com.android.messaging.ui.photoviewer import android.content.Context import android.content.Intent +import android.graphics.Color import android.graphics.Rect import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import com.android.messaging.ui.core.AppTheme @@ -20,7 +22,10 @@ internal class PhotoViewerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(scrim = Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(scrim = Color.TRANSPARENT), + ) val launchRequest = toPhotoViewerLaunchRequest(intent = intent) ?: run { finish() diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt index 352476915..6ec982a13 100644 --- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt +++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt @@ -50,9 +50,10 @@ private suspend fun PointerInputScope.detectPhotoViewerTransformGestures( if (!isTransformActive) { accumulatedZoom *= zoomChange accumulatedPan += panChange - isTransformActive = event.shouldStartPhotoViewerTransform( - gestureState = gestureState, + isTransformActive = shouldStartPhotoViewerTransform( + isZoomed = gestureState.isZoomed(), accumulatedZoom = accumulatedZoom, + centroidSize = event.calculateCentroidSize(useCurrent = false), accumulatedPan = accumulatedPan, touchSlop = touchSlop, pressedPointerCount = pressedPointerCount, @@ -94,20 +95,20 @@ private fun PointerEvent.photoViewerPanChange( } } -private fun PointerEvent.shouldStartPhotoViewerTransform( - gestureState: ZoomablePhotoGestureState, +internal fun shouldStartPhotoViewerTransform( + isZoomed: Boolean, accumulatedZoom: Float, + centroidSize: Float, accumulatedPan: Offset, touchSlop: Float, pressedPointerCount: Int, ): Boolean { - val centroidSize = calculateCentroidSize(useCurrent = false) val zoomMotion = abs(1f - accumulatedZoom) * centroidSize val panMotion = accumulatedPan.getDistance() return when { pressedPointerCount > 1 && zoomMotion > touchSlop -> true - gestureState.isZoomed() && panMotion > touchSlop -> true + isZoomed && panMotion > touchSlop -> true else -> false } } diff --git a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt index c5ab3033c..7d557309f 100644 --- a/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt +++ b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt @@ -267,8 +267,34 @@ private fun handlePhotoViewerDismissDrag( isZoomed = gestureState.isZoomed(), dragAmount = dragAmount, ) + val decision = resolvePhotoViewerDismissDragDecision( + canHandleDismissDrag = canHandleDismissDrag, + accumulatedDrag = accumulatedDrag, + dragAmount = dragAmount, + isDismissDragActive = isDismissDragActive, + touchSlop = touchSlop, + ) + + if (decision.isActive) { + dismissDragState.activate() + dismissDragState.applyDrag(dragAmount = decision.dragAmount) + } + + return decision.isActive +} + +internal fun resolvePhotoViewerDismissDragDecision( + canHandleDismissDrag: Boolean, + accumulatedDrag: Offset, + dragAmount: Float, + isDismissDragActive: Boolean, + touchSlop: Float, +): PhotoViewerDismissDragDecision { val shouldStartDismissDrag = isDismissDragActive || - accumulatedDrag.shouldStartPhotoViewerDismissDrag(touchSlop = touchSlop) + shouldStartPhotoViewerDismissDrag( + accumulatedDrag = accumulatedDrag, + touchSlop = touchSlop, + ) val dismissDragAmount = when { isDismissDragActive -> dragAmount @@ -278,18 +304,25 @@ private fun handlePhotoViewerDismissDrag( } } - if (canHandleDismissDrag && shouldStartDismissDrag) { - dismissDragState.activate() - dismissDragState.applyDrag(dragAmount = dismissDragAmount) - } - - return canHandleDismissDrag && shouldStartDismissDrag + return PhotoViewerDismissDragDecision( + isActive = canHandleDismissDrag && shouldStartDismissDrag, + dragAmount = dismissDragAmount, + ) } -private fun Offset.shouldStartPhotoViewerDismissDrag(touchSlop: Float): Boolean { - return y > touchSlop && abs(y) >= abs(x) +internal fun shouldStartPhotoViewerDismissDrag( + accumulatedDrag: Offset, + touchSlop: Float, +): Boolean { + return accumulatedDrag.y > touchSlop && + abs(accumulatedDrag.y) >= abs(accumulatedDrag.x) } +internal data class PhotoViewerDismissDragDecision( + val isActive: Boolean, + val dragAmount: Float, +) + private fun finishPhotoViewerDismissDrag( dismissDragState: PhotoViewerDismissDragState, currentOnCloseClick: State<() -> Unit>, diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt index a179ecc6f..ccaa34d89 100644 --- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt @@ -39,6 +39,7 @@ internal fun PhotoViewerScreen( screenModel = screenModel, onFinish = onFinish, ) + PhotoViewerSystemBarsEffect(displayMode = uiState.displayMode) BackHandler(enabled = uiState.isMetadataSheetVisible) { screenModel.onMetadataDismissed() diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt new file mode 100644 index 000000000..8c1fd3b40 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt @@ -0,0 +1,39 @@ +package com.android.messaging.ui.photoviewer.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.android.messaging.ui.core.findActivityWindow +import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode + +@Composable +internal fun PhotoViewerSystemBarsEffect( + displayMode: PhotoViewerDisplayMode, +) { + val view = LocalView.current + val window = view.context.findActivityWindow() + + DisposableEffect(window, view, displayMode) { + val controller = window?.let { + WindowInsetsControllerCompat( + it, + view, + ) + } + + controller?.systemBarsBehavior = WindowInsetsControllerCompat + .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + val systemBars = WindowInsetsCompat.Type.systemBars() + when (displayMode) { + PhotoViewerDisplayMode.Carousel -> controller?.show(systemBars) + PhotoViewerDisplayMode.Immersive -> controller?.hide(systemBars) + } + + onDispose { + controller?.show(systemBars) + } + } +} diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt index 81fa41b1b..d631a53d8 100644 --- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt @@ -166,7 +166,6 @@ internal class PhotoViewerViewModel @Inject constructor( state.isClosing -> state else -> { state.copy( - displayMode = PhotoViewerDisplayMode.Immersive, isMetadataSheetVisible = false, isClosing = true, ) From 791b244f5d92e6db1b70f04ee888788e28084aef Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Jul 2026 00:03:08 +0300 Subject: [PATCH 04/19] Refine photo viewer system bar effect lifecycle --- .../screen/PhotoViewerSystemBarsEffect.kt | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt index 8c1fd3b40..405da79d7 100644 --- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt +++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt @@ -1,7 +1,10 @@ package com.android.messaging.ui.photoviewer.screen +import android.view.View +import android.view.Window import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -15,25 +18,41 @@ internal fun PhotoViewerSystemBarsEffect( val view = LocalView.current val window = view.context.findActivityWindow() - DisposableEffect(window, view, displayMode) { - val controller = window?.let { - WindowInsetsControllerCompat( - it, - view, - ) - } - + DisposableEffect(window, view) { + val controller = createWindowInsetsController( + window = window, + view = view, + ) controller?.systemBarsBehavior = WindowInsetsControllerCompat .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + val systemBars = WindowInsetsCompat.Type.systemBars() + onDispose { + controller?.show(systemBars) + } + } + + LaunchedEffect(window, view, displayMode) { + val controller = createWindowInsetsController( + window = window, + view = view, + ) val systemBars = WindowInsetsCompat.Type.systemBars() when (displayMode) { PhotoViewerDisplayMode.Carousel -> controller?.show(systemBars) PhotoViewerDisplayMode.Immersive -> controller?.hide(systemBars) } + } +} - onDispose { - controller?.show(systemBars) - } +private fun createWindowInsetsController( + window: Window?, + view: View, +): WindowInsetsControllerCompat? { + return window?.let { + WindowInsetsControllerCompat( + it, + view, + ) } } From c4f4381a43172f9a51519ee7e764ab92c03168b4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Jul 2026 00:19:46 +0300 Subject: [PATCH 05/19] Update licenses --- assets/licenses.html | 410 ++++++++++++++++++++++++++----------------- 1 file changed, 246 insertions(+), 164 deletions(-) diff --git a/assets/licenses.html b/assets/licenses.html index 58b5467f9..e6466703f 100644 --- a/assets/licenses.html +++ b/assets/licenses.html @@ -896,88 +896,6 @@ -
-Notice for Android Support AnimatedVectorDrawable (androidx.vectordrawable:vectordrawable-animated:1.1.0) -
-Source: https://developer.android.com/jetpack/androidx
-
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
-
-"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
-
-"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
-
-"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
-
-"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
-
-"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
-
-"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
-
-"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
-
-"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
-
-"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
-
-     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
-
-     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
-
-     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
-
-     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
-
-     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-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.
-
-
-
-
Notice for Android Support DynamicAnimation (androidx.dynamicanimation:dynamicanimation:1.0.0)
@@ -2126,88 +2044,6 @@
 
-
-Notice for Android Support VectorDrawable (androidx.vectordrawable:vectordrawable:1.1.0) -
-Source: https://developer.android.com/jetpack/androidx
-
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
-
-TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
-
-"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
-
-"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
-
-"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
-
-"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
-
-"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
-
-"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
-
-"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
-
-"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
-
-"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
-
-     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
-
-     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
-
-     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
-
-     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
-
-     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-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.
-
-
-
-
Notice for AndroidX Autofill (androidx.autofill:autofill:1.0.0)
@@ -5156,6 +4992,88 @@
 
+
+Notice for androidx.vectordrawable:vectordrawable:1.2.0 +
+Source: https://developer.android.com/jetpack/androidx/releases/vectordrawable#1.2.0
+
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+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.
+
+
+
+
Notice for androidx.versionedparcelable:versionedparcelable:1.1.1
@@ -5238,6 +5156,88 @@
 
+
+Notice for AnimatedVectorDrawable (androidx.vectordrawable:vectordrawable-animated:1.2.0) +
+Source: https://developer.android.com/jetpack/androidx/releases/vectordrawable#1.2.0
+
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+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.
+
+
+
+
Notice for Annotation (androidx.annotation:annotation-jvm:1.9.1)
@@ -14415,6 +14415,88 @@
 
+
+Notice for io.coil-kt.coil3:coil-gif:3.4.0 +
+Source: https://github.com/coil-kt/coil
+
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+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.
+
+
+
+
Notice for io.coil-kt.coil3:coil-network-core:3.4.0

From 563fa759010c6b9fdf0523ffc7a5ad5904140b8a Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Wed, 8 Jul 2026 17:32:31 +0300
Subject: [PATCH 06/19] Restore photo viewer action gating and safe send URI
 prep

---
 .idea/vcs.xml                                 |   1 +
 .../screen/PhotoViewerScreenContentTest.kt    |  38 ++++-
 .../PhotoViewerRepositoryImplTest.kt          |  29 ++++
 .../PreparePhotoViewerSendUriImplTest.kt      |  77 ++++++++++
 .../screen/PhotoViewerViewModelTest.kt        | 144 ++++++++++++++++--
 .../data/media/model/PhotoViewerItem.kt       |   1 +
 .../media/repository/PhotoViewerRepository.kt |   6 +-
 .../di/photoviewer/PhotoViewerBindsModule.kt  |   8 +
 .../usecase/PreparePhotoViewerSendUri.kt      |  46 ++++++
 .../host/share/ShareIntentActivity.kt         |  30 +++-
 .../photoviewer/screen/PhotoViewerScreen.kt   |   2 +-
 .../screen/PhotoViewerScreenEffects.kt        |  36 +----
 .../screen/PhotoViewerViewModel.kt            |  42 +++--
 13 files changed, 400 insertions(+), 60 deletions(-)
 create mode 100644 app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
 create mode 100644 src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt

diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index e0146fae7..4d760f2f2 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -12,6 +12,7 @@
     
     
     
+    
     
   
 
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
index edf882788..dc3fcbfee 100644
--- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
+++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
@@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.test.TouchInjectionScope
 import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
 import androidx.compose.ui.test.assertTextContains
 import androidx.compose.ui.test.doubleClick
 import androidx.compose.ui.test.junit4.v2.createComposeRule
@@ -147,6 +148,23 @@ internal class PhotoViewerScreenContentTest {
         }
     }
 
+    @Test
+    fun chromeActions_whenCurrentItemDisallowsActions_areDisabled() {
+        val uiState = loadedPhotoViewerUiState(
+            firstItemCanUseActions = false,
+        )
+        setScreenContent(uiState = uiState)
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG)
+            .assertIsNotEnabled()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG)
+            .assertIsNotEnabled()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG)
+            .performClick()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG)
+            .assertIsNotEnabled()
+    }
+
     @Test
     fun doubleTapPhoto_entersImmersiveMode() {
         val enterImmersiveCount = AtomicInteger()
@@ -320,6 +338,7 @@ internal class PhotoViewerScreenContentTest {
     }
 
     private fun setScreenContent(
+        uiState: PhotoViewerUiState = loadedPhotoViewerUiState(),
         onPageSettled: (Int) -> Unit = {},
         onToggleDisplayMode: () -> Unit = {},
         onEnterImmersiveMode: () -> Unit = {},
@@ -328,7 +347,7 @@ internal class PhotoViewerScreenContentTest {
         onSaveClick: () -> Unit = {},
         onShareClick: () -> Unit = {},
     ): MutableState {
-        val uiState = mutableStateOf(value = loadedPhotoViewerUiState())
+        val uiState = mutableStateOf(value = uiState)
 
         composeRule.setContent {
             AppTheme {
@@ -371,17 +390,27 @@ internal class PhotoViewerScreenContentTest {
         return uiState
     }
 
-    private fun loadedPhotoViewerUiState(): PhotoViewerUiState {
+    private fun loadedPhotoViewerUiState(
+        firstItemCanUseActions: Boolean = true,
+    ): PhotoViewerUiState {
         return PhotoViewerUiState(
             items = persistentListOf(
-                photoViewerItem(index = 1, senderName = FIRST_SENDER),
+                photoViewerItem(
+                    index = 1,
+                    senderName = FIRST_SENDER,
+                    canUseActions = firstItemCanUseActions,
+                ),
                 photoViewerItem(index = 2, senderName = SECOND_SENDER),
             ),
             loadState = PhotoViewerLoadState.Loaded,
         )
     }
 
-    private fun photoViewerItem(index: Int, senderName: String): PhotoViewerItem {
+    private fun photoViewerItem(
+        index: Int,
+        senderName: String,
+        canUseActions: Boolean = true,
+    ): PhotoViewerItem {
         return PhotoViewerItem(
             contentUri = photoViewerImageUri(),
             contentType = IMAGE_JPEG,
@@ -389,6 +418,7 @@ internal class PhotoViewerScreenContentTest {
             senderDestination = "+1555000$index",
             receivedTimestampMillis = 1_735_689_600_000L + index,
             isDraft = false,
+            canUseActions = canUseActions,
         )
     }
 
diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
index 8bf74a184..1d965020f 100644
--- a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
@@ -9,6 +9,7 @@ import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.datamodel.ConversationImagePartsView
+import com.android.messaging.datamodel.MediaScratchFileProvider
 import com.android.messaging.datamodel.data.MessageData
 import io.mockk.every
 import io.mockk.mockk
@@ -112,6 +113,34 @@ internal class PhotoViewerRepositoryImplTest {
         }
     }
 
+    @Test
+    fun getPhotoViewerItems_whenContentUriIsScratchSpace_marksActionsUnavailable() {
+        runTest(context = testDispatcher) {
+            val scratchUri = Uri.parse(
+                "content://${MediaScratchFileProvider.AUTHORITY}/800001?ext=jpg",
+            )
+            val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply {
+                addPhotoRow(
+                    uri = "content://example/photo/1",
+                    senderName = "Ada",
+                    contentUri = scratchUri.toString(),
+                    contentType = IMAGE_JPEG,
+                    senderDestination = "+15550001",
+                    receivedTimestampMillis = 1000L,
+                    status = MessageData.BUGLE_STATUS_OUTGOING_DRAFT,
+                )
+            }
+            stubContentResolver(cursor = cursor)
+
+            val result = repository.getPhotoViewerItems(
+                photosUri = photosUri,
+                initialPhotoUri = scratchUri,
+            ).firstLoadedForTest()
+
+            assertEquals(false, result.items[0].canUseActions)
+        }
+    }
+
     @Test
     fun getPhotoViewerItems_whenCursorIsNull_returnsEmptyLoadedResult() {
         runTest(context = testDispatcher) {
diff --git a/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
new file mode 100644
index 000000000..8ee310531
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
@@ -0,0 +1,77 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import android.net.Uri
+import com.android.messaging.util.UriUtil
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.single
+import kotlinx.coroutines.flow.singleOrNull
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class PreparePhotoViewerSendUriImplTest {
+
+    private val testDispatcher = StandardTestDispatcher()
+    private val useCase = PreparePhotoViewerSendUriImpl(ioDispatcher = testDispatcher)
+
+    @Before
+    fun setUp() {
+        mockkStatic(UriUtil::class)
+    }
+
+    @After
+    fun tearDown() {
+        unmockkAll()
+    }
+
+    @Test
+    fun invoke_whenUriIsContentUri_emitsOriginalUri() {
+        runTest(context = testDispatcher) {
+            val uri = Uri.parse("content://example/image/1")
+
+            val result = useCase(uri = uri).single()
+
+            assertEquals(uri, result)
+            verify(exactly = 0) {
+                UriUtil.persistContentToScratchSpace(any())
+            }
+        }
+    }
+
+    @Test
+    fun invoke_whenFileUriCopySucceeds_emitsScratchUri() {
+        runTest(context = testDispatcher) {
+            val fileUri = Uri.parse("file:///sdcard/Pictures/photo.jpg")
+            val scratchUri = Uri.parse("content://example/scratch/1")
+            every { UriUtil.persistContentToScratchSpace(fileUri) } returns scratchUri
+
+            val result = useCase(uri = fileUri).single()
+
+            assertEquals(scratchUri, result)
+        }
+    }
+
+    @Test
+    fun invoke_whenFileUriCopyFails_emitsNoUri() {
+        runTest(context = testDispatcher) {
+            val fileUri = Uri.parse("file:///sdcard/Pictures/photo.jpg")
+            every { UriUtil.persistContentToScratchSpace(fileUri) } returns null
+
+            val result = useCase(uri = fileUri).singleOrNull()
+
+            assertNull(result)
+        }
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
index 9820477f0..ede295ee3 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
@@ -6,6 +6,7 @@ import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.data.media.repository.PhotoViewerRepository
+import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
 import com.android.messaging.testutil.MainDispatcherRule
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest
 import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds
@@ -16,7 +17,10 @@ import io.mockk.every
 import io.mockk.mockk
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
@@ -190,17 +194,16 @@ internal class PhotoViewerViewModelTest {
     }
 
     @Test
-    fun scratchUriActions_emitEffects() {
+    fun actions_emitEffects() {
         runTest(context = mainDispatcherRule.testDispatcher) {
             val repositoryResults = MutableSharedFlow(replay = 1)
             val repository = mockPhotoViewerRepository(results = repositoryResults)
             val viewModel = createViewModel(repository = repository)
-            val scratchItem = photo1.copy(contentUri = scratchUri)
 
             viewModel.onLaunchRequest(launchRequest = launchRequest)
             repositoryResults.emitLoaded(
                 photoViewerItems(
-                    items = listOf(scratchItem),
+                    items = listOf(photo1),
                     initialIndex = 0,
                 ),
             )
@@ -210,7 +213,7 @@ internal class PhotoViewerViewModelTest {
                 viewModel.onSaveClick()
                 assertEquals(
                     PhotoViewerEffect.Save(
-                        uri = scratchUri,
+                        uri = photo1.contentUri,
                         contentType = IMAGE_JPEG,
                     ),
                     awaitItem(),
@@ -219,7 +222,7 @@ internal class PhotoViewerViewModelTest {
                 viewModel.onShareClick()
                 assertEquals(
                     PhotoViewerEffect.Share(
-                        uri = scratchUri,
+                        uri = photo1.contentUri,
                         contentType = IMAGE_JPEG,
                     ),
                     awaitItem(),
@@ -228,7 +231,7 @@ internal class PhotoViewerViewModelTest {
                 viewModel.onForwardClick()
                 assertEquals(
                     PhotoViewerEffect.Forward(
-                        uri = scratchUri,
+                        uri = photo1.contentUri,
                         contentType = IMAGE_JPEG,
                     ),
                     awaitItem(),
@@ -238,6 +241,112 @@ internal class PhotoViewerViewModelTest {
         }
     }
 
+    @Test
+    fun shareAndForward_whenPreparedUriIsDifferent_emitPreparedUri() {
+        runTest(context = mainDispatcherRule.testDispatcher) {
+            val repositoryResults = MutableSharedFlow(replay = 1)
+            val repository = mockPhotoViewerRepository(results = repositoryResults)
+            val preparedUri = Uri.parse("content://example/prepared/1")
+            val viewModel = createViewModel(
+                repository = repository,
+                preparePhotoViewerSendUri = FakePreparePhotoViewerSendUri(
+                    preparedUri = preparedUri,
+                ),
+            )
+
+            viewModel.onLaunchRequest(launchRequest = launchRequest)
+            repositoryResults.emitLoaded(
+                photoViewerItems(
+                    items = listOf(photo1),
+                    initialIndex = 0,
+                ),
+            )
+            advanceUntilIdle()
+
+            viewModel.effects.test {
+                viewModel.onShareClick()
+                assertEquals(
+                    PhotoViewerEffect.Share(
+                        uri = preparedUri,
+                        contentType = IMAGE_JPEG,
+                    ),
+                    awaitItem(),
+                )
+
+                viewModel.onForwardClick()
+                assertEquals(
+                    PhotoViewerEffect.Forward(
+                        uri = preparedUri,
+                        contentType = IMAGE_JPEG,
+                    ),
+                    awaitItem(),
+                )
+                cancelAndIgnoreRemainingEvents()
+            }
+        }
+    }
+
+    @Test
+    fun shareAndForward_whenUriPreparationFails_emitNoEffects() {
+        runTest(context = mainDispatcherRule.testDispatcher) {
+            val repositoryResults = MutableSharedFlow(replay = 1)
+            val repository = mockPhotoViewerRepository(results = repositoryResults)
+            val viewModel = createViewModel(
+                repository = repository,
+                preparePhotoViewerSendUri = FakePreparePhotoViewerSendUri(
+                    shouldFail = true,
+                ),
+            )
+
+            viewModel.onLaunchRequest(launchRequest = launchRequest)
+            repositoryResults.emitLoaded(
+                photoViewerItems(
+                    items = listOf(photo1),
+                    initialIndex = 0,
+                ),
+            )
+            advanceUntilIdle()
+
+            viewModel.effects.test {
+                viewModel.onShareClick()
+                viewModel.onForwardClick()
+                advanceUntilIdle()
+
+                expectNoEvents()
+                cancelAndIgnoreRemainingEvents()
+            }
+        }
+    }
+
+    @Test
+    fun actions_whenCurrentItemDisallowsActions_emitNoEffects() {
+        runTest(context = mainDispatcherRule.testDispatcher) {
+            val repositoryResults = MutableSharedFlow(replay = 1)
+            val repository = mockPhotoViewerRepository(results = repositoryResults)
+            val viewModel = createViewModel(repository = repository)
+            val nonActionableItem = photo1.copy(canUseActions = false)
+
+            viewModel.onLaunchRequest(launchRequest = launchRequest)
+            repositoryResults.emitLoaded(
+                photoViewerItems(
+                    items = listOf(nonActionableItem),
+                    initialIndex = 0,
+                ),
+            )
+            advanceUntilIdle()
+
+            viewModel.effects.test {
+                viewModel.onSaveClick()
+                viewModel.onShareClick()
+                viewModel.onForwardClick()
+                advanceUntilIdle()
+
+                expectNoEvents()
+                cancelAndIgnoreRemainingEvents()
+            }
+        }
+    }
+
     @Test
     fun closeClick_whenInCarousel_preservesCarouselDisplayMode() {
         runTest(context = mainDispatcherRule.testDispatcher) {
@@ -374,9 +483,13 @@ internal class PhotoViewerViewModelTest {
         advanceUntilIdle()
     }
 
-    private fun createViewModel(repository: PhotoViewerRepository): PhotoViewerViewModel {
+    private fun createViewModel(
+        repository: PhotoViewerRepository,
+        preparePhotoViewerSendUri: PreparePhotoViewerSendUri = FakePreparePhotoViewerSendUri(),
+    ): PhotoViewerViewModel {
         return PhotoViewerViewModel(
             photoViewerRepository = repository,
+            preparePhotoViewerSendUri = preparePhotoViewerSendUri,
             defaultDispatcher = mainDispatcherRule.testDispatcher,
         )
     }
@@ -427,9 +540,6 @@ internal class PhotoViewerViewModelTest {
             photosUri = "content://example/photos",
             sourceBounds = PhotoViewerSourceBounds(),
         )
-        val scratchUri: Uri = Uri.parse(
-            "content://com.android.messaging.debug.datamodel.MediaScratchFileProvider/800001?ext=jpg",
-        )
         val photo1 = photoViewerItem(index = 1)
         val photo2 = photoViewerItem(index = 2)
         val photo3 = photoViewerItem(index = 3)
@@ -448,4 +558,18 @@ internal class PhotoViewerViewModelTest {
             )
         }
     }
+
+    private class FakePreparePhotoViewerSendUri(
+        private val preparedUri: Uri? = null,
+        private val shouldFail: Boolean = false,
+    ) : PreparePhotoViewerSendUri {
+
+        override fun invoke(uri: Uri): Flow {
+            return when {
+                shouldFail -> emptyFlow()
+                preparedUri != null -> flowOf(preparedUri)
+                else -> flowOf(uri)
+            }
+        }
+    }
 }
diff --git a/src/com/android/messaging/data/media/model/PhotoViewerItem.kt b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
index f4aba773a..7068ad1fe 100644
--- a/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
+++ b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
@@ -11,6 +11,7 @@ internal data class PhotoViewerItem(
     val senderDestination: String?,
     val receivedTimestampMillis: Long,
     val isDraft: Boolean,
+    val canUseActions: Boolean = true,
 ) {
     val title: String
         get() {
diff --git a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
index b25306572..d373d7d1a 100644
--- a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
+++ b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
@@ -14,6 +14,7 @@ import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.datamodel.ConversationImagePartsView
+import com.android.messaging.datamodel.MediaScratchFileProvider
 import com.android.messaging.datamodel.data.MessageData
 import com.android.messaging.di.core.DefaultDispatcher
 import com.android.messaging.di.core.MessagingDbDispatcher
@@ -147,8 +148,10 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
         return when {
             contentUriString == null || contentType == null -> null
             else -> {
+                val contentUri = contentUriString.toUri()
+
                 PhotoViewerItem(
-                    contentUri = contentUriString.toUri(),
+                    contentUri = contentUri,
                     contentType = contentType,
                     senderName = getStringOrNull(
                         ConversationImagePartsView
@@ -166,6 +169,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
                             .INDEX_RECEIVED_TIMESTAMP,
                     ),
                     isDraft = status == MessageData.BUGLE_STATUS_OUTGOING_DRAFT,
+                    canUseActions = !MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri),
                 )
             }
         }
diff --git a/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
index 1dc71b4b5..9e10c2cd0 100644
--- a/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
+++ b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
@@ -2,6 +2,8 @@ package com.android.messaging.di.photoviewer
 
 import com.android.messaging.data.media.repository.PhotoViewerRepository
 import com.android.messaging.data.media.repository.PhotoViewerRepositoryImpl
+import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
+import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUriImpl
 import dagger.Binds
 import dagger.Module
 import dagger.Reusable
@@ -17,4 +19,10 @@ internal abstract class PhotoViewerBindsModule {
     abstract fun bindPhotoViewerRepository(
         impl: PhotoViewerRepositoryImpl,
     ): PhotoViewerRepository
+
+    @Binds
+    @Reusable
+    abstract fun bindPreparePhotoViewerSendUri(
+        impl: PreparePhotoViewerSendUriImpl,
+    ): PreparePhotoViewerSendUri
 }
diff --git a/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
new file mode 100644
index 000000000..36af7c65f
--- /dev/null
+++ b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
@@ -0,0 +1,46 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import android.content.ContentResolver
+import android.net.Uri
+import com.android.messaging.di.core.IoDispatcher
+import com.android.messaging.util.LogUtil
+import com.android.messaging.util.UriUtil
+import com.android.messaging.util.core.extension.typedFlow
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import javax.inject.Inject
+
+internal interface PreparePhotoViewerSendUri {
+    operator fun invoke(uri: Uri): Flow
+}
+
+internal class PreparePhotoViewerSendUriImpl @Inject constructor(
+    @param:IoDispatcher
+    private val ioDispatcher: CoroutineDispatcher,
+) : PreparePhotoViewerSendUri {
+
+    override fun invoke(uri: Uri): Flow {
+        return when (uri.scheme) {
+            ContentResolver.SCHEME_FILE -> copyFileUriToScratchSpace(uri = uri)
+            else -> flowOf(uri)
+        }
+    }
+
+    private fun copyFileUriToScratchSpace(uri: Uri): Flow {
+        return typedFlow {
+            UriUtil
+                .persistContentToScratchSpace(uri)
+                .also { persistedUri ->
+                    if (persistedUri != null) {
+                        LogUtil.w(TAG, "Failed to copy photo viewer file URI to scratch space")
+                    }
+                }
+        }.flowOn(context = ioDispatcher)
+    }
+
+    private companion object {
+        private const val TAG = "PhotoViewerSendUri"
+    }
+}
diff --git a/src/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivity.kt b/src/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivity.kt
index cd0551a0d..bba460809 100644
--- a/src/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivity.kt
+++ b/src/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivity.kt
@@ -1,6 +1,8 @@
 package com.android.messaging.ui.conversationpicker.host.share
 
+import android.content.Context
 import android.content.Intent
+import android.net.Uri
 import android.os.Bundle
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
@@ -85,7 +87,7 @@ class ShareIntentActivity : BugleComponentActivity() {
                     effectHandler = effectHandler,
                     onNavigateBack = ::finish,
                     allowMultiSelect = true,
-                    labels = ConversationPickerLabels.Share,
+                    labels = conversationPickerLabels(),
                     isInitialDraftLoading = shareDraft.isLoading,
                     initialDraft = shareDraft.draft,
                 )
@@ -142,13 +144,37 @@ class ShareIntentActivity : BugleComponentActivity() {
         return true
     }
 
+    private fun conversationPickerLabels(): ConversationPickerLabels {
+        return when (intent.getStringExtra(EXTRA_CONVERSATION_PICKER_LABELS)) {
+            LABELS_FORWARD -> ConversationPickerLabels.Forward
+            else -> ConversationPickerLabels.Share
+        }
+    }
+
     private data class ShareDraftState(
         val draft: ConversationDraft?,
         val isLoading: Boolean,
         val hasDroppedContent: Boolean,
     )
 
-    private companion object {
+    companion object {
+        internal fun createForwardIntent(
+            context: Context,
+            uri: Uri,
+            contentType: String,
+        ): Intent {
+            return Intent(context, ShareIntentActivity::class.java).apply {
+                action = Intent.ACTION_SEND
+                type = contentType
+                putExtra(Intent.EXTRA_STREAM, uri)
+                putExtra(EXTRA_CONVERSATION_PICKER_LABELS, LABELS_FORWARD)
+                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            }
+        }
+
         private const val EXTRA_ADDRESS = "address"
+        private const val EXTRA_CONVERSATION_PICKER_LABELS =
+            "com.android.messaging.extra.CONVERSATION_PICKER_LABELS"
+        private const val LABELS_FORWARD = "forward"
     }
 }
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt
index ccaa34d89..e61c441ea 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt
@@ -83,7 +83,7 @@ internal fun PhotoViewerScreenContent(
     onShareClick: () -> Unit,
 ) {
     val currentItem = uiState.items.getOrNull(index = uiState.currentPage)
-    val actionsEnabled = currentItem != null
+    val actionsEnabled = currentItem?.canUseActions == true
     val dismissDragState = rememberPhotoViewerDismissDragState(
         resetKey = photoViewerLaunchRequestKey(launchRequest = launchRequest),
     )
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
index d83deacf0..683ea2a2f 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
@@ -1,7 +1,6 @@
 package com.android.messaging.ui.photoviewer.screen
 
 import android.content.ActivityNotFoundException
-import android.content.ContentResolver
 import android.content.Context
 import android.content.Intent
 import android.net.Uri
@@ -15,9 +14,6 @@ import com.android.messaging.ui.conversationpicker.host.share.ShareIntentActivit
 import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerEffect
 import com.android.messaging.util.LogUtil
 import com.android.messaging.util.UiUtils
-import com.android.messaging.util.UriUtil
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
 
 private const val LOG_TAG = "PhotoViewerScreenEffects"
 
@@ -80,13 +76,11 @@ private suspend fun sharePhoto(
     uri: Uri,
     contentType: String,
 ) {
-    val shareUri = normalizeUriForSendIntent(uri = uri)
-
     try {
         Intent(Intent.ACTION_SEND)
             .apply {
                 type = contentType
-                putExtra(Intent.EXTRA_STREAM, shareUri)
+                putExtra(Intent.EXTRA_STREAM, uri)
                 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
             }
             .let { intent ->
@@ -107,32 +101,16 @@ private suspend fun forwardPhoto(
     uri: Uri,
     contentType: String,
 ) {
-    val forwardUri = normalizeUriForSendIntent(uri = uri)
-
     try {
-        Intent(context, ShareIntentActivity::class.java)
-            .apply {
-                action = Intent.ACTION_SEND
-                type = contentType
-                putExtra(Intent.EXTRA_STREAM, forwardUri)
-                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-            }
+        ShareIntentActivity
+            .createForwardIntent(
+                context = context,
+                uri = uri,
+                contentType = contentType,
+            )
             .let(context::startActivity)
     } catch (e: ActivityNotFoundException) {
         LogUtil.w(LOG_TAG, "No activity found for photo forward intent", e)
         UiUtils.showToastAtBottom(R.string.activity_not_found_message)
     }
 }
-
-@Suppress("InjectDispatcher")
-private suspend fun normalizeUriForSendIntent(uri: Uri): Uri {
-    return when {
-        uri.scheme != ContentResolver.SCHEME_FILE -> uri
-
-        else -> {
-            withContext(context = Dispatchers.IO) {
-                UriUtil.persistContentToScratchSpace(uri) ?: uri
-            }
-        }
-    }
-}
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
index d631a53d8..a9fbd931b 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
@@ -9,6 +9,7 @@ import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.data.media.repository.PhotoViewerRepository
 import com.android.messaging.di.core.DefaultDispatcher
+import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequestKey
 import com.android.messaging.ui.photoviewer.model.photoViewerLaunchRequestKey
@@ -18,7 +19,6 @@ import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState
 import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState
 import com.android.messaging.util.LogUtil
 import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.coroutines.CancellationException
@@ -30,9 +30,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import javax.inject.Inject
 
 internal interface PhotoViewerScreenModel {
     val effects: Flow
@@ -54,6 +56,7 @@ internal interface PhotoViewerScreenModel {
 @HiltViewModel
 internal class PhotoViewerViewModel @Inject constructor(
     private val photoViewerRepository: PhotoViewerRepository,
+    private val preparePhotoViewerSendUri: PreparePhotoViewerSendUri,
     @param:DefaultDispatcher
     private val defaultDispatcher: CoroutineDispatcher,
 ) : ViewModel(),
@@ -195,23 +198,19 @@ internal class PhotoViewerViewModel @Inject constructor(
     }
 
     override fun onShareClick() {
-        currentItemForAction()?.let { item ->
-            sendEffect(
-                effect = PhotoViewerEffect.Share(
-                    uri = item.contentUri,
-                    contentType = item.contentType,
-                ),
+        sendPreparedPhotoEffect { item, preparedUri ->
+            PhotoViewerEffect.Share(
+                uri = preparedUri,
+                contentType = item.contentType,
             )
         }
     }
 
     override fun onForwardClick() {
-        currentItemForAction()?.let { item ->
-            sendEffect(
-                effect = PhotoViewerEffect.Forward(
-                    uri = item.contentUri,
-                    contentType = item.contentType,
-                ),
+        sendPreparedPhotoEffect { item, preparedUri ->
+            PhotoViewerEffect.Forward(
+                uri = preparedUri,
+                contentType = item.contentType,
             )
         }
     }
@@ -222,6 +221,22 @@ internal class PhotoViewerViewModel @Inject constructor(
         }
     }
 
+    private fun sendPreparedPhotoEffect(
+        buildEffect: (PhotoViewerItem, Uri) -> PhotoViewerEffect,
+    ) {
+        val item = currentItemForAction() ?: return
+
+        viewModelScope.launch(defaultDispatcher) {
+            preparePhotoViewerSendUri(uri = item.contentUri)
+                .map { preparedUri ->
+                    buildEffect(item, preparedUri)
+                }
+                .collect { element ->
+                    effectsChannel.send(element = element)
+                }
+        }
+    }
+
     private fun setCurrentPage(page: Int) {
         _uiState.update { currentState ->
             val coercedPage = page.coerceIn(
@@ -239,6 +254,7 @@ internal class PhotoViewerViewModel @Inject constructor(
         return state
             .items
             .getOrNull(index = state.currentPage)
+            ?.takeIf { item -> item.canUseActions }
     }
 
     private fun updatedStateForPhotoViewerItems(

From c39e1aeb18e62c5ad638c4ff92e1cfe9ffd55c37 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Wed, 8 Jul 2026 18:40:44 +0300
Subject: [PATCH 07/19] Respect safe-area insets in photo viewer layout

---
 .../screen/PhotoViewerScreenContentTest.kt    | 90 +++++++++++++++++++
 .../usecase/PreparePhotoViewerSendUri.kt      |  2 +-
 .../photoviewer/component/PhotoViewerPager.kt |  2 +-
 .../component/PhotoViewerTopBar.kt            | 11 +++
 .../screen/PhotoViewerViewModel.kt            |  2 +-
 5 files changed, 104 insertions(+), 3 deletions(-)

diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
index dc3fcbfee..7f1a3eba0 100644
--- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
+++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
@@ -4,6 +4,7 @@ import android.content.Context
 import android.net.Uri
 import androidx.annotation.StringRes
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.mutableStateOf
@@ -36,6 +37,7 @@ import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG
+import com.android.messaging.ui.photoviewer.component.PhotoViewerTopBar
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest
 import com.android.messaging.ui.photoviewer.model.PhotoViewerSourceBounds
 import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerDisplayMode
@@ -44,6 +46,7 @@ import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState
 import java.util.concurrent.atomic.AtomicInteger
 import kotlinx.collections.immutable.persistentListOf
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
 import org.junit.Rule
 import org.junit.Test
 
@@ -70,6 +73,93 @@ internal class PhotoViewerScreenContentTest {
             .assertIsDisplayed()
     }
 
+    @Test
+    fun topBar_withHorizontalNavigationInsets_keepsEdgeActionsInsideSafeRegion() {
+        val containerWidth = 640.dp
+        val navigationInset = 72.dp
+
+        composeRule.setContent {
+            AppTheme {
+                Box(
+                    modifier = Modifier.size(
+                        width = containerWidth,
+                        height = 360.dp,
+                    ),
+                ) {
+                    PhotoViewerTopBar(
+                        isVisible = true,
+                        item = photoViewerItem(index = 1, senderName = FIRST_SENDER),
+                        actionsEnabled = true,
+                        navigationBarInsets = WindowInsets(
+                            left = navigationInset,
+                            right = navigationInset,
+                        ),
+                        onMetadataClick = {},
+                        onCloseClick = {},
+                        onForwardClick = {},
+                        onSaveClick = {},
+                        onShareClick = {},
+                    )
+                }
+            }
+        }
+
+        composeRule.waitForIdle()
+
+        val containerWidthPx = with(composeRule.density) { containerWidth.toPx() }
+        val navigationInsetPx = with(composeRule.density) { navigationInset.toPx() }
+        val closeButtonBounds = composeRule
+            .onNodeWithTag(testTag = PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG)
+            .fetchSemanticsNode()
+            .boundsInRoot
+        val overflowButtonBounds = composeRule
+            .onNodeWithTag(testTag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG)
+            .fetchSemanticsNode()
+            .boundsInRoot
+
+        assertTrue(closeButtonBounds.left >= navigationInsetPx)
+        assertTrue(overflowButtonBounds.right <= containerWidthPx - navigationInsetPx)
+    }
+
+    @Test
+    fun pager_whenRootWidthShrinks_keepsContentPaddingNonNegative() {
+        val containerWidth = mutableStateOf(value = 900.dp)
+
+        composeRule.setContent {
+            AppTheme {
+                Box(
+                    modifier = Modifier.size(
+                        width = containerWidth.value,
+                        height = 500.dp,
+                    ),
+                ) {
+                    PhotoViewerScreenContent(
+                        launchRequest = launchRequest,
+                        uiState = loadedPhotoViewerUiState(),
+                        onPageSettled = {},
+                        onToggleDisplayMode = {},
+                        onEnterImmersiveMode = {},
+                        onMetadataClick = {},
+                        onMetadataDismissed = {},
+                        onCloseClick = {},
+                        onCloseAnimationFinished = {},
+                        onForwardClick = {},
+                        onSaveClick = {},
+                        onShareClick = {},
+                    )
+                }
+            }
+        }
+
+        composeRule.waitForIdle()
+        composeRule.runOnIdle {
+            containerWidth.value = 320.dp
+        }
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGER_TEST_TAG)
+            .assertIsDisplayed()
+    }
+
     @Test
     fun swipeLeft_setsNextPage() {
         val currentPage = AtomicInteger()
diff --git a/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
index 36af7c65f..ed5b3b3cb 100644
--- a/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
+++ b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
@@ -6,11 +6,11 @@ import com.android.messaging.di.core.IoDispatcher
 import com.android.messaging.util.LogUtil
 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.flowOf
 import kotlinx.coroutines.flow.flowOn
-import javax.inject.Inject
 
 internal interface PreparePhotoViewerSendUri {
     operator fun invoke(uri: Uri): Flow
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt
index b1cafe3ef..d2a4ab88c 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerPager.kt
@@ -196,7 +196,7 @@ private fun rememberPhotoViewerPagerLayout(
     return PhotoViewerPagerLayout(
         pageWidth = pageWidth,
         pageSpacing = pageSpacing,
-        horizontalInset = (maxWidth - pageWidth) / 2,
+        horizontalInset = ((maxWidth - pageWidth) / 2).coerceAtLeast(minimumValue = 0.dp),
     )
 }
 
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
index 7cffcd3d1..474004c96 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
@@ -9,11 +9,16 @@ 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.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
 import androidx.compose.foundation.layout.displayCutoutPadding
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.rounded.Close
 import androidx.compose.material.icons.rounded.FileDownload
@@ -55,6 +60,7 @@ internal fun PhotoViewerTopBar(
     isVisible: Boolean,
     item: PhotoViewerItem?,
     actionsEnabled: Boolean,
+    navigationBarInsets: WindowInsets = WindowInsets.navigationBars,
     onMetadataClick: () -> Unit,
     onCloseClick: () -> Unit,
     onForwardClick: () -> Unit,
@@ -75,6 +81,7 @@ internal fun PhotoViewerTopBar(
             PhotoViewerTopBarContent(
                 item = item,
                 actionsEnabled = actionsEnabled,
+                navigationBarInsets = navigationBarInsets,
                 onMetadataClick = onMetadataClick,
                 onCloseClick = onCloseClick,
                 onForwardClick = onForwardClick,
@@ -89,6 +96,7 @@ internal fun PhotoViewerTopBar(
 private fun PhotoViewerTopBarContent(
     item: PhotoViewerItem?,
     actionsEnabled: Boolean,
+    navigationBarInsets: WindowInsets,
     onMetadataClick: () -> Unit,
     onCloseClick: () -> Unit,
     onForwardClick: () -> Unit,
@@ -102,6 +110,9 @@ private fun PhotoViewerTopBarContent(
         modifier = Modifier
             .fillMaxWidth()
             .statusBarsPadding()
+            .windowInsetsPadding(
+                insets = navigationBarInsets.only(sides = WindowInsetsSides.Horizontal),
+            )
             .padding(horizontal = 8.dp, vertical = 8.dp),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(space = 2.dp),
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
index a9fbd931b..dbd99e8a6 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
@@ -19,6 +19,7 @@ import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerLoadState
 import com.android.messaging.ui.photoviewer.screen.model.PhotoViewerUiState
 import com.android.messaging.util.LogUtil
 import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.coroutines.CancellationException
@@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import javax.inject.Inject
 
 internal interface PhotoViewerScreenModel {
     val effects: Flow

From fbab9be990886022ae756dcf635fa6d4431aeb0f Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 00:21:04 +0300
Subject: [PATCH 08/19] Respect tapped attachment when opening conversation
 photo viewer

---
 .../PhotoViewerRepositoryImplTest.kt          |  47 +++++++
 .../NormalizePhotoViewerUriImplTest.kt        |  31 +++++
 ...onPhotoViewerInitialOccurrenceIndexTest.kt | 123 ++++++++++++++++++
 .../ConversationMessagesDelegateImplTest.kt   |  83 ++++++++++++
 .../ConversationViewModelSimSelectionTest.kt  |  48 +++++++
 .../screen/PhotoViewerViewModelTest.kt        |   5 +
 .../media/repository/PhotoViewerRepository.kt |  39 ++++--
 .../di/photoviewer/PhotoViewerBindsModule.kt  |  16 +++
 .../ConversationPhotoViewerAttachment.kt      |   8 ++
 .../usecase/NormalizePhotoViewerUri.kt        |  20 +++
 ...sationPhotoViewerInitialOccurrenceIndex.kt |  48 +++++++
 src/com/android/messaging/ui/UIIntents.java   |  10 +-
 .../android/messaging/ui/UIIntentsImpl.java   |   6 +-
 .../attachment/AttachmentPreviewLauncher.kt   |   4 +
 .../delegate/ConversationMessagesDelegate.kt  |  86 ++++++++++++
 .../ConversationMessageUiModelMapper.kt       |  19 +++
 .../ConversationAttachmentOpenAction.kt       |   1 +
 .../message/ConversationMessagePartUiModel.kt |   6 +
 .../messages/ui/ConversationMessages.kt       |   9 +-
 .../ConversationAttachmentActionDispatcher.kt |   5 +-
 .../ConversationGenericInlineAttachmentRow.kt |   4 +-
 .../ConversationInlineAttachmentRow.kt        |   4 +-
 .../ConversationMessageAttachments.kt         |   4 +-
 .../ConversationVCardInlineAttachmentRow.kt   |   4 +-
 .../ConversationVisualAttachments.kt          |  16 +--
 .../OnConversationAttachmentClick.kt          |   7 +
 .../ui/message/ConversationMessage.kt         |   6 +-
 .../ui/message/ConversationMessageBubble.kt   |  64 ++++++---
 ...ConversationMessageBubblePreviewSupport.kt |   2 +-
 .../ui/message/ConversationMessageRows.kt     |   9 +-
 .../screen/ConversationAttachmentEffects.kt   |   1 +
 .../screen/ConversationScreenContent.kt       |   5 +-
 ...ConversationScreenContentPreviewSupport.kt |   2 +-
 .../screen/ConversationViewModel.kt           |  10 ++
 .../screen/model/ConversationScreenEffect.kt  |   1 +
 .../ui/photoviewer/PhotoViewerActivity.kt     |   9 ++
 .../model/PhotoViewerLaunchRequest.kt         |   3 +
 .../screen/PhotoViewerViewModel.kt            |  12 +-
 38 files changed, 701 insertions(+), 76 deletions(-)
 create mode 100644 app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUriImplTest.kt
 create mode 100644 app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndexTest.kt
 create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
 create mode 100644 src/com/android/messaging/domain/photoviewer/model/ConversationPhotoViewerAttachment.kt
 create mode 100644 src/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUri.kt
 create mode 100644 src/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndex.kt
 create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/attachment/OnConversationAttachmentClick.kt

diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
index 1d965020f..ac4315f69 100644
--- a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
@@ -11,6 +11,7 @@ import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.datamodel.ConversationImagePartsView
 import com.android.messaging.datamodel.MediaScratchFileProvider
 import com.android.messaging.datamodel.data.MessageData
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUriImpl
 import io.mockk.every
 import io.mockk.mockk
 import io.mockk.slot
@@ -38,6 +39,7 @@ internal class PhotoViewerRepositoryImplTest {
     private val testDispatcher = StandardTestDispatcher()
     private val repository = PhotoViewerRepositoryImpl(
         contentResolver = contentResolver,
+        normalizePhotoViewerUri = NormalizePhotoViewerUriImpl(),
         messagingDbDispatcher = testDispatcher,
         defaultDispatcher = testDispatcher,
     )
@@ -88,6 +90,51 @@ internal class PhotoViewerRepositoryImplTest {
         }
     }
 
+    @Test
+    fun getPhotoViewerItems_whenInitialUriHasDuplicates_usesOccurrenceIndex() {
+        runTest(context = testDispatcher) {
+            val duplicateContentUri = "content://example/content/shared"
+            val cursor = MatrixCursor(ConversationImagePartsView.PhotoViewQuery.PROJECTION).apply {
+                addPhotoRow(
+                    uri = "content://example/photo/1",
+                    senderName = "Ada",
+                    contentUri = duplicateContentUri,
+                    contentType = IMAGE_JPEG,
+                    senderDestination = "+15550001",
+                    receivedTimestampMillis = 1000L,
+                    status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE,
+                )
+                addPhotoRow(
+                    uri = "content://example/photo/2",
+                    senderName = "Grace",
+                    contentUri = duplicateContentUri,
+                    contentType = IMAGE_JPEG,
+                    senderDestination = "+15550002",
+                    receivedTimestampMillis = 2000L,
+                    status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE,
+                )
+                addPhotoRow(
+                    uri = "content://example/photo/3",
+                    senderName = "Katherine",
+                    contentUri = duplicateContentUri,
+                    contentType = IMAGE_JPEG,
+                    senderDestination = "+15550003",
+                    receivedTimestampMillis = 3000L,
+                    status = MessageData.BUGLE_STATUS_INCOMING_COMPLETE,
+                )
+            }
+            stubContentResolver(cursor = cursor)
+
+            val result = repository.getPhotoViewerItems(
+                photosUri = photosUri,
+                initialPhotoUri = Uri.parse(duplicateContentUri),
+                initialPhotoOccurrenceIndex = 2,
+            ).firstLoadedForTest()
+
+            assertEquals(2, result.initialIndex)
+        }
+    }
+
     @Test
     fun getPhotoViewerItems_whenInitialMissing_usesZeroIndex() {
         runTest(context = testDispatcher) {
diff --git a/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUriImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUriImplTest.kt
new file mode 100644
index 000000000..b32722904
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUriImplTest.kt
@@ -0,0 +1,31 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import androidx.core.net.toUri
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class NormalizePhotoViewerUriImplTest {
+
+    private val normalizePhotoViewerUri = NormalizePhotoViewerUriImpl()
+
+    @Test
+    fun invoke_whenUriHasQueryAndFragment_removesBoth() {
+        val result = normalizePhotoViewerUri(
+            uri = "content://example/images/1?version=2#preview".toUri(),
+        )
+
+        assertEquals("content://example/images/1", result)
+    }
+
+    @Test
+    fun invoke_whenUriHasNoQueryOrFragment_returnsSameUriString() {
+        val result = normalizePhotoViewerUri(
+            uri = "content://example/images/1".toUri(),
+        )
+
+        assertEquals("content://example/images/1", result)
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndexTest.kt b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndexTest.kt
new file mode 100644
index 000000000..aed60424d
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndexTest.kt
@@ -0,0 +1,123 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import androidx.core.net.toUri
+import com.android.messaging.domain.photoviewer.model.ConversationPhotoViewerAttachment
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ResolveConversationPhotoViewerInitialOccurrenceIndexTest {
+
+    private val resolveIndex = ResolveConversationPhotoViewerInitialOccurrenceIndexImpl(
+        normalizePhotoViewerUri = NormalizePhotoViewerUriImpl(),
+    )
+
+    @Test
+    fun invoke_whenClickedUriHasDuplicates_returnsClickedOccurrenceIndex() {
+        val result = resolveIndex(
+            partId = "part-3",
+            contentUri = DUPLICATE_URI.toUri(),
+            attachments = sequenceOf(
+                photoAttachment(partId = "part-1", contentUri = DUPLICATE_URI),
+                photoAttachment(partId = "part-2", contentUri = DUPLICATE_URI),
+                photoAttachment(partId = "part-3", contentUri = DUPLICATE_URI),
+            ),
+        )
+
+        assertEquals(2, result)
+    }
+
+    @Test
+    fun invoke_whenClickedUriIsDistinct_returnsZero() {
+        val result = resolveIndex(
+            partId = "part-2",
+            contentUri = "content://example/content/2".toUri(),
+            attachments = sequenceOf(
+                photoAttachment(partId = "part-1", contentUri = "content://example/content/1"),
+                photoAttachment(partId = "part-2", contentUri = "content://example/content/2"),
+                photoAttachment(partId = "part-3", contentUri = "content://example/content/3"),
+            ),
+        )
+
+        assertEquals(0, result)
+    }
+
+    @Test
+    fun invoke_whenUrisOnlyDifferByQueryOrFragment_treatsUrisAsDuplicates() {
+        val result = resolveIndex(
+            partId = "part-3",
+            contentUri = "$DUPLICATE_URI#preview".toUri(),
+            attachments = sequenceOf(
+                photoAttachment(partId = "part-1", contentUri = DUPLICATE_URI),
+                photoAttachment(partId = "part-2", contentUri = "$DUPLICATE_URI?version=2"),
+                photoAttachment(partId = "part-3", contentUri = "$DUPLICATE_URI#preview"),
+            ),
+        )
+
+        assertEquals(2, result)
+    }
+
+    @Test
+    fun invoke_whenPartIdIsBlank_returnsZero() {
+        val result = resolveIndex(
+            partId = "",
+            contentUri = DUPLICATE_URI.toUri(),
+            attachments = sequenceOf(
+                photoAttachment(partId = "part-1", contentUri = DUPLICATE_URI),
+                photoAttachment(partId = "part-2", contentUri = DUPLICATE_URI),
+            ),
+        )
+
+        assertEquals(0, result)
+    }
+
+    @Test
+    fun invoke_whenEarlierPartIdIsBlank_countsEarlierOccurrence() {
+        val result = resolveIndex(
+            partId = "part-2",
+            contentUri = DUPLICATE_URI.toUri(),
+            attachments = sequenceOf(
+                photoAttachment(partId = "", contentUri = DUPLICATE_URI),
+                photoAttachment(partId = "part-2", contentUri = DUPLICATE_URI),
+            ),
+        )
+
+        assertEquals(1, result)
+    }
+
+    @Test
+    fun invoke_whenClickedPartIsReached_doesNotConsumeLaterAttachments() {
+        var laterAttachmentConsumed = false
+
+        val result = resolveIndex(
+            partId = "part-2",
+            contentUri = DUPLICATE_URI.toUri(),
+            attachments = sequence {
+                yield(photoAttachment(partId = "part-1", contentUri = DUPLICATE_URI))
+                yield(photoAttachment(partId = "part-2", contentUri = DUPLICATE_URI))
+                laterAttachmentConsumed = true
+                yield(photoAttachment(partId = "part-3", contentUri = DUPLICATE_URI))
+            },
+        )
+
+        assertEquals(1, result)
+        assertFalse(laterAttachmentConsumed)
+    }
+
+    private fun photoAttachment(
+        partId: String,
+        contentUri: String,
+    ): ConversationPhotoViewerAttachment {
+        return ConversationPhotoViewerAttachment(
+            partId = partId,
+            contentUri = contentUri.toUri(),
+        )
+    }
+
+    private companion object {
+        private const val DUPLICATE_URI = "content://example/content/shared"
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
new file mode 100644
index 000000000..052021d2c
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
@@ -0,0 +1,83 @@
+package com.android.messaging.ui.conversation.messages.delegate
+
+import androidx.core.net.toUri
+import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository
+import com.android.messaging.data.conversation.repository.ConversationsRepository
+import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
+import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper
+import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper
+import com.android.messaging.util.ContentType
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.StandardTestDispatcher
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationMessagesDelegateImplTest {
+
+    private val resolveInitialPhotoOccurrenceIndex =
+        mockk()
+    private val delegate = ConversationMessagesDelegateImpl(
+        conversationsRepository = mockk(),
+        resolveInitialPhotoOccurrenceIndex = resolveInitialPhotoOccurrenceIndex,
+        conversationMessageUiModelMapper = mockk(),
+        conversationVCardAttachmentUiModelMapper =
+            mockk(),
+        conversationVCardMetadataRepository = mockk(),
+        defaultDispatcher = StandardTestDispatcher(),
+    )
+
+    @Test
+    fun resolvePhotoViewerInitialOccurrenceIndex_whenContentTypeIsImage_usesPhotoResolver() {
+        every {
+            resolveInitialPhotoOccurrenceIndex.invoke(
+                partId = PART_ID,
+                contentUri = ATTACHMENT_URI.toUri(),
+                attachments = any(),
+            )
+        } returns SECOND_OCCURRENCE_INDEX
+
+        val result = delegate.resolvePhotoViewerInitialOccurrenceIndex(
+            contentType = ContentType.IMAGE_JPEG,
+            partId = PART_ID,
+            contentUri = ATTACHMENT_URI,
+        )
+
+        assertEquals(SECOND_OCCURRENCE_INDEX, result)
+        verify(exactly = 1) {
+            resolveInitialPhotoOccurrenceIndex.invoke(
+                partId = PART_ID,
+                contentUri = ATTACHMENT_URI.toUri(),
+                attachments = any(),
+            )
+        }
+    }
+
+    @Test
+    fun resolvePhotoViewerInitialOccurrenceIndex_whenContentTypeIsNotImage_skipsPhotoResolver() {
+        val result = delegate.resolvePhotoViewerInitialOccurrenceIndex(
+            contentType = ContentType.VIDEO_MP4,
+            partId = PART_ID,
+            contentUri = ATTACHMENT_URI,
+        )
+
+        assertEquals(0, result)
+        verify(exactly = 0) {
+            resolveInitialPhotoOccurrenceIndex.invoke(
+                partId = any(),
+                contentUri = any(),
+                attachments = any(),
+            )
+        }
+    }
+
+    private companion object {
+        private const val ATTACHMENT_URI = "content://example/attachment/1"
+        private const val PART_ID = "part-1"
+        private const val SECOND_OCCURRENCE_INDEX = 1
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt
index e13c32b68..9ed90915d 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt
@@ -27,6 +27,7 @@ import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetad
 import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState
 import com.android.messaging.ui.conversation.screen.ConversationViewModel
 import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState
+import com.android.messaging.util.ContentType
 import io.mockk.every
 import io.mockk.just
 import io.mockk.mockk
@@ -97,6 +98,13 @@ internal class ConversationViewModelSimSelectionTest {
             ConversationMessagesUiState.Loading,
         )
         every { conversationMessagesDelegate.bind(any(), any()) } just runs
+        every {
+            conversationMessagesDelegate.resolvePhotoViewerInitialOccurrenceIndex(
+                contentType = any(),
+                partId = any(),
+                contentUri = any(),
+            )
+        } returns 0
 
         every { conversationMessageSelectionDelegate.state } returns MutableStateFlow(
             ConversationMessageSelectionUiState(),
@@ -145,6 +153,44 @@ internal class ConversationViewModelSimSelectionTest {
         } just runs
     }
 
+    @Test
+    fun onMessageAttachmentClicked_whenImageAttachment_resolvesPhotoOccurrenceIndex() {
+        val viewModel = createViewModel()
+
+        viewModel.onMessageAttachmentClicked(
+            contentType = ContentType.IMAGE_JPEG,
+            contentUri = ATTACHMENT_URI,
+            partId = ATTACHMENT_PART_ID,
+        )
+
+        verify(exactly = 1) {
+            conversationMessagesDelegate.resolvePhotoViewerInitialOccurrenceIndex(
+                contentType = ContentType.IMAGE_JPEG,
+                partId = ATTACHMENT_PART_ID,
+                contentUri = ATTACHMENT_URI,
+            )
+        }
+    }
+
+    @Test
+    fun onMessageAttachmentClicked_whenNonImageAttachment_delegatesOccurrenceResolution() {
+        val viewModel = createViewModel()
+
+        viewModel.onMessageAttachmentClicked(
+            contentType = ContentType.VIDEO_MP4,
+            contentUri = ATTACHMENT_URI,
+            partId = ATTACHMENT_PART_ID,
+        )
+
+        verify(exactly = 1) {
+            conversationMessagesDelegate.resolvePhotoViewerInitialOccurrenceIndex(
+                contentType = ContentType.VIDEO_MP4,
+                partId = ATTACHMENT_PART_ID,
+                contentUri = ATTACHMENT_URI,
+            )
+        }
+    }
+
     @Test
     fun onSimSelected_withConversationId_forwardsSelectionToDraftDelegate() {
         val viewModel = createViewModel()
@@ -324,6 +370,8 @@ internal class ConversationViewModelSimSelectionTest {
 
     private companion object {
         private const val CONVERSATION_ID = "conversation-1"
+        private const val ATTACHMENT_PART_ID = "attachment-part-1"
+        private const val ATTACHMENT_URI = "content://example/attachment/1"
         private const val PICKED_SELF_PARTICIPANT_ID = "self-participant-2"
         private const val FIRST_SELF_PARTICIPANT_ID = "self-participant-1"
         private const val SECOND_SELF_PARTICIPANT_ID = "self-participant-2"
diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
index ede295ee3..4f3aacf65 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
@@ -6,6 +6,8 @@ import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.data.media.repository.PhotoViewerRepository
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUri
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUriImpl
 import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
 import com.android.messaging.testutil.MainDispatcherRule
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest
@@ -485,10 +487,12 @@ internal class PhotoViewerViewModelTest {
 
     private fun createViewModel(
         repository: PhotoViewerRepository,
+        normalizePhotoViewerUri: NormalizePhotoViewerUri = NormalizePhotoViewerUriImpl(),
         preparePhotoViewerSendUri: PreparePhotoViewerSendUri = FakePreparePhotoViewerSendUri(),
     ): PhotoViewerViewModel {
         return PhotoViewerViewModel(
             photoViewerRepository = repository,
+            normalizePhotoViewerUri = normalizePhotoViewerUri,
             preparePhotoViewerSendUri = preparePhotoViewerSendUri,
             defaultDispatcher = mainDispatcherRule.testDispatcher,
         )
@@ -512,6 +516,7 @@ internal class PhotoViewerViewModelTest {
             repository.getPhotoViewerItems(
                 photosUri = any(),
                 initialPhotoUri = any(),
+                initialPhotoOccurrenceIndex = any(),
             )
         } returns results
 
diff --git a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
index d373d7d1a..89af74736 100644
--- a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
+++ b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
@@ -18,6 +18,7 @@ import com.android.messaging.datamodel.MediaScratchFileProvider
 import com.android.messaging.datamodel.data.MessageData
 import com.android.messaging.di.core.DefaultDispatcher
 import com.android.messaging.di.core.MessagingDbDispatcher
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUri
 import com.android.messaging.util.LogUtil
 import com.android.messaging.util.core.extension.typedFlow
 import com.android.messaging.util.db.ext.getNonBlankStringOrNull
@@ -37,11 +38,13 @@ internal interface PhotoViewerRepository {
     fun getPhotoViewerItems(
         photosUri: Uri,
         initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int = 0,
     ): Flow
 }
 
 internal class PhotoViewerRepositoryImpl @Inject constructor(
     private val contentResolver: ContentResolver,
+    private val normalizePhotoViewerUri: NormalizePhotoViewerUri,
     @param:MessagingDbDispatcher
     private val messagingDbDispatcher: CoroutineDispatcher,
     @param:DefaultDispatcher
@@ -51,6 +54,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
     override fun getPhotoViewerItems(
         photosUri: Uri,
         initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
     ): Flow {
         return observeUri(uri = photosUri)
             .flowOn(defaultDispatcher)
@@ -58,6 +62,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
                 loadPhotoViewerItems(
                     photosUri = photosUri,
                     initialPhotoUri = initialPhotoUri,
+                    initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
                 ).recoverPhotoViewerItemsLoadFailure(photosUri = photosUri)
             }
             .recoverPhotoViewerItemsLoadFailure(photosUri = photosUri)
@@ -66,6 +71,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
     private fun loadPhotoViewerItems(
         photosUri: Uri,
         initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
     ): Flow {
         return typedFlow { queryItems(photosUri = photosUri) }
             .flowOn(messagingDbDispatcher)
@@ -74,6 +80,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
                     photoViewerItems = buildPhotoViewerItems(
                         queriedItems = queriedItems,
                         initialPhotoUri = initialPhotoUri,
+                        initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
                     ),
                 )
 
@@ -85,6 +92,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
     private fun buildPhotoViewerItems(
         queriedItems: List,
         initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
     ): PhotoViewerItems {
         val items = queriedItems.toImmutableList()
 
@@ -93,6 +101,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
             initialIndex = resolveInitialIndex(
                 items = items,
                 initialPhotoUri = initialPhotoUri,
+                initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
             ),
         )
     }
@@ -178,24 +187,28 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
     private fun resolveInitialIndex(
         items: List,
         initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
     ): Int {
-        val normalizedInitialPhotoUri = normalizePhotoUri(uri = initialPhotoUri)
-        val matchIndex = items.indexOfFirst { item ->
-            normalizePhotoUri(uri = item.contentUri) == normalizedInitialPhotoUri
-        }
+        val normalizedInitialPhotoUri = normalizePhotoViewerUri(uri = initialPhotoUri)
+        val requestedOccurrenceIndex = initialPhotoOccurrenceIndex.coerceAtLeast(
+            minimumValue = 0,
+        )
+        val matchingIndexes = items
+            .mapIndexedNotNull { index, item ->
+                when {
+                    normalizePhotoViewerUri(
+                        uri = item.contentUri,
+                    ) == normalizedInitialPhotoUri -> index
+                    else -> null
+                }
+            }
+        val matchIndex = matchingIndexes.getOrNull(index = requestedOccurrenceIndex)
+            ?: matchingIndexes.firstOrNull()
+            ?: -1
 
         return matchIndex.coerceAtLeast(minimumValue = 0)
     }
 
-    private fun normalizePhotoUri(uri: Uri): String {
-        return uri
-            .buildUpon()
-            .clearQuery()
-            .fragment(null)
-            .build()
-            .toString()
-    }
-
     private fun Flow.recoverPhotoViewerItemsLoadFailure(
         photosUri: Uri,
     ): Flow {
diff --git a/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
index 9e10c2cd0..aa7b7d8ca 100644
--- a/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
+++ b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
@@ -2,8 +2,12 @@ package com.android.messaging.di.photoviewer
 
 import com.android.messaging.data.media.repository.PhotoViewerRepository
 import com.android.messaging.data.media.repository.PhotoViewerRepositoryImpl
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUri
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUriImpl
 import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
 import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUriImpl
+import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
+import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndexImpl
 import dagger.Binds
 import dagger.Module
 import dagger.Reusable
@@ -14,6 +18,18 @@ import dagger.hilt.components.SingletonComponent
 @InstallIn(SingletonComponent::class)
 internal abstract class PhotoViewerBindsModule {
 
+    @Binds
+    @Reusable
+    abstract fun bindNormalizePhotoViewerUri(
+        impl: NormalizePhotoViewerUriImpl,
+    ): NormalizePhotoViewerUri
+
+    @Binds
+    @Reusable
+    abstract fun bindResolveConversationPhotoViewerInitialOccurrenceIndex(
+        impl: ResolveConversationPhotoViewerInitialOccurrenceIndexImpl,
+    ): ResolveConversationPhotoViewerInitialOccurrenceIndex
+
     @Binds
     @Reusable
     abstract fun bindPhotoViewerRepository(
diff --git a/src/com/android/messaging/domain/photoviewer/model/ConversationPhotoViewerAttachment.kt b/src/com/android/messaging/domain/photoviewer/model/ConversationPhotoViewerAttachment.kt
new file mode 100644
index 000000000..b796cbb7a
--- /dev/null
+++ b/src/com/android/messaging/domain/photoviewer/model/ConversationPhotoViewerAttachment.kt
@@ -0,0 +1,8 @@
+package com.android.messaging.domain.photoviewer.model
+
+import android.net.Uri
+
+internal data class ConversationPhotoViewerAttachment(
+    val partId: String,
+    val contentUri: Uri,
+)
diff --git a/src/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUri.kt b/src/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUri.kt
new file mode 100644
index 000000000..b1044d7d3
--- /dev/null
+++ b/src/com/android/messaging/domain/photoviewer/usecase/NormalizePhotoViewerUri.kt
@@ -0,0 +1,20 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import android.net.Uri
+import javax.inject.Inject
+
+internal interface NormalizePhotoViewerUri {
+    operator fun invoke(uri: Uri): String
+}
+
+internal class NormalizePhotoViewerUriImpl @Inject constructor() : NormalizePhotoViewerUri {
+
+    override fun invoke(uri: Uri): String {
+        return uri
+            .buildUpon()
+            .clearQuery()
+            .fragment(null)
+            .build()
+            .toString()
+    }
+}
diff --git a/src/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndex.kt b/src/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndex.kt
new file mode 100644
index 000000000..200e83ce7
--- /dev/null
+++ b/src/com/android/messaging/domain/photoviewer/usecase/ResolveConversationPhotoViewerInitialOccurrenceIndex.kt
@@ -0,0 +1,48 @@
+package com.android.messaging.domain.photoviewer.usecase
+
+import android.net.Uri
+import com.android.messaging.domain.photoviewer.model.ConversationPhotoViewerAttachment
+import javax.inject.Inject
+
+internal interface ResolveConversationPhotoViewerInitialOccurrenceIndex {
+    operator fun invoke(
+        partId: String,
+        contentUri: Uri,
+        attachments: Sequence,
+    ): Int
+}
+
+internal class ResolveConversationPhotoViewerInitialOccurrenceIndexImpl @Inject constructor(
+    private val normalizePhotoViewerUri: NormalizePhotoViewerUri,
+) : ResolveConversationPhotoViewerInitialOccurrenceIndex {
+
+    override fun invoke(
+        partId: String,
+        contentUri: Uri,
+        attachments: Sequence,
+    ): Int {
+        if (partId.isBlank()) {
+            return 0
+        }
+
+        val normalizedContentUri = normalizePhotoViewerUri(uri = contentUri)
+        var resolvedOccurrenceIndex = 0
+        var clickedPartFound = false
+
+        for (attachment in attachments) {
+            if (attachment.partId == partId) {
+                clickedPartFound = true
+                break
+            }
+
+            if (normalizePhotoViewerUri(uri = attachment.contentUri) == normalizedContentUri) {
+                resolvedOccurrenceIndex++
+            }
+        }
+
+        return when {
+            clickedPartFound -> resolvedOccurrenceIndex
+            else -> 0
+        }
+    }
+}
diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java
index b97e2f65b..c8da82758 100644
--- a/src/com/android/messaging/ui/UIIntents.java
+++ b/src/com/android/messaging/ui/UIIntents.java
@@ -215,11 +215,19 @@ public abstract void launchPhoneCallActivity(final Context context, final String
      */
     public abstract void launchFullScreenVideoViewer(Context context, Uri videoUri);
 
+    /**
+     * Launch full screen photo viewer.
+     */
+    public void launchFullScreenPhotoViewer(Activity activity, Uri initialPhoto,
+            Rect initialPhotoBounds, Uri photosUri) {
+        launchFullScreenPhotoViewer(activity, initialPhoto, initialPhotoBounds, photosUri, 0);
+    }
+
     /**
      * Launch full screen photo viewer.
      */
     public abstract void launchFullScreenPhotoViewer(Activity activity, Uri initialPhoto,
-            Rect initialPhotoBounds, Uri photosUri);
+            Rect initialPhotoBounds, Uri photosUri, int initialPhotoOccurrenceIndex);
 
     /**
      * Get a ACTION_VIEW intent
diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java
index a23160e55..438e844c9 100644
--- a/src/com/android/messaging/ui/UIIntentsImpl.java
+++ b/src/com/android/messaging/ui/UIIntentsImpl.java
@@ -301,9 +301,11 @@ public void launchFullScreenVideoViewer(final Context context, final Uri videoUr
 
     @Override
     public void launchFullScreenPhotoViewer(final Activity activity, final Uri initialPhoto,
-            final Rect initialPhotoBounds, final Uri photosUri) {
+            final Rect initialPhotoBounds, final Uri photosUri,
+            final int initialPhotoOccurrenceIndex) {
         activity.startActivity(PhotoViewerActivity.createIntent(
-                activity, initialPhoto, photosUri, initialPhotoBounds));
+                activity, initialPhoto, photosUri, initialPhotoBounds,
+                initialPhotoOccurrenceIndex));
         activity.overridePendingTransition(0, 0);
     }
 
diff --git a/src/com/android/messaging/ui/common/components/attachment/AttachmentPreviewLauncher.kt b/src/com/android/messaging/ui/common/components/attachment/AttachmentPreviewLauncher.kt
index 487e7e771..fff73cb69 100644
--- a/src/com/android/messaging/ui/common/components/attachment/AttachmentPreviewLauncher.kt
+++ b/src/com/android/messaging/ui/common/components/attachment/AttachmentPreviewLauncher.kt
@@ -21,6 +21,7 @@ internal suspend fun openAttachmentPreview(
     contentUri: String,
     contentType: String,
     imageCollectionUri: String? = null,
+    initialPhotoOccurrenceIndex: Int = 0,
     hostBounds: ComposeRect? = null,
     awaitHostBounds: (suspend () -> ComposeRect)? = null,
 ) {
@@ -35,6 +36,7 @@ internal suspend fun openAttachmentPreview(
                     hostBounds = resolvedHostBounds,
                     attachmentUri = attachmentUri,
                     imageCollectionUri = imageCollectionUri,
+                    initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
                 )
 
             if (!isOpenedInternally) {
@@ -75,6 +77,7 @@ private fun openImageAttachmentPreview(
     hostBounds: ComposeRect,
     attachmentUri: Uri,
     imageCollectionUri: String?,
+    initialPhotoOccurrenceIndex: Int,
 ): Boolean {
     val activity = UiUtils.getActivity(context)
     val imageCollection = imageCollectionUri?.toUri()
@@ -88,6 +91,7 @@ private fun openImageAttachmentPreview(
         attachmentUri,
         hostBounds.toAndroidRect(),
         imageCollection,
+        initialPhotoOccurrenceIndex,
     )
 
     return true
diff --git a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt
index e0872de8e..1be397239 100644
--- a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt
+++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt
@@ -1,15 +1,21 @@
 package com.android.messaging.ui.conversation.messages.delegate
 
+import androidx.core.net.toUri
 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.datamodel.data.ConversationMessageData
+import com.android.messaging.datamodel.data.MessagePartData
 import com.android.messaging.di.core.DefaultDispatcher
+import com.android.messaging.domain.photoviewer.model.ConversationPhotoViewerAttachment
+import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
 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 com.android.messaging.util.ContentType
 import javax.inject.Inject
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
@@ -27,15 +33,24 @@ import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 
 internal interface ConversationMessagesDelegate :
     ConversationScreenDelegate {
     fun refresh()
+
+    fun resolvePhotoViewerInitialOccurrenceIndex(
+        contentType: String,
+        partId: String,
+        contentUri: String,
+    ): Int
 }
 
 internal class ConversationMessagesDelegateImpl @Inject constructor(
     private val conversationsRepository: ConversationsRepository,
+    private val resolveInitialPhotoOccurrenceIndex:
+    ResolveConversationPhotoViewerInitialOccurrenceIndex,
     private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper,
     private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper,
     private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository,
@@ -46,6 +61,9 @@ internal class ConversationMessagesDelegateImpl @Inject constructor(
     private val _state = MutableStateFlow(
         value = ConversationMessagesUiState.Loading,
     )
+    private val currentMessages = MutableStateFlow>(
+        value = emptyList(),
+    )
 
     override val state = _state.asStateFlow()
 
@@ -65,6 +83,7 @@ internal class ConversationMessagesDelegateImpl @Inject constructor(
 
         scope.launch(defaultDispatcher) {
             conversationIdFlow.collectLatest { conversationId ->
+                currentMessages.value = emptyList()
                 _state.value = ConversationMessagesUiState.Loading
 
                 if (conversationId == null) {
@@ -84,12 +103,35 @@ internal class ConversationMessagesDelegateImpl @Inject constructor(
         refreshTriggers.tryEmit(Unit)
     }
 
+    override fun resolvePhotoViewerInitialOccurrenceIndex(
+        contentType: String,
+        partId: String,
+        contentUri: String,
+    ): Int {
+        return when {
+            ContentType.isImageType(contentType) -> {
+                resolveInitialPhotoOccurrenceIndex(
+                    partId = partId,
+                    contentUri = contentUri.toUri(),
+                    attachments = buildConversationPhotoViewerAttachments(
+                        messages = currentMessages.value,
+                    ),
+                )
+            }
+
+            else -> 0
+        }
+    }
+
     @OptIn(ExperimentalCoroutinesApi::class)
     private fun observeConversationMessagesUiState(
         conversationId: String,
     ): Flow {
         return conversationsRepository
             .getConversationMessages(conversationId = conversationId)
+            .onEach { messages ->
+                currentMessages.value = messages
+            }
             .map { messages ->
                 messages
                     .asSequence()
@@ -209,4 +251,48 @@ internal class ConversationMessagesDelegateImpl @Inject constructor(
             }
         }
     }
+
+    private fun buildConversationPhotoViewerAttachments(
+        messages: List,
+    ): Sequence {
+        return messages.asSequence().flatMap { message ->
+            buildConversationPhotoViewerAttachments(message = message)
+        }
+    }
+
+    private fun buildConversationPhotoViewerAttachments(
+        message: ConversationMessageData,
+    ): Sequence {
+        val parts = message.parts ?: return emptySequence()
+
+        return parts
+            .asSequence()
+            .withIndex()
+            .filter { indexedPart ->
+                indexedPart.value.isImage
+            }
+            .sortedWith(comparator = photoViewerAttachmentPartComparator)
+            .mapNotNull { indexedPart ->
+                val part = indexedPart.value
+                val contentUri = part.contentUri ?: return@mapNotNull null
+
+                ConversationPhotoViewerAttachment(
+                    partId = part.partId.orEmpty(),
+                    contentUri = contentUri,
+                )
+            }
+    }
+
+    private companion object {
+        private val photoViewerAttachmentPartComparator =
+            compareBy> { indexedPart ->
+                indexedPart.value.partId?.toLongOrNull() == null
+            }.thenBy { indexedPart ->
+                indexedPart.value.partId?.toLongOrNull() ?: Long.MAX_VALUE
+            }.thenBy { indexedPart ->
+                indexedPart.value.partId.orEmpty()
+            }.thenBy { indexedPart ->
+                indexedPart.index
+            }
+    }
 }
diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt
index b5d62262f..a9098cfed 100644
--- a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt
+++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt
@@ -121,6 +121,20 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                 )
             }
 
+            else -> mapAttachmentPart(
+                part = part,
+                contentType = contentType,
+            )
+        }
+    }
+
+    private fun mapAttachmentPart(
+        part: MessagePartData,
+        contentType: String,
+    ): ConversationMessagePartUiModel.Attachment {
+        val partId = part.partId.orEmpty()
+
+        return when {
             ContentType.isAudioType(contentType) -> {
                 ConversationMessagePartUiModel.Attachment.Audio(
                     text = part.text,
@@ -128,6 +142,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                     contentUri = part.contentUri,
                     width = part.width,
                     height = part.height,
+                    partId = partId,
                 )
             }
 
@@ -138,6 +153,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                     contentUri = part.contentUri,
                     width = part.width,
                     height = part.height,
+                    partId = partId,
                 )
             }
 
@@ -151,6 +167,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                     vCardUiModel = conversationVCardAttachmentUiModelMapper.map(
                         metadata = null,
                     ),
+                    partId = partId,
                 )
             }
 
@@ -161,6 +178,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                     contentUri = part.contentUri,
                     width = part.width,
                     height = part.height,
+                    partId = partId,
                 )
             }
 
@@ -171,6 +189,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor(
                     contentUri = part.contentUri,
                     width = part.width,
                     height = part.height,
+                    partId = partId,
                 )
             }
         }
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
index 29058b0f0..a00052774 100644
--- a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt
+++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt
@@ -9,6 +9,7 @@ internal sealed interface ConversationAttachmentOpenAction {
     data class OpenContent(
         val contentType: String,
         val contentUri: String,
+        val partId: String = "",
     ) : ConversationAttachmentOpenAction
 
     @Immutable
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
index 579119716..982c1ea8f 100644
--- a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt
+++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt
@@ -24,6 +24,7 @@ internal sealed interface ConversationMessagePartUiModel {
         val contentUri: Uri?
         val width: Int
         val height: Int
+        val partId: String
 
         @Immutable
         data class Audio(
@@ -32,6 +33,7 @@ internal sealed interface ConversationMessagePartUiModel {
             override val contentUri: Uri?,
             override val width: Int,
             override val height: Int,
+            override val partId: String = "",
         ) : Attachment
 
         @Immutable
@@ -41,6 +43,7 @@ internal sealed interface ConversationMessagePartUiModel {
             override val contentUri: Uri?,
             override val width: Int,
             override val height: Int,
+            override val partId: String = "",
         ) : Attachment
 
         @Immutable
@@ -50,6 +53,7 @@ internal sealed interface ConversationMessagePartUiModel {
             override val contentUri: Uri?,
             override val width: Int,
             override val height: Int,
+            override val partId: String = "",
         ) : Attachment
 
         @Immutable
@@ -60,6 +64,7 @@ internal sealed interface ConversationMessagePartUiModel {
             override val width: Int,
             override val height: Int,
             val vCardUiModel: ConversationVCardAttachmentUiModel,
+            override val partId: String = "",
         ) : Attachment
 
         @Immutable
@@ -69,6 +74,7 @@ internal sealed interface ConversationMessagePartUiModel {
             override val contentUri: Uri?,
             override val width: Int,
             override val height: Int,
+            override val partId: String = "",
         ) : Attachment
     }
 }
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
index 67540a46a..878cb776b 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
@@ -29,6 +29,7 @@ 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.attachment.OnConversationAttachmentClick
 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
@@ -86,7 +87,7 @@ internal fun ConversationMessages(
     subscriptions: ImmutableList = persistentListOf(),
     currentSendSimDisplayName: String? = null,
     additionalTopContentPadding: Dp = 0.dp,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: (String) -> Unit,
     onMessageAvatarClick: (String) -> Unit,
@@ -153,7 +154,7 @@ private fun LazyListScope.conversationMessageItems(
     isSelectionMode: Boolean,
     showIncomingParticipantIdentity: Boolean,
     simDisplayNameByParticipantId: ImmutableMap,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: (String) -> Unit,
     onMessageAvatarClick: (String) -> Unit,
@@ -307,7 +308,7 @@ private fun ConversationMessagesItem(
     isSelected: Boolean,
     showIncomingParticipantIdentity: Boolean,
     simDisplayNameByParticipantId: ImmutableMap,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: (String) -> Unit,
     onMessageAvatarClick: (String) -> Unit,
@@ -528,7 +529,7 @@ private fun ConversationMessagesPreview() {
             showIncomingParticipantIdentity = true,
             subscriptions = previewSubscriptions(),
             currentSendSimDisplayName = "Personal",
-            onAttachmentClick = { _, _ -> },
+            onAttachmentClick = { _, _, _ -> },
             onExternalUriClick = {},
             onMessageClick = {},
             onMessageAvatarClick = {},
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
index 0f091060f..9e7d8a4a2 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt
@@ -5,7 +5,7 @@ import com.android.messaging.ui.conversation.messages.model.attachment.Conversat
 
 internal fun dispatchConversationAttachmentOpenAction(
     action: ConversationAttachmentOpenAction,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
 ) {
     when (action) {
@@ -13,6 +13,7 @@ internal fun dispatchConversationAttachmentOpenAction(
             onAttachmentClick(
                 action.contentType,
                 action.contentUri,
+                action.partId,
             )
         }
 
@@ -29,6 +30,7 @@ internal fun ConversationMessageAttachment.toConversationAttachmentOpenActionOrN
             ConversationAttachmentOpenAction.OpenContent(
                 contentType = part.contentType,
                 contentUri = part.contentUri.toString(),
+                partId = part.partId,
             )
         }
 
@@ -37,6 +39,7 @@ internal fun ConversationMessageAttachment.toConversationAttachmentOpenActionOrN
                 ConversationAttachmentOpenAction.OpenContent(
                     contentType = part.contentType,
                     contentUri = contentUri.toString(),
+                    partId = part.partId,
                 )
             }
         }
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
index 1292a1ea1..ce59c24fb 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt
@@ -29,7 +29,7 @@ import com.android.messaging.ui.core.MessagingPreviewColumn
 @Composable
 internal fun ConversationGenericInlineAttachmentRow(
     attachment: ConversationInlineAttachment.File,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onLongClick: () -> Unit,
 ) {
@@ -125,7 +125,7 @@ private fun ConversationGenericInlineAttachmentRowPreview() {
     MessagingPreviewColumn {
         ConversationGenericInlineAttachmentRow(
             attachment = previewInlineFileAttachment(),
-            onAttachmentClick = { _, _ -> },
+            onAttachmentClick = { _, _, _ -> },
             onExternalUriClick = {},
             onLongClick = {},
         )
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
index 54d2236e7..bd156cbf6 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt
@@ -17,7 +17,7 @@ internal fun ConversationInlineAttachmentRow(
     isIncoming: Boolean,
     isSelectionMode: Boolean,
     useStandaloneAudioAttachmentBackground: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onLongClick: () -> Unit = {},
 ) {
@@ -68,7 +68,7 @@ private fun ConversationInlineAttachmentRowPreview() {
                     isIncoming = true,
                     isSelectionMode = false,
                     useStandaloneAudioAttachmentBackground = true,
-                    onAttachmentClick = { _, _ -> },
+                    onAttachmentClick = { _, _, _ -> },
                     onExternalUriClick = {},
                 )
             }
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
index 52be78fa5..ccfe4d2ee 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt
@@ -20,7 +20,7 @@ internal fun ConversationMessageAttachments(
     isIncoming: Boolean,
     isSelectionMode: Boolean,
     useStandaloneAudioAttachmentBg: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -86,7 +86,7 @@ private fun ConversationMessageAttachmentsPreview() {
             isIncoming = true,
             isSelectionMode = false,
             useStandaloneAudioAttachmentBg = true,
-            onAttachmentClick = { _, _ -> },
+            onAttachmentClick = { _, _, _ -> },
             onExternalUriClick = {},
             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
index 57b46657d..f1e54cd90 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt
@@ -32,7 +32,7 @@ private const val PREVIEW_LONG_LOCATION_SUBTITLE =
 internal fun ConversationVCardInlineAttachmentRow(
     attachment: ConversationInlineAttachment.VCard,
     isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onLongClick: () -> Unit,
 ) {
@@ -237,7 +237,7 @@ private fun PreviewConversationVCardInlineAttachmentRow(
     ConversationVCardInlineAttachmentRow(
         attachment = attachment,
         isSelectionMode = false,
-        onAttachmentClick = { _, _ -> },
+        onAttachmentClick = { _, _, _ -> },
         onExternalUriClick = {},
         onLongClick = {},
     )
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
index a3a895c47..a6a9da0de 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt
@@ -54,7 +54,7 @@ internal fun ConversationGalleryVisualAttachments(
     attachments: ImmutableList,
     hasTextAboveVisualAttachments: Boolean,
     hasTextBelowVisualAttachments: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -95,7 +95,7 @@ internal fun ConversationStandaloneVisualAttachment(
     attachment: ConversationMessageAttachment,
     hasTextAboveVisualAttachments: Boolean,
     hasTextBelowVisualAttachments: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -120,7 +120,7 @@ private fun ConversationVisualAttachmentGrid(
     attachments: ImmutableList,
     hasTextAboveVisualAttachments: Boolean,
     hasTextBelowVisualAttachments: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -177,7 +177,7 @@ private fun ConversationVisualAttachmentCard(
     attachment: ConversationMessageAttachment,
     aspectRatio: Float,
     attachmentShape: RoundedCornerShape,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -203,7 +203,7 @@ private fun ConversationVisualAttachmentSurface(
     attachment: ConversationMessageAttachment,
     attachmentShape: RoundedCornerShape,
     contentScale: ContentScale,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
     overlay: @Composable BoxScope.() -> Unit,
@@ -411,7 +411,7 @@ private fun ConversationVisualAttachmentsPreview() {
                 attachments = previewMessageAttachments(),
                 hasTextAboveVisualAttachments = false,
                 hasTextBelowVisualAttachments = true,
-                onAttachmentClick = { _, _ -> },
+                onAttachmentClick = { _, _, _ -> },
                 onExternalUriClick = {},
                 onMessageLongClick = {},
             )
@@ -419,7 +419,7 @@ private fun ConversationVisualAttachmentsPreview() {
                 attachment = previewMessageAttachments().first(),
                 hasTextAboveVisualAttachments = true,
                 hasTextBelowVisualAttachments = false,
-                onAttachmentClick = { _, _ -> },
+                onAttachmentClick = { _, _, _ -> },
                 onExternalUriClick = {},
                 onMessageLongClick = {},
             )
@@ -427,7 +427,7 @@ private fun ConversationVisualAttachmentsPreview() {
                 attachments = persistentListOf(previewMessageAttachments().last()),
                 hasTextAboveVisualAttachments = false,
                 hasTextBelowVisualAttachments = false,
-                onAttachmentClick = { _, _ -> },
+                onAttachmentClick = { _, _, _ -> },
                 onExternalUriClick = {},
                 onMessageLongClick = {},
             )
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/OnConversationAttachmentClick.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/OnConversationAttachmentClick.kt
new file mode 100644
index 000000000..4bb99aade
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/OnConversationAttachmentClick.kt
@@ -0,0 +1,7 @@
+package com.android.messaging.ui.conversation.messages.ui.attachment
+
+internal typealias OnConversationAttachmentClick = (
+    contentType: String,
+    contentUri: String,
+    partId: String,
+) -> Unit
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
index caec0cdd1..bea920f8a 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt
@@ -25,6 +25,7 @@ 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
+import com.android.messaging.ui.conversation.messages.ui.attachment.OnConversationAttachmentClick
 import com.android.messaging.ui.conversation.preview.previewAudioPart
 import com.android.messaging.ui.conversation.preview.previewFilePart
 import com.android.messaging.ui.conversation.preview.previewImagePart
@@ -48,7 +49,8 @@ internal fun ConversationMessage(
     isSelectionMode: Boolean = false,
     showIncomingParticipantIdentity: Boolean = true,
     simDisplayName: String? = null,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> },
+    onAttachmentClick: OnConversationAttachmentClick =
+        { _, _, _ -> },
     onExternalUriClick: (String) -> Unit = {},
     onMessageClick: () -> Unit = {},
     onMessageAvatarClick: () -> Unit = {},
@@ -271,7 +273,7 @@ private fun ConversationMessageContent(
     layout: ConversationMessageLayout,
     maxBubbleWidth: Dp,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: () -> Unit,
     onMessageAvatarClick: () -> Unit,
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
index daa9d1acd..9ccaa1a36 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
@@ -27,6 +27,7 @@ import com.android.messaging.ui.conversation.messages.model.message.Conversation
 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.ui.attachment.ConversationMessageAttachments
+import com.android.messaging.ui.conversation.messages.ui.attachment.OnConversationAttachmentClick
 import com.android.messaging.ui.conversation.messages.ui.text.ConversationMessageText
 import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkColor
 import com.android.messaging.ui.conversation.preview.previewAudioPart
@@ -54,7 +55,7 @@ internal fun ConversationMessageBubble(
     layout: ConversationMessageLayout,
     maxBubbleWidth: Dp,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -64,23 +65,16 @@ internal fun ConversationMessageBubble(
 
     when (layout.bubbleLayoutMode) {
         ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> {
-            ConversationMessageAttachmentOnlyContainer(
+            ConversationMessageAttachmentOnlyBubble(
                 modifier = bubbleModifier,
-                bubbleShape = layout.bubbleShape,
+                layout = layout,
                 message = message,
                 isSelected = isSelected,
-            ) {
-                ConversationMessageAttachmentBubbleContent(
-                    modifier = Modifier.fillMaxWidth(),
-                    layout = layout,
-                    message = message,
-                    isSelected = isSelected,
-                    isSelectionMode = isSelectionMode,
-                    onAttachmentClick = onAttachmentClick,
-                    onExternalUriClick = onExternalUriClick,
-                    onMessageLongClick = onMessageLongClick,
-                )
-            }
+                isSelectionMode = isSelectionMode,
+                onAttachmentClick = onAttachmentClick,
+                onExternalUriClick = onExternalUriClick,
+                onMessageLongClick = onMessageLongClick,
+            )
         }
 
         ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> {
@@ -112,6 +106,36 @@ internal fun ConversationMessageBubble(
     }
 }
 
+@Composable
+private fun ConversationMessageAttachmentOnlyBubble(
+    modifier: Modifier,
+    layout: ConversationMessageLayout,
+    message: ConversationMessageUiModel,
+    isSelected: Boolean,
+    isSelectionMode: Boolean,
+    onAttachmentClick: OnConversationAttachmentClick,
+    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,
@@ -119,7 +143,7 @@ private fun ConversationMessageAttachmentSurfaceBubble(
     isSelected: Boolean,
     message: ConversationMessageUiModel,
     isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -149,7 +173,7 @@ private fun ConversationMessageTextSurfaceBubble(
     message: ConversationMessageUiModel,
     isSelectionMode: Boolean,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -247,7 +271,7 @@ private fun ConversationMessageTextBubbleContent(
     isSelected: Boolean,
     isSelectionMode: Boolean,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -304,7 +328,7 @@ private fun ConversationMessageAttachmentBubbleContent(
     message: ConversationMessageUiModel,
     isSelected: Boolean,
     isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -390,7 +414,7 @@ private fun ConversationMessageBody(
     content: ConversationMessageContent,
     isIncoming: Boolean,
     isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubblePreviewSupport.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubblePreviewSupport.kt
index dace5be6e..cf95075d4 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubblePreviewSupport.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubblePreviewSupport.kt
@@ -62,7 +62,7 @@ internal fun ConversationMessageBubblePreviewItem(
         ),
         maxBubbleWidth = 320.dp,
         simDisplayName = simDisplayName,
-        onAttachmentClick = { _, _ -> },
+        onAttachmentClick = { _, _, _ -> },
         onExternalUriClick = {},
         onMessageLongClick = {},
     )
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
index bbf6b517d..44b271831 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 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.ui.attachment.OnConversationAttachmentClick
 import com.android.messaging.ui.conversation.preview.previewAudioPart
 import com.android.messaging.ui.conversation.preview.previewFilePart
 import com.android.messaging.ui.conversation.preview.previewImagePart
@@ -45,7 +46,7 @@ internal fun ConversationMessageBubbleRow(
     layout: ConversationMessageLayout,
     maxBubbleWidth: Dp,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: () -> Unit,
     onMessageAvatarClick: () -> Unit,
@@ -77,12 +78,12 @@ internal fun ConversationMessageBubbleRow(
             layout = layout,
             maxBubbleWidth = maxBubbleWidth,
             simDisplayName = simDisplayName,
-            onAttachmentClick = { contentType, contentUri ->
+            onAttachmentClick = { contentType, contentUri, partId ->
                 when {
                     isSelectionMode -> onMessageClick()
                     message.canDownloadMessage -> onMessageDownloadClick()
                     message.canResendMessage -> onMessageResendClick()
-                    else -> onAttachmentClick(contentType, contentUri)
+                    else -> onAttachmentClick(contentType, contentUri, partId)
                 }
             },
             onExternalUriClick = { uri ->
@@ -673,7 +674,7 @@ private fun ConversationMessageRowsPreviewItem(
             layout = layout,
             maxBubbleWidth = 320.dp,
             simDisplayName = simDisplayName,
-            onAttachmentClick = { _, _ -> },
+            onAttachmentClick = { _, _, _ -> },
             onExternalUriClick = {},
             onMessageClick = {},
             onMessageAvatarClick = {},
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt
index de1f7ffa5..b24927c23 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt
@@ -28,6 +28,7 @@ internal suspend fun openAttachmentPreviewEffect(
         contentUri = effect.contentUri,
         contentType = effect.contentType,
         imageCollectionUri = effect.imageCollectionUri,
+        initialPhotoOccurrenceIndex = effect.initialPhotoOccurrenceIndex,
         awaitHostBounds = {
             snapshotFlow { hostBoundsState.value }
                 .filterNotNull()
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
index 5dde73d60..8632fed22 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
@@ -37,6 +37,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_LOADING_INDICATOR_TEST
 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.messages.ui.attachment.OnConversationAttachmentClick
 import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState
 import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState
 import com.android.messaging.ui.subscription.mapper.resolveDisplayName
@@ -58,7 +59,7 @@ internal fun ConversationScreenContent(
     contentPadding: PaddingValues,
     pendingScrollPosition: Int?,
     onPendingScrollPositionConsumed: () -> Unit,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: (String) -> Unit,
     onMessageAvatarClick: (String) -> Unit,
@@ -166,7 +167,7 @@ private fun ConversationScreenPresentContent(
     contentBackdropColor: Color,
     pendingScrollPosition: Int?,
     onPendingScrollPositionConsumed: () -> Unit,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageClick: (String) -> Unit,
     onMessageAvatarClick: (String) -> Unit,
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContentPreviewSupport.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContentPreviewSupport.kt
index 41116143a..e1067638d 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContentPreviewSupport.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContentPreviewSupport.kt
@@ -62,7 +62,7 @@ internal fun ConversationScreenContentPreview(
             contentPadding = PaddingValues(),
             pendingScrollPosition = null,
             onPendingScrollPositionConsumed = {},
-            onAttachmentClick = { _, _ -> },
+            onAttachmentClick = { _, _, _ -> },
             onExternalUriClick = {},
             onMessageClick = {},
             onMessageAvatarClick = {},
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt
index b2e4564c1..0e15cbf5c 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt
@@ -70,6 +70,7 @@ internal interface ConversationScreenModel {
     fun onMessageAttachmentClicked(
         contentType: String,
         contentUri: String,
+        partId: String,
     )
 
     fun onMessageClick(messageId: String)
@@ -458,18 +459,27 @@ internal class ConversationViewModel @Inject constructor(
     override fun onMessageAttachmentClicked(
         contentType: String,
         contentUri: String,
+        partId: String,
     ) {
         val imageCollectionUri = conversationIdFlow
             .value
             ?.let(MessagingContentProvider::buildConversationImagesUri)
             ?.toString()
 
+        val initialPhotoOccurrenceIndex =
+            conversationMessagesDelegate.resolvePhotoViewerInitialOccurrenceIndex(
+                contentType = contentType,
+                partId = partId,
+                contentUri = contentUri,
+            )
+
         viewModelScope.launch(defaultDispatcher) {
             _effects.emit(
                 ConversationScreenEffect.OpenAttachmentPreview(
                     contentType = contentType,
                     contentUri = contentUri,
                     imageCollectionUri = imageCollectionUri,
+                    initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
                 ),
             )
         }
diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt
index 557c4d15f..53daca5e9 100644
--- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt
+++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt
@@ -29,6 +29,7 @@ internal sealed interface ConversationScreenEffect {
         val contentType: String,
         val contentUri: String,
         val imageCollectionUri: String?,
+        val initialPhotoOccurrenceIndex: Int = 0,
     ) : ConversationScreenEffect
 
     data class OpenExternalUri(
diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt
index 1e75b04aa..e5b255933 100644
--- a/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt
@@ -59,6 +59,10 @@ internal class PhotoViewerActivity : ComponentActivity() {
         return PhotoViewerLaunchRequest(
             initialPhotoUri = initialPhotoUri.toString(),
             photosUri = photosUri.toString(),
+            initialPhotoOccurrenceIndex = intent.getIntExtra(
+                EXTRA_INITIAL_PHOTO_OCCURRENCE_INDEX,
+                0,
+            ),
             sourceBounds = sourceBoundsFromIntent(intent = intent),
         )
     }
@@ -80,6 +84,8 @@ internal class PhotoViewerActivity : ComponentActivity() {
             "com.android.messaging.photoviewer.extra.INITIAL_PHOTO_URI"
         private const val EXTRA_PHOTOS_URI =
             "com.android.messaging.photoviewer.extra.PHOTOS_URI"
+        private const val EXTRA_INITIAL_PHOTO_OCCURRENCE_INDEX =
+            "com.android.messaging.photoviewer.extra.INITIAL_PHOTO_OCCURRENCE_INDEX"
         private const val EXTRA_START_BOUNDS_LEFT =
             "com.android.messaging.photoviewer.extra.START_BOUNDS_LEFT"
         private const val EXTRA_START_BOUNDS_TOP =
@@ -90,15 +96,18 @@ internal class PhotoViewerActivity : ComponentActivity() {
             "com.android.messaging.photoviewer.extra.START_BOUNDS_HEIGHT"
 
         @JvmStatic
+        @JvmOverloads
         fun createIntent(
             context: Context,
             initialPhotoUri: Uri,
             photosUri: Uri,
             sourceBounds: Rect,
+            initialPhotoOccurrenceIndex: Int = 0,
         ): Intent {
             return Intent(context, PhotoViewerActivity::class.java).apply {
                 putExtra(EXTRA_INITIAL_PHOTO_URI, initialPhotoUri)
                 putExtra(EXTRA_PHOTOS_URI, photosUri)
+                putExtra(EXTRA_INITIAL_PHOTO_OCCURRENCE_INDEX, initialPhotoOccurrenceIndex)
                 putExtra(EXTRA_START_BOUNDS_LEFT, sourceBounds.left)
                 putExtra(EXTRA_START_BOUNDS_TOP, sourceBounds.top)
                 putExtra(EXTRA_START_BOUNDS_WIDTH, sourceBounds.width())
diff --git a/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt
index 005c8d74b..4471dd757 100644
--- a/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt
+++ b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt
@@ -7,12 +7,14 @@ internal data class PhotoViewerLaunchRequest(
     val initialPhotoUri: String,
     val photosUri: String,
     val sourceBounds: PhotoViewerSourceBounds,
+    val initialPhotoOccurrenceIndex: Int = 0,
 )
 
 @Immutable
 internal data class PhotoViewerLaunchRequestKey(
     val initialPhotoUri: String,
     val photosUri: String,
+    val initialPhotoOccurrenceIndex: Int,
 )
 
 internal fun photoViewerLaunchRequestKey(
@@ -21,5 +23,6 @@ internal fun photoViewerLaunchRequestKey(
     return PhotoViewerLaunchRequestKey(
         initialPhotoUri = launchRequest.initialPhotoUri,
         photosUri = launchRequest.photosUri,
+        initialPhotoOccurrenceIndex = launchRequest.initialPhotoOccurrenceIndex,
     )
 }
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
index dbd99e8a6..38515adc9 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
@@ -9,6 +9,7 @@ import com.android.messaging.data.media.model.PhotoViewerItems
 import com.android.messaging.data.media.model.PhotoViewerItemsLoadResult
 import com.android.messaging.data.media.repository.PhotoViewerRepository
 import com.android.messaging.di.core.DefaultDispatcher
+import com.android.messaging.domain.photoviewer.usecase.NormalizePhotoViewerUri
 import com.android.messaging.domain.photoviewer.usecase.PreparePhotoViewerSendUri
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest
 import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequestKey
@@ -56,6 +57,7 @@ internal interface PhotoViewerScreenModel {
 @HiltViewModel
 internal class PhotoViewerViewModel @Inject constructor(
     private val photoViewerRepository: PhotoViewerRepository,
+    private val normalizePhotoViewerUri: NormalizePhotoViewerUri,
     private val preparePhotoViewerSendUri: PreparePhotoViewerSendUri,
     @param:DefaultDispatcher
     private val defaultDispatcher: CoroutineDispatcher,
@@ -91,6 +93,7 @@ internal class PhotoViewerViewModel @Inject constructor(
                 .getPhotoViewerItems(
                     photosUri = launchRequest.photosUri.toUri(),
                     initialPhotoUri = launchRequest.initialPhotoUri.toUri(),
+                    initialPhotoOccurrenceIndex = launchRequest.initialPhotoOccurrenceIndex,
                 )
                 .catch { throwable ->
                     if (throwable is CancellationException) {
@@ -376,15 +379,6 @@ internal class PhotoViewerViewModel @Inject constructor(
         }
     }
 
-    private fun normalizePhotoViewerUri(uri: Uri): String {
-        return uri
-            .buildUpon()
-            .clearQuery()
-            .fragment(null)
-            .build()
-            .toString()
-    }
-
     private companion object {
         const val TAG = "PhotoViewerViewModel"
     }

From d4e08bc910a3f3ac2811e0433290305c7965f1d4 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 20:49:30 +0300
Subject: [PATCH 09/19] Add photo viewer page indicator

---
 .../screen/PhotoViewerScreenContentTest.kt    | 66 +++++++++++++++---
 .../ui/common/components/PagerIndicator.kt    | 51 ++++++++++++++
 .../common/PickerReviewAttachments.kt         | 38 +----------
 .../ui/photoviewer/PhotoViewerTestTags.kt     |  1 +
 .../photoviewer/screen/PhotoViewerContent.kt  | 67 +++++++++++++------
 5 files changed, 159 insertions(+), 64 deletions(-)
 create mode 100644 src/com/android/messaging/ui/common/components/PagerIndicator.kt

diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
index 7f1a3eba0..8f2b88960 100644
--- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
+++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
@@ -33,6 +33,7 @@ import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGER_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG
@@ -73,6 +74,44 @@ internal class PhotoViewerScreenContentTest {
             .assertIsDisplayed()
     }
 
+    @Test
+    fun pageIndicator_whenMultipleItemsInCarousel_isDisplayed() {
+        setScreenContent()
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG)
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun pageIndicator_whenSingleItem_doesNotExist() {
+        setScreenContent(
+            uiState = loadedPhotoViewerUiState(itemCount = 1),
+        )
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG)
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun pageIndicator_whenImmersive_doesNotExist() {
+        setScreenContent(
+            uiState = loadedPhotoViewerUiState(displayMode = PhotoViewerDisplayMode.Immersive),
+        )
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG)
+            .assertDoesNotExist()
+    }
+
+    @Test
+    fun pageIndicator_whenClosing_doesNotExist() {
+        setScreenContent(
+            uiState = loadedPhotoViewerUiState(isClosing = true),
+        )
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG)
+            .assertDoesNotExist()
+    }
+
     @Test
     fun topBar_withHorizontalNavigationInsets_keepsEdgeActionsInsideSafeRegion() {
         val containerWidth = 640.dp
@@ -482,17 +521,28 @@ internal class PhotoViewerScreenContentTest {
 
     private fun loadedPhotoViewerUiState(
         firstItemCanUseActions: Boolean = true,
+        itemCount: Int = 2,
+        displayMode: PhotoViewerDisplayMode = PhotoViewerDisplayMode.Carousel,
+        isClosing: Boolean = false,
     ): PhotoViewerUiState {
-        return PhotoViewerUiState(
-            items = persistentListOf(
-                photoViewerItem(
-                    index = 1,
-                    senderName = FIRST_SENDER,
-                    canUseActions = firstItemCanUseActions,
-                ),
+        val firstItem = photoViewerItem(
+            index = 1,
+            senderName = FIRST_SENDER,
+            canUseActions = firstItemCanUseActions,
+        )
+        val items = when (itemCount) {
+            1 -> persistentListOf(firstItem)
+            else -> persistentListOf(
+                firstItem,
                 photoViewerItem(index = 2, senderName = SECOND_SENDER),
-            ),
+            )
+        }
+
+        return PhotoViewerUiState(
+            items = items,
             loadState = PhotoViewerLoadState.Loaded,
+            displayMode = displayMode,
+            isClosing = isClosing,
         )
     }
 
diff --git a/src/com/android/messaging/ui/common/components/PagerIndicator.kt b/src/com/android/messaging/ui/common/components/PagerIndicator.kt
new file mode 100644
index 000000000..20fee9386
--- /dev/null
+++ b/src/com/android/messaging/ui/common/components/PagerIndicator.kt
@@ -0,0 +1,51 @@
+package com.android.messaging.ui.common.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+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.unit.dp
+
+
+@Composable
+internal fun PagerIndicator(
+    pagerState: PagerState,
+    pageCount: Int,
+    modifier: Modifier = Modifier,
+) {
+    if (pageCount <= 1) {
+        return
+    }
+
+    Row(
+        modifier = modifier,
+        horizontalArrangement = Arrangement.spacedBy(space = 6.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        repeat(times = pageCount) { index ->
+            val isSelected = pagerState.currentPage == index
+            val dotColor = when {
+                isSelected -> MaterialTheme.colorScheme.primary
+                else -> {
+                    MaterialTheme.colorScheme.onSurfaceVariant
+                        .copy(alpha = 0.4f)
+                }
+            }
+
+            Box(
+                modifier = Modifier
+                    .size(size = 8.dp)
+                    .clip(shape = CircleShape)
+                    .background(color = dotColor),
+            )
+        }
+    }
+}
diff --git a/src/com/android/messaging/ui/conversationpicker/common/PickerReviewAttachments.kt b/src/com/android/messaging/ui/conversationpicker/common/PickerReviewAttachments.kt
index 225bb3ae1..51c72f4ba 100644
--- a/src/com/android/messaging/ui/conversationpicker/common/PickerReviewAttachments.kt
+++ b/src/com/android/messaging/ui/conversationpicker/common/PickerReviewAttachments.kt
@@ -1,6 +1,5 @@
 package com.android.messaging.ui.conversationpicker.common
 
-import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.PagerState
-import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
@@ -29,6 +27,7 @@ import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
 import com.android.messaging.R
+import com.android.messaging.ui.common.components.PagerIndicator
 import com.android.messaging.ui.common.components.attachment.AudioAttachmentCell
 import com.android.messaging.ui.common.components.attachment.VCardAttachmentCell
 import com.android.messaging.ui.common.components.mediapreview.MediaPreviewCard
@@ -44,9 +43,6 @@ private val ReviewPageHorizontalPadding = 24.dp
 private val ReviewPageVerticalPadding = 12.dp
 private val ReviewDeleteChipPadding = 8.dp
 private val ReviewPageIndicatorPadding = 12.dp
-private val ReviewIndicatorDotSize = 8.dp
-private val ReviewIndicatorDotSpacing = 6.dp
-private const val REVIEW_INDICATOR_INACTIVE_ALPHA = 0.4f
 
 @Composable
 internal fun PickerReviewAttachments(
@@ -98,7 +94,7 @@ internal fun PickerReviewAttachments(
         }
 
         if (attachments.size > 1) {
-            PickerReviewPagerIndicator(
+            PagerIndicator(
                 pagerState = pagerState,
                 pageCount = attachments.size,
                 modifier = Modifier.padding(vertical = ReviewPageIndicatorPadding),
@@ -107,36 +103,6 @@ internal fun PickerReviewAttachments(
     }
 }
 
-@Composable
-private fun PickerReviewPagerIndicator(
-    pagerState: PagerState,
-    pageCount: Int,
-    modifier: Modifier = Modifier,
-) {
-    Row(
-        modifier = modifier,
-        horizontalArrangement = Arrangement.spacedBy(space = ReviewIndicatorDotSpacing),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        repeat(times = pageCount) { index ->
-            val isSelected = pagerState.currentPage == index
-            val dotColor = when {
-                isSelected -> MaterialTheme.colorScheme.primary
-                else ->
-                    MaterialTheme.colorScheme.onSurfaceVariant
-                        .copy(alpha = REVIEW_INDICATOR_INACTIVE_ALPHA)
-            }
-
-            Box(
-                modifier = Modifier
-                    .size(size = ReviewIndicatorDotSize)
-                    .clip(shape = CircleShape)
-                    .background(color = dotColor),
-            )
-        }
-    }
-}
-
 @Composable
 private fun PickerReviewAttachmentPage(
     attachment: AttachmentUiModel,
diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
index ff5b65ea4..2956cf849 100644
--- a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
@@ -6,6 +6,7 @@ internal const val PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG = "photo_viewer_forwa
 internal const val PHOTO_VIEWER_METADATA_SHEET_TEST_TAG = "photo_viewer_metadata_sheet"
 internal const val PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG = "photo_viewer_overflow_button"
 internal const val PHOTO_VIEWER_PAGER_TEST_TAG = "photo_viewer_pager"
+internal const val PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG = "photo_viewer_page_indicator"
 internal const val PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG = "photo_viewer_save_button"
 internal const val PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG = "photo_viewer_share_button"
 internal const val PHOTO_VIEWER_TITLE_TEST_TAG = "photo_viewer_title"
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt
index 1703dc656..72196f4eb 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt
@@ -4,6 +4,8 @@ import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.pager.PagerState
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.runtime.Composable
@@ -18,10 +20,14 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
 import com.android.messaging.data.media.model.PhotoViewerItem
+import com.android.messaging.ui.common.components.PagerIndicator
 import com.android.messaging.ui.common.components.mediapreview.MediaPreviewBackground
 import com.android.messaging.ui.common.components.mediapreview.MediaPreviewItem
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG
 import com.android.messaging.ui.photoviewer.component.PhotoViewerDismissDragState
 import com.android.messaging.ui.photoviewer.component.PhotoViewerEmptyState
 import com.android.messaging.ui.photoviewer.component.PhotoViewerLoadError
@@ -37,6 +43,7 @@ import kotlinx.collections.immutable.ImmutableList
 
 private const val PHOTO_VIEWER_CLOSE_ANIMATION_MILLIS = 250
 private const val PHOTO_VIEWER_ENTER_ANIMATION_MILLIS = 350
+private val PhotoViewerPageIndicatorBottomPadding = 24.dp
 
 @Composable
 internal fun PhotoViewerAnimatedContent(
@@ -113,10 +120,18 @@ internal fun PhotoViewerContent(
     val shouldShowTopBar by remember(dismissDragState) {
         derivedStateOf { dismissDragState.shouldShowTopBar }
     }
+    val shouldShowChrome = uiState.displayMode == PhotoViewerDisplayMode.Carousel &&
+        currentItem != null &&
+        !uiState.isClosing &&
+        shouldShowTopBar
+    val shouldShowPageIndicator = shouldShowChrome &&
+        uiState.loadState == PhotoViewerLoadState.Loaded &&
+        uiState.items.size > 1
 
     Box(modifier = Modifier.fillMaxSize()) {
         PhotoViewerMediaContent(
             uiState = uiState,
+            isPageIndicatorVisible = shouldShowPageIndicator,
             onPageSettled = onPageSettled,
             onToggleDisplayMode = onToggleDisplayMode,
             onEnterImmersiveMode = onEnterImmersiveMode,
@@ -150,10 +165,7 @@ internal fun PhotoViewerContent(
         }
 
         PhotoViewerTopBar(
-            isVisible = uiState.displayMode == PhotoViewerDisplayMode.Carousel &&
-                currentItem != null &&
-                !uiState.isClosing &&
-                shouldShowTopBar,
+            isVisible = shouldShowChrome,
             item = currentItem,
             actionsEnabled = actionsEnabled,
             onMetadataClick = onMetadataClick,
@@ -168,6 +180,7 @@ internal fun PhotoViewerContent(
 @Composable
 private fun PhotoViewerMediaContent(
     uiState: PhotoViewerUiState,
+    isPageIndicatorVisible: Boolean,
     onPageSettled: (Int) -> Unit,
     onToggleDisplayMode: () -> Unit,
     onEnterImmersiveMode: () -> Unit,
@@ -184,23 +197,37 @@ private fun PhotoViewerMediaContent(
         onPageSettled = onPageSettled,
     )
 
-    PhotoViewerBlurredBackground(
-        dismissDragState = dismissDragState,
-        items = rememberPhotoViewerPreviewItems(items = uiState.items),
-        pagerState = pagerState,
-    )
+    Box(modifier = Modifier.fillMaxSize()) {
+        PhotoViewerBlurredBackground(
+            dismissDragState = dismissDragState,
+            items = rememberPhotoViewerPreviewItems(items = uiState.items),
+            pagerState = pagerState,
+        )
 
-    PhotoViewerPager(
-        modifier = Modifier.fillMaxSize(),
-        items = uiState.items,
-        pagerState = pagerState,
-        displayMode = uiState.displayMode,
-        isClosing = uiState.isClosing,
-        onToggleDisplayMode = onToggleDisplayMode,
-        onEnterImmersiveMode = onEnterImmersiveMode,
-        dismissDragState = dismissDragState,
-        onCloseClick = onCloseClick,
-    )
+        PhotoViewerPager(
+            modifier = Modifier.fillMaxSize(),
+            items = uiState.items,
+            pagerState = pagerState,
+            displayMode = uiState.displayMode,
+            isClosing = uiState.isClosing,
+            onToggleDisplayMode = onToggleDisplayMode,
+            onEnterImmersiveMode = onEnterImmersiveMode,
+            dismissDragState = dismissDragState,
+            onCloseClick = onCloseClick,
+        )
+
+        if (isPageIndicatorVisible) {
+            PagerIndicator(
+                modifier = Modifier
+                    .align(alignment = Alignment.BottomCenter)
+                    .navigationBarsPadding()
+                    .padding(bottom = PhotoViewerPageIndicatorBottomPadding)
+                    .testTag(tag = PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG),
+                pagerState = pagerState,
+                pageCount = uiState.items.size,
+            )
+        }
+    }
 }
 
 @Composable

From ecd5eec98fa37c2be06f47e85a0b52f036dedc27 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 21:01:48 +0300
Subject: [PATCH 10/19] Reuse photo viewer system bar controller

---
 .../ui/common/components/PagerIndicator.kt        |  1 -
 .../screen/PhotoViewerSystemBarsEffect.kt         | 15 +++++++--------
 2 files changed, 7 insertions(+), 9 deletions(-)

diff --git a/src/com/android/messaging/ui/common/components/PagerIndicator.kt b/src/com/android/messaging/ui/common/components/PagerIndicator.kt
index 20fee9386..f576e480a 100644
--- a/src/com/android/messaging/ui/common/components/PagerIndicator.kt
+++ b/src/com/android/messaging/ui/common/components/PagerIndicator.kt
@@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.unit.dp
 
-
 @Composable
 internal fun PagerIndicator(
     pagerState: PagerState,
diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt
index 405da79d7..d3d8a7c1b 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt
@@ -5,6 +5,7 @@ import android.view.Window
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalView
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsControllerCompat
@@ -17,12 +18,14 @@ internal fun PhotoViewerSystemBarsEffect(
 ) {
     val view = LocalView.current
     val window = view.context.findActivityWindow()
-
-    DisposableEffect(window, view) {
-        val controller = createWindowInsetsController(
+    val controller = remember(window, view) {
+        createWindowInsetsController(
             window = window,
             view = view,
         )
+    }
+
+    DisposableEffect(controller) {
         controller?.systemBarsBehavior = WindowInsetsControllerCompat
             .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
 
@@ -32,11 +35,7 @@ internal fun PhotoViewerSystemBarsEffect(
         }
     }
 
-    LaunchedEffect(window, view, displayMode) {
-        val controller = createWindowInsetsController(
-            window = window,
-            view = view,
-        )
+    LaunchedEffect(controller, displayMode) {
         val systemBars = WindowInsetsCompat.Type.systemBars()
         when (displayMode) {
             PhotoViewerDisplayMode.Carousel -> controller?.show(systemBars)

From 4cc2a6893204e64dc443e2f5248e14ba17346407 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 21:28:39 +0300
Subject: [PATCH 11/19] Hide timestamps for draft photo attachments

---
 .../screen/PhotoViewerScreenContentTest.kt    | 40 ++++++++++++++++++-
 .../ui/photoviewer/PhotoViewerTestTags.kt     |  3 ++
 .../component/PhotoViewerMetadataSheet.kt     | 17 +++++---
 .../component/PhotoViewerTopBar.kt            | 18 +++++----
 4 files changed, 65 insertions(+), 13 deletions(-)

diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
index 8f2b88960..db4f0ac5c 100644
--- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
+++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
@@ -30,12 +30,14 @@ import com.android.messaging.ui.core.AppTheme
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGER_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TIMESTAMP_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG
 import com.android.messaging.ui.photoviewer.component.PhotoViewerTopBar
@@ -237,6 +239,30 @@ internal class PhotoViewerScreenContentTest {
         composeRule.onNodeWithText(text = IMAGE_JPEG).assertIsDisplayed()
     }
 
+    @Test
+    fun timestamps_whenCurrentItemIsNotDraft_areDisplayed() {
+        setScreenContent()
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_TIMESTAMP_TEST_TAG)
+            .assertIsDisplayed()
+        openMetadataSheet()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG)
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun timestamps_whenCurrentItemIsDraft_doNotExist() {
+        setScreenContent(
+            uiState = loadedPhotoViewerUiState(firstItemIsDraft = true),
+        )
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_TIMESTAMP_TEST_TAG)
+            .assertDoesNotExist()
+        openMetadataSheet()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG)
+            .assertDoesNotExist()
+    }
+
     @Test
     fun chromeActions_emitCallbacks() {
         val closeClickCount = AtomicInteger()
@@ -519,8 +545,18 @@ internal class PhotoViewerScreenContentTest {
         return uiState
     }
 
+    private fun openMetadataSheet() {
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG)
+            .performClick()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG)
+            .performClick()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_SHEET_TEST_TAG)
+            .assertIsDisplayed()
+    }
+
     private fun loadedPhotoViewerUiState(
         firstItemCanUseActions: Boolean = true,
+        firstItemIsDraft: Boolean = false,
         itemCount: Int = 2,
         displayMode: PhotoViewerDisplayMode = PhotoViewerDisplayMode.Carousel,
         isClosing: Boolean = false,
@@ -529,6 +565,7 @@ internal class PhotoViewerScreenContentTest {
             index = 1,
             senderName = FIRST_SENDER,
             canUseActions = firstItemCanUseActions,
+            isDraft = firstItemIsDraft,
         )
         val items = when (itemCount) {
             1 -> persistentListOf(firstItem)
@@ -550,6 +587,7 @@ internal class PhotoViewerScreenContentTest {
         index: Int,
         senderName: String,
         canUseActions: Boolean = true,
+        isDraft: Boolean = false,
     ): PhotoViewerItem {
         return PhotoViewerItem(
             contentUri = photoViewerImageUri(),
@@ -557,7 +595,7 @@ internal class PhotoViewerScreenContentTest {
             senderName = senderName,
             senderDestination = "+1555000$index",
             receivedTimestampMillis = 1_735_689_600_000L + index,
-            isDraft = false,
+            isDraft = isDraft,
             canUseActions = canUseActions,
         )
     }
diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
index 2956cf849..a15ef5cf4 100644
--- a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
@@ -3,11 +3,14 @@ package com.android.messaging.ui.photoviewer
 internal const val PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG = "photo_viewer_close_button"
 internal const val PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG = "photo_viewer_details_menu_item"
 internal const val PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG = "photo_viewer_forward_menu_item"
+internal const val PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG =
+    "photo_viewer_metadata_received_timestamp"
 internal const val PHOTO_VIEWER_METADATA_SHEET_TEST_TAG = "photo_viewer_metadata_sheet"
 internal const val PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG = "photo_viewer_overflow_button"
 internal const val PHOTO_VIEWER_PAGER_TEST_TAG = "photo_viewer_pager"
 internal const val PHOTO_VIEWER_PAGE_INDICATOR_TEST_TAG = "photo_viewer_page_indicator"
 internal const val PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG = "photo_viewer_save_button"
 internal const val PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG = "photo_viewer_share_button"
+internal const val PHOTO_VIEWER_TIMESTAMP_TEST_TAG = "photo_viewer_timestamp"
 internal const val PHOTO_VIEWER_TITLE_TEST_TAG = "photo_viewer_title"
 internal const val PHOTO_VIEWER_ZOOMABLE_PHOTO_TEST_TAG = "photo_viewer_zoomable_photo"
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
index 513150a5a..8892aba4c 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
 import com.android.messaging.R
 import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.ui.core.MessagingPreviewTheme
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG
 import com.android.messaging.ui.photoviewer.preview.previewPhotoViewerItems
 import com.android.messaging.util.Dates
@@ -122,10 +123,15 @@ private fun PhotoViewerMetadataGroup(
             value = item.title.takeIf { it.isNotBlank() }
                 ?: stringResource(id = R.string.unknown_sender),
         )
-        PhotoViewerMetadataRow(
-            label = stringResource(id = R.string.message_details_received_label),
-            value = Dates.getMessageTimeString(item.receivedTimestampMillis).toString(),
-        )
+        if (!item.isDraft) {
+            PhotoViewerMetadataRow(
+                modifier = Modifier.testTag(
+                    tag = PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG,
+                ),
+                label = stringResource(id = R.string.message_details_received_label),
+                value = Dates.getMessageTimeString(item.receivedTimestampMillis).toString(),
+            )
+        }
         PhotoViewerMetadataRow(
             label = stringResource(id = R.string.message_details_type_label),
             value = item.contentType,
@@ -179,11 +185,12 @@ private fun PhotoViewerMetadataActions(
 
 @Composable
 private fun PhotoViewerMetadataRow(
+    modifier: Modifier = Modifier,
     label: String,
     value: String,
 ) {
     Column(
-        modifier = Modifier.fillMaxWidth(),
+        modifier = modifier.fillMaxWidth(),
         verticalArrangement = Arrangement.spacedBy(space = 4.dp),
     ) {
         Text(
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
index 474004c96..31b8fc44f 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
@@ -52,6 +52,7 @@ import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SAVE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_SHARE_BUTTON_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TIMESTAMP_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_TITLE_TEST_TAG
 import com.android.messaging.util.Dates
 
@@ -181,13 +182,16 @@ private fun PhotoViewerTitleBlock(
             maxLines = 1,
             overflow = TextOverflow.Ellipsis,
         )
-        Text(
-            text = photoViewerTimestamp(item = item),
-            style = MaterialTheme.typography.bodySmall,
-            color = secondaryContentColor,
-            maxLines = 1,
-            overflow = TextOverflow.Ellipsis,
-        )
+        if (item?.isDraft != true) {
+            Text(
+                modifier = Modifier.testTag(tag = PHOTO_VIEWER_TIMESTAMP_TEST_TAG),
+                text = photoViewerTimestamp(item = item),
+                style = MaterialTheme.typography.bodySmall,
+                color = secondaryContentColor,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
     }
 }
 

From ee8fbdbaefaee1706f7919d22eabc52037b0835d Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 21:46:01 +0300
Subject: [PATCH 12/19] Show self label for outgoing photo attachments

---
 .../screen/PhotoViewerScreenContentTest.kt    | 30 +++++++++++++++++++
 .../PhotoViewerRepositoryImplTest.kt          |  2 ++
 .../screen/PhotoViewerViewModelTest.kt        |  1 +
 .../data/media/model/PhotoViewerItem.kt       | 12 ++------
 .../media/repository/PhotoViewerRepository.kt |  1 +
 .../ui/photoviewer/PhotoViewerTestTags.kt     |  1 +
 .../component/PhotoViewerMetadataSheet.kt     |  7 +++--
 .../component/PhotoViewerParticipantTitle.kt  | 24 +++++++++++++++
 .../component/PhotoViewerTopBar.kt            |  5 +---
 .../preview/PhotoViewerPreviewData.kt         |  1 +
 10 files changed, 68 insertions(+), 16 deletions(-)
 create mode 100644 src/com/android/messaging/ui/photoviewer/component/PhotoViewerParticipantTitle.kt

diff --git a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
index db4f0ac5c..538ae856f 100644
--- a/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
+++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt
@@ -31,6 +31,7 @@ import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_CLOSE_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SENDER_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_PAGER_TEST_TAG
@@ -76,6 +77,31 @@ internal class PhotoViewerScreenContentTest {
             .assertIsDisplayed()
     }
 
+    @Test
+    fun participantTitle_whenCurrentItemIsIncoming_showsSenderInTopBarAndDetails() {
+        setScreenContent()
+
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_TITLE_TEST_TAG)
+            .assertTextContains(value = FIRST_SENDER)
+        openMetadataSheet()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_SENDER_TEST_TAG)
+            .assertTextContains(value = FIRST_SENDER)
+    }
+
+    @Test
+    fun participantTitle_whenCurrentItemIsOutgoing_showsSelfInTopBarAndDetails() {
+        setScreenContent(
+            uiState = loadedPhotoViewerUiState(firstItemIsIncoming = false),
+        )
+
+        val selfTitle = string(resId = R.string.unknown_self_participant)
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_TITLE_TEST_TAG)
+            .assertTextContains(value = selfTitle)
+        openMetadataSheet()
+        composeRule.onNodeWithTag(testTag = PHOTO_VIEWER_METADATA_SENDER_TEST_TAG)
+            .assertTextContains(value = selfTitle)
+    }
+
     @Test
     fun pageIndicator_whenMultipleItemsInCarousel_isDisplayed() {
         setScreenContent()
@@ -556,6 +582,7 @@ internal class PhotoViewerScreenContentTest {
 
     private fun loadedPhotoViewerUiState(
         firstItemCanUseActions: Boolean = true,
+        firstItemIsIncoming: Boolean = true,
         firstItemIsDraft: Boolean = false,
         itemCount: Int = 2,
         displayMode: PhotoViewerDisplayMode = PhotoViewerDisplayMode.Carousel,
@@ -565,6 +592,7 @@ internal class PhotoViewerScreenContentTest {
             index = 1,
             senderName = FIRST_SENDER,
             canUseActions = firstItemCanUseActions,
+            isIncoming = firstItemIsIncoming,
             isDraft = firstItemIsDraft,
         )
         val items = when (itemCount) {
@@ -587,11 +615,13 @@ internal class PhotoViewerScreenContentTest {
         index: Int,
         senderName: String,
         canUseActions: Boolean = true,
+        isIncoming: Boolean = true,
         isDraft: Boolean = false,
     ): PhotoViewerItem {
         return PhotoViewerItem(
             contentUri = photoViewerImageUri(),
             contentType = IMAGE_JPEG,
+            isIncoming = isIncoming,
             senderName = senderName,
             senderDestination = "+1555000$index",
             receivedTimestampMillis = 1_735_689_600_000L + index,
diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
index ac4315f69..613bb0c95 100644
--- a/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt
@@ -76,10 +76,12 @@ internal class PhotoViewerRepositoryImplTest {
 
             assertEquals(1, result.initialIndex)
             assertEquals(2, result.items.size)
+            assertEquals(true, result.items[0].isIncoming)
             assertEquals(
                 PhotoViewerItem(
                     contentUri = Uri.parse("content://example/content/2?updated=true"),
                     contentType = IMAGE_JPEG,
+                    isIncoming = false,
                     senderName = null,
                     senderDestination = "+15550002",
                     receivedTimestampMillis = 2000L,
diff --git a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
index 4f3aacf65..e4949d892 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt
@@ -556,6 +556,7 @@ internal class PhotoViewerViewModelTest {
             return PhotoViewerItem(
                 contentUri = Uri.parse("content://example/content/$index"),
                 contentType = IMAGE_JPEG,
+                isIncoming = true,
                 senderName = "Ada Lovelace",
                 senderDestination = "+1555123000$index",
                 receivedTimestampMillis = 1_735_689_600_000L + index,
diff --git a/src/com/android/messaging/data/media/model/PhotoViewerItem.kt b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
index 7068ad1fe..8224652d9 100644
--- a/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
+++ b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
@@ -7,18 +7,10 @@ import androidx.compose.runtime.Immutable
 internal data class PhotoViewerItem(
     val contentUri: Uri,
     val contentType: String,
+    val isIncoming: Boolean,
     val senderName: String?,
     val senderDestination: String?,
     val receivedTimestampMillis: Long,
     val isDraft: Boolean,
     val canUseActions: Boolean = true,
-) {
-    val title: String
-        get() {
-            return senderName
-                ?.takeIf { it.isNotBlank() }
-                ?: senderDestination
-                    ?.takeIf { it.isNotBlank() }
-                    .orEmpty()
-        }
-}
+)
diff --git a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
index 89af74736..3f6ec0bf9 100644
--- a/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
+++ b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
@@ -162,6 +162,7 @@ internal class PhotoViewerRepositoryImpl @Inject constructor(
                 PhotoViewerItem(
                     contentUri = contentUri,
                     contentType = contentType,
+                    isIncoming = MessageData.getIsIncoming(status),
                     senderName = getStringOrNull(
                         ConversationImagePartsView
                             .PhotoViewQuery
diff --git a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
index a15ef5cf4..ce6e13dd1 100644
--- a/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
@@ -5,6 +5,7 @@ internal const val PHOTO_VIEWER_DETAILS_MENU_ITEM_TEST_TAG = "photo_viewer_detai
 internal const val PHOTO_VIEWER_FORWARD_MENU_ITEM_TEST_TAG = "photo_viewer_forward_menu_item"
 internal const val PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG =
     "photo_viewer_metadata_received_timestamp"
+internal const val PHOTO_VIEWER_METADATA_SENDER_TEST_TAG = "photo_viewer_metadata_sender"
 internal const val PHOTO_VIEWER_METADATA_SHEET_TEST_TAG = "photo_viewer_metadata_sheet"
 internal const val PHOTO_VIEWER_OVERFLOW_BUTTON_TEST_TAG = "photo_viewer_overflow_button"
 internal const val PHOTO_VIEWER_PAGER_TEST_TAG = "photo_viewer_pager"
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
index 8892aba4c..7c4fc296b 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
@@ -34,6 +34,7 @@ import com.android.messaging.R
 import com.android.messaging.data.media.model.PhotoViewerItem
 import com.android.messaging.ui.core.MessagingPreviewTheme
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_RECEIVED_TIMESTAMP_TEST_TAG
+import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SENDER_TEST_TAG
 import com.android.messaging.ui.photoviewer.PHOTO_VIEWER_METADATA_SHEET_TEST_TAG
 import com.android.messaging.ui.photoviewer.preview.previewPhotoViewerItems
 import com.android.messaging.util.Dates
@@ -120,8 +121,8 @@ private fun PhotoViewerMetadataGroup(
     ) {
         PhotoViewerMetadataRow(
             label = stringResource(id = R.string.message_details_from_label),
-            value = item.title.takeIf { it.isNotBlank() }
-                ?: stringResource(id = R.string.unknown_sender),
+            value = photoViewerParticipantTitle(item = item),
+            valueModifier = Modifier.testTag(tag = PHOTO_VIEWER_METADATA_SENDER_TEST_TAG),
         )
         if (!item.isDraft) {
             PhotoViewerMetadataRow(
@@ -188,6 +189,7 @@ private fun PhotoViewerMetadataRow(
     modifier: Modifier = Modifier,
     label: String,
     value: String,
+    valueModifier: Modifier = Modifier,
 ) {
     Column(
         modifier = modifier.fillMaxWidth(),
@@ -199,6 +201,7 @@ private fun PhotoViewerMetadataRow(
             color = MaterialTheme.colorScheme.onSurfaceVariant,
         )
         Text(
+            modifier = valueModifier,
             text = value,
             style = MaterialTheme.typography.bodyLarge,
             color = MaterialTheme.colorScheme.onSurface,
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerParticipantTitle.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerParticipantTitle.kt
new file mode 100644
index 000000000..72fc12679
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerParticipantTitle.kt
@@ -0,0 +1,24 @@
+package com.android.messaging.ui.photoviewer.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.android.messaging.R
+import com.android.messaging.data.media.model.PhotoViewerItem
+
+@Composable
+internal fun photoViewerParticipantTitle(
+    item: PhotoViewerItem?,
+): String {
+    val title = when {
+        item == null -> null
+        item.isIncoming -> {
+            item.senderName
+                ?.takeIf { it.isNotBlank() }
+                ?: item.senderDestination?.takeIf { it.isNotBlank() }
+        }
+
+        else -> stringResource(id = R.string.unknown_self_participant)
+    }
+
+    return title ?: stringResource(id = R.string.unknown_sender)
+}
diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
index 31b8fc44f..45d9a291e 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
@@ -173,10 +173,7 @@ private fun PhotoViewerTitleBlock(
     ) {
         Text(
             modifier = Modifier.testTag(tag = PHOTO_VIEWER_TITLE_TEST_TAG),
-            text = item
-                ?.title
-                ?.takeIf { it.isNotBlank() }
-                ?: stringResource(id = R.string.unknown_sender),
+            text = photoViewerParticipantTitle(item = item),
             style = MaterialTheme.typography.titleSmall,
             color = contentColor,
             maxLines = 1,
diff --git a/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt b/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt
index 99286848c..5d8205f04 100644
--- a/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt
+++ b/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt
@@ -17,6 +17,7 @@ internal fun previewPhotoViewerItem(index: Int): PhotoViewerItem {
     return PhotoViewerItem(
         contentUri = "content://example/content/$index".toUri(),
         contentType = "image/jpeg",
+        isIncoming = true,
         senderName = "Ada Lovelace",
         senderDestination = "+1555123000$index",
         receivedTimestampMillis = 1_735_689_600_000L,

From f9d8dc2bbe349166baea35e04168e8ee18d4c5e4 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 21:58:13 +0300
Subject: [PATCH 13/19] Abort photo sharing when URI preparation fails

---
 .../PreparePhotoViewerSendUriImplTest.kt        |  8 ++++----
 .../usecase/PreparePhotoViewerSendUri.kt        | 17 +++++++++--------
 2 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
index 8ee310531..816888d28 100644
--- a/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt
@@ -8,12 +8,12 @@ import io.mockk.unmockkAll
 import io.mockk.verify
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.single
-import kotlinx.coroutines.flow.singleOrNull
+import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -69,9 +69,9 @@ internal class PreparePhotoViewerSendUriImplTest {
             val fileUri = Uri.parse("file:///sdcard/Pictures/photo.jpg")
             every { UriUtil.persistContentToScratchSpace(fileUri) } returns null
 
-            val result = useCase(uri = fileUri).singleOrNull()
+            val result = useCase(uri = fileUri).toList()
 
-            assertNull(result)
+            assertTrue(result.isEmpty())
         }
     }
 }
diff --git a/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
index ed5b3b3cb..7e0ab768c 100644
--- a/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
+++ b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
@@ -5,10 +5,10 @@ import android.net.Uri
 import com.android.messaging.di.core.IoDispatcher
 import com.android.messaging.util.LogUtil
 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.flow
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flowOn
 
@@ -29,14 +29,15 @@ internal class PreparePhotoViewerSendUriImpl @Inject constructor(
     }
 
     private fun copyFileUriToScratchSpace(uri: Uri): Flow {
-        return typedFlow {
-            UriUtil
-                .persistContentToScratchSpace(uri)
-                .also { persistedUri ->
-                    if (persistedUri != null) {
-                        LogUtil.w(TAG, "Failed to copy photo viewer file URI to scratch space")
-                    }
+        return flow {
+            when (val persistedUri: Uri? = UriUtil.persistContentToScratchSpace(uri)) {
+                null -> {
+                    LogUtil.w(TAG, "Failed to copy photo viewer file URI to scratch space")
                 }
+                else -> {
+                    emit(persistedUri)
+                }
+            }
         }.flowOn(context = ioDispatcher)
     }
 

From f1d37dd7863b16b948bc5f9cefcd5787bc268fe8 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 22:20:03 +0300
Subject: [PATCH 14/19] Fix padding args

---
 .../messaging/ui/photoviewer/component/PhotoViewerTopBar.kt     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
index 45d9a291e..fe2899c19 100644
--- a/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
@@ -114,7 +114,7 @@ private fun PhotoViewerTopBarContent(
             .windowInsetsPadding(
                 insets = navigationBarInsets.only(sides = WindowInsetsSides.Horizontal),
             )
-            .padding(horizontal = 8.dp, vertical = 8.dp),
+            .padding(all = 8.dp),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(space = 2.dp),
     ) {

From d7eb3a71388681e1a52156cdef9db9f70a417a66 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 23:04:31 +0300
Subject: [PATCH 15/19] Add tests for share intent

---
 .../host/share/ShareIntentActivityTest.kt     | 41 ++++++++
 .../host/share/ShareIntentActivityUiTest.kt   | 99 +++++++++++++++++++
 2 files changed, 140 insertions(+)
 create mode 100644 app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityTest.kt
 create mode 100644 app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityUiTest.kt

diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityTest.kt
new file mode 100644
index 000000000..d7dca0c8a
--- /dev/null
+++ b/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityTest.kt
@@ -0,0 +1,41 @@
+package com.android.messaging.ui.conversationpicker.host.share
+
+import android.content.Intent
+import android.net.Uri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+internal class ShareIntentActivityTest {
+
+    private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+    @Test
+    fun createForwardIntent_preservesAttachmentAndGrantsReadPermission() {
+        val intent = ShareIntentActivity.createForwardIntent(
+            context = context,
+            uri = FORWARD_URI,
+            contentType = IMAGE_JPEG,
+        )
+
+        assertEquals(ShareIntentActivity::class.java.name, intent.component?.className)
+        assertEquals(Intent.ACTION_SEND, intent.action)
+        assertEquals(IMAGE_JPEG, intent.type)
+        assertEquals(
+            FORWARD_URI,
+            intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java),
+        )
+        assertTrue(
+            intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0,
+        )
+    }
+
+    private companion object {
+        private const val IMAGE_JPEG = "image/jpeg"
+        private val FORWARD_URI = Uri.parse("content://example.test/photo.jpg")
+    }
+}
diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityUiTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityUiTest.kt
new file mode 100644
index 000000000..70aff3062
--- /dev/null
+++ b/app/src/androidTest/java/com/android/messaging/ui/conversationpicker/host/share/ShareIntentActivityUiTest.kt
@@ -0,0 +1,99 @@
+package com.android.messaging.ui.conversationpicker.host.share
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.common.test.rules.AppTestRule
+import com.android.common.test.rules.MessagingTestRule
+import com.android.messaging.R
+import com.android.messaging.datamodel.MediaScratchFileProvider
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+internal class ShareIntentActivityUiTest {
+
+    @get:Rule
+    val appRule = AppTestRule()
+
+    @get:Rule
+    val messagingRule = MessagingTestRule()
+
+    @get:Rule
+    val composeRule = createEmptyComposeRule()
+
+    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+
+    @OptIn(ExperimentalTestApi::class)
+    @Test
+    fun forwardIntent_displaysForwardMessageTitle() {
+        val scenario = ActivityScenario.launch(
+            ShareIntentActivity.createForwardIntent(
+                context = context,
+                uri = seededImageUri(),
+                contentType = IMAGE_JPEG,
+            ),
+        )
+
+        scenario.use {
+            val title = context.getString(R.string.forward_message_activity_title)
+
+            composeRule.waitUntilAtLeastOneExists(
+                matcher = hasText(text = title),
+                timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS,
+            )
+            composeRule.onNodeWithText(text = title).assertIsDisplayed()
+        }
+    }
+
+    @OptIn(ExperimentalTestApi::class)
+    @Test
+    fun shareIntent_displaysShareTitle() {
+        val scenario = ActivityScenario.launch(
+            Intent(context, ShareIntentActivity::class.java).apply {
+                action = Intent.ACTION_SEND
+                type = IMAGE_JPEG
+                putExtra(Intent.EXTRA_STREAM, seededImageUri())
+                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            },
+        )
+
+        scenario.use {
+            val title = context.getString(R.string.share_intent_activity_label)
+
+            composeRule.waitUntilAtLeastOneExists(
+                matcher = hasText(text = title),
+                timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS,
+            )
+            composeRule.onNodeWithText(text = title).assertIsDisplayed()
+        }
+    }
+
+    private fun seededImageUri(): Uri {
+        return MediaScratchFileProvider
+            .getUriBuilder()
+            .appendPath(SEED_IMAGE_FILE_ID)
+            .appendQueryParameter(
+                SEED_IMAGE_FILE_EXTENSION_QUERY_PARAMETER,
+                SEED_IMAGE_FILE_EXTENSION,
+            )
+            .build()
+    }
+
+    private companion object {
+        private const val IMAGE_JPEG = "image/jpeg"
+        private const val SEED_IMAGE_FILE_EXTENSION = "jpg"
+        private const val SEED_IMAGE_FILE_EXTENSION_QUERY_PARAMETER = "ext"
+        private const val SEED_IMAGE_FILE_ID = "800001"
+        private const val TEST_WAIT_TIMEOUT_MILLIS = 5_000L
+    }
+}

From 19494d8ee47b80f67ed12d752335f0dd3b863ce3 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Thu, 9 Jul 2026 23:05:11 +0300
Subject: [PATCH 16/19] Remove redundant suspends

---
 .../ui/photoviewer/screen/PhotoViewerScreenEffects.kt       | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
index 683ea2a2f..989c8b85c 100644
--- a/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
@@ -37,7 +37,7 @@ internal fun PhotoViewerScreenEffects(
     }
 }
 
-private suspend fun handlePhotoViewerEffect(
+private fun handlePhotoViewerEffect(
     context: Context,
     effect: PhotoViewerEffect,
     onFinish: () -> Unit,
@@ -71,7 +71,7 @@ private suspend fun handlePhotoViewerEffect(
     }
 }
 
-private suspend fun sharePhoto(
+private fun sharePhoto(
     context: Context,
     uri: Uri,
     contentType: String,
@@ -96,7 +96,7 @@ private suspend fun sharePhoto(
     }
 }
 
-private suspend fun forwardPhoto(
+private fun forwardPhoto(
     context: Context,
     uri: Uri,
     contentType: String,

From ccd411eae62d81341ed87d2dc3f0a3ae05276367 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Sun, 12 Jul 2026 22:00:55 +0300
Subject: [PATCH 17/19] Improve photo viewer background transitions

---
 .../MediaPreviewBackgroundContentTest.kt      | 682 ++++++++++++++++++
 .../MediaPreviewBackgroundTest.kt             | 411 +++++++++++
 .../MediaPreviewBitmapPrefetcherTest.kt       | 220 ++++++
 .../mediapreview/MediaPreviewBackground.kt    | 453 ++++++++----
 .../MediaPreviewBackgroundState.kt            | 323 +++++++++
 .../mediapreview/MediaPreviewBitmapCache.kt   | 164 ++++-
 6 files changed, 2121 insertions(+), 132 deletions(-)
 create mode 100644 app/src/androidTest/java/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundContentTest.kt
 create mode 100644 app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
 create mode 100644 app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapPrefetcherTest.kt
 create mode 100644 src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundState.kt

diff --git a/app/src/androidTest/java/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundContentTest.kt b/app/src/androidTest/java/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundContentTest.kt
new file mode 100644
index 000000000..bbb3e4eb8
--- /dev/null
+++ b/app/src/androidTest/java/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundContentTest.kt
@@ -0,0 +1,682 @@
+package com.android.messaging.ui.common.components.mediapreview
+
+import android.graphics.Bitmap
+import android.graphics.Color as AndroidColor
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.toPixelMap
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import com.android.messaging.ui.core.MessagingPreviewTheme
+import io.mockk.coEvery
+import io.mockk.mockk
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.math.abs
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.channels.Channel
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+
+internal class MediaPreviewBackgroundContentTest {
+
+    @get:Rule
+    val composeRule = createComposeRule()
+
+    private val bitmapLoader = mockk Bitmap?>()
+    private val deferredBitmapsByContentUri =
+        ConcurrentHashMap>()
+    private val returnedContentUris = ConcurrentHashMap.newKeySet()
+
+    init {
+        coEvery { bitmapLoader.invoke(any()) } coAnswers {
+            val item = firstArg()
+            deferredBitmap(contentUri = item.contentUri)
+                .await()
+                .also { returnedContentUris += item.contentUri }
+        }
+    }
+
+    @Test
+    fun lateAndRapidBitmapChanges_holdThenFadeWithoutFallbackFlash() {
+        val pagerScrollController = ControlledPagerScrollController()
+        val items = persistentListOf(
+            mediaPreviewItem(contentUri = "first"),
+            mediaPreviewItem(contentUri = "second"),
+            mediaPreviewItem(contentUri = "third"),
+        )
+        lateinit var pagerState: PagerState
+        composeRule.mainClock.autoAdvance = false
+
+        try {
+            composeRule.setContent {
+                MessagingPreviewTheme {
+                    val currentPagerState = rememberPagerState(pageCount = { items.size })
+                    SideEffect {
+                        pagerState = currentPagerState
+                    }
+                    LaunchedEffect(currentPagerState, pagerScrollController) {
+                        pagerScrollController.run(pagerState = currentPagerState)
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .size(size = 100.dp)
+                            .testTag(tag = BACKGROUND_TEST_TAG),
+                    ) {
+                        MediaPreviewBackground(
+                            modifier = Modifier.fillMaxSize(),
+                            items = items,
+                            pagerState = currentPagerState,
+                            bitmapLoader = bitmapLoader,
+                        )
+                        HorizontalPager(
+                            modifier = Modifier.fillMaxSize(),
+                            state = currentPagerState,
+                        ) { _ ->
+                            Box(modifier = Modifier.fillMaxSize())
+                        }
+                    }
+                }
+            }
+
+            val fallbackColor = captureBackgroundColor()
+            completeBitmapLoader(
+                contentUri = "first",
+                color = AndroidColor.RED,
+            )
+            waitForBitmapLoader(contentUri = "first")
+            composeRule.waitForIdle()
+            assertBackgroundColor(expectedColor = fallbackColor)
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            val imageMidpointColor = Color.Red
+                .copy(alpha = 0.5f)
+                .compositeOver(background = fallbackColor)
+            val expectedInitialMidpointColor = Color.Black
+                .copy(alpha = 0.25f)
+                .compositeOver(background = imageMidpointColor)
+            assertBackgroundColor(expectedColor = expectedInitialMidpointColor)
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertBackgroundColor(expectedColor = Color(red = 0.5f, green = 0f, blue = 0f))
+
+            val firstScrollSession = pagerScrollController.beginScroll()
+            waitForScrollSession(firstScrollSession)
+            setPagerPosition(
+                session = firstScrollSession,
+                page = 0,
+                pageOffsetFraction = 0.25f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.5f, green = 0f, blue = 0f))
+
+            completeBitmapLoader(
+                contentUri = "second",
+                color = AndroidColor.BLUE,
+            )
+            waitForBitmapLoader(contentUri = "second")
+            composeRule.waitForIdle()
+            assertBackgroundColor(expectedColor = Color(red = 0.5f, green = 0f, blue = 0f))
+
+            setPagerPosition(
+                session = firstScrollSession,
+                page = 1,
+                pageOffsetFraction = 0f,
+            )
+            firstScrollSession.finish()
+            waitForScrollToFinish(pagerState = pagerState)
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertBackgroundColor(expectedColor = Color(red = 0.25f, green = 0f, blue = 0.25f))
+
+            val secondScrollSession = pagerScrollController.beginScroll()
+            waitForScrollSession(secondScrollSession)
+            setPagerPosition(
+                session = secondScrollSession,
+                page = 2,
+                pageOffsetFraction = 0f,
+            )
+            completeBitmapLoader(
+                contentUri = "third",
+                color = AndroidColor.GREEN,
+            )
+            waitForBitmapLoader(contentUri = "third")
+            secondScrollSession.finish()
+            waitForScrollToFinish(pagerState = pagerState)
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertConstantLuminancePrimaryBlend()
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS * 2)
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0.5f, blue = 0f))
+        } finally {
+            composeRule.mainClock.autoAdvance = true
+        }
+    }
+
+    @Test
+    fun interactiveBlend_tracksOffsetsAcrossCurrentPageFlip() {
+        val pagerScrollController = ControlledPagerScrollController()
+        val items = persistentListOf(
+            mediaPreviewItem(contentUri = "first"),
+            mediaPreviewItem(contentUri = "second"),
+        )
+        lateinit var pagerState: PagerState
+        composeRule.mainClock.autoAdvance = false
+
+        try {
+            composeRule.setContent {
+                MessagingPreviewTheme {
+                    val currentPagerState = rememberPagerState(pageCount = { items.size })
+                    SideEffect {
+                        pagerState = currentPagerState
+                    }
+                    LaunchedEffect(currentPagerState, pagerScrollController) {
+                        pagerScrollController.run(pagerState = currentPagerState)
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .size(size = 100.dp)
+                            .testTag(tag = BACKGROUND_TEST_TAG),
+                    ) {
+                        MediaPreviewBackground(
+                            modifier = Modifier.fillMaxSize(),
+                            items = items,
+                            pagerState = currentPagerState,
+                            bitmapLoader = bitmapLoader,
+                        )
+                        HorizontalPager(
+                            modifier = Modifier.fillMaxSize(),
+                            state = currentPagerState,
+                        ) { _ ->
+                            Box(modifier = Modifier.fillMaxSize())
+                        }
+                    }
+                }
+            }
+
+            completeBitmapLoader(contentUri = "first", color = AndroidColor.RED)
+            completeBitmapLoader(contentUri = "second", color = AndroidColor.BLUE)
+            waitForBitmapLoader(contentUri = "first")
+            waitForBitmapLoader(contentUri = "second")
+            composeRule.waitForIdle()
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS * 2)
+            composeRule.waitForIdle()
+
+            val scrollSession = pagerScrollController.beginScroll()
+            waitForScrollSession(scrollSession)
+            setPagerPosition(
+                session = scrollSession,
+                page = 0,
+                pageOffsetFraction = 0.49f,
+            )
+            assertPagerPosition(
+                pagerState = pagerState,
+                expectedPage = 0,
+                expectedPageOffsetFraction = 0.49f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.255f, green = 0f, blue = 0.245f))
+
+            setPagerPosition(
+                session = scrollSession,
+                page = 1,
+                pageOffsetFraction = -0.49f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.245f, green = 0f, blue = 0.255f))
+
+            setPagerPosition(
+                session = scrollSession,
+                page = 1,
+                pageOffsetFraction = -0.25f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.125f, green = 0f, blue = 0.375f))
+
+            setPagerPosition(
+                session = scrollSession,
+                page = 0,
+                pageOffsetFraction = 0.25f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.375f, green = 0f, blue = 0.125f))
+
+            scrollSession.finish()
+        } finally {
+            composeRule.mainClock.autoAdvance = true
+        }
+    }
+
+    @Test
+    fun readyScrollSettlesWithoutReturningToPreviousFrame() {
+        val pagerScrollController = ControlledPagerScrollController()
+        val items = persistentListOf(
+            mediaPreviewItem(contentUri = "first"),
+            mediaPreviewItem(contentUri = "second"),
+        )
+        lateinit var pagerState: PagerState
+        composeRule.mainClock.autoAdvance = false
+
+        try {
+            composeRule.setContent {
+                MessagingPreviewTheme {
+                    val currentPagerState = rememberPagerState(pageCount = { items.size })
+                    SideEffect {
+                        pagerState = currentPagerState
+                    }
+                    LaunchedEffect(currentPagerState, pagerScrollController) {
+                        pagerScrollController.run(pagerState = currentPagerState)
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .size(size = 100.dp)
+                            .testTag(tag = BACKGROUND_TEST_TAG),
+                    ) {
+                        MediaPreviewBackground(
+                            modifier = Modifier.fillMaxSize(),
+                            items = items,
+                            pagerState = currentPagerState,
+                            bitmapLoader = bitmapLoader,
+                        )
+                        HorizontalPager(
+                            modifier = Modifier.fillMaxSize(),
+                            state = currentPagerState,
+                        ) { _ ->
+                            Box(modifier = Modifier.fillMaxSize())
+                        }
+                    }
+                }
+            }
+
+            completeBitmapLoader(contentUri = "first", color = AndroidColor.RED)
+            completeBitmapLoader(contentUri = "second", color = AndroidColor.BLUE)
+            waitForBitmapLoader(contentUri = "first")
+            waitForBitmapLoader(contentUri = "second")
+            composeRule.waitForIdle()
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS * 2)
+
+            val scrollSession = pagerScrollController.beginScroll()
+            waitForScrollSession(scrollSession)
+            setPagerPosition(
+                session = scrollSession,
+                page = 0,
+                pageOffsetFraction = 0.49f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.255f, green = 0f, blue = 0.245f))
+
+            setPagerPositionAndFinish(
+                session = scrollSession,
+                pagerState = pagerState,
+                page = 1,
+                pageOffsetFraction = 0f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+        } finally {
+            composeRule.mainClock.autoAdvance = true
+        }
+    }
+
+    @Test
+    fun middlePageBoundary_doesNotRequireInvisibleFollowingFrame() {
+        val pagerScrollController = ControlledPagerScrollController()
+        val items = persistentListOf(
+            mediaPreviewItem(contentUri = "first"),
+            mediaPreviewItem(contentUri = "second"),
+            mediaPreviewItem(contentUri = "third"),
+        )
+        lateinit var pagerState: PagerState
+        composeRule.mainClock.autoAdvance = false
+
+        try {
+            composeRule.setContent {
+                MessagingPreviewTheme {
+                    val currentPagerState = rememberPagerState(pageCount = { items.size })
+                    SideEffect {
+                        pagerState = currentPagerState
+                    }
+                    LaunchedEffect(currentPagerState, pagerScrollController) {
+                        pagerScrollController.run(pagerState = currentPagerState)
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .size(size = 100.dp)
+                            .testTag(tag = BACKGROUND_TEST_TAG),
+                    ) {
+                        MediaPreviewBackground(
+                            modifier = Modifier.fillMaxSize(),
+                            items = items,
+                            pagerState = currentPagerState,
+                            bitmapLoader = bitmapLoader,
+                        )
+                        HorizontalPager(
+                            modifier = Modifier.fillMaxSize(),
+                            state = currentPagerState,
+                        ) { _ ->
+                            Box(modifier = Modifier.fillMaxSize())
+                        }
+                    }
+                }
+            }
+
+            completeBitmapLoader(contentUri = "first", color = AndroidColor.RED)
+            completeBitmapLoader(contentUri = "second", color = AndroidColor.BLUE)
+            waitForBitmapLoader(contentUri = "first")
+            waitForBitmapLoader(contentUri = "second")
+            composeRule.waitForIdle()
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS * 2)
+
+            val scrollSession = pagerScrollController.beginScroll()
+            waitForScrollSession(scrollSession)
+            setPagerPosition(
+                session = scrollSession,
+                page = 0,
+                pageOffsetFraction = 0.49f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0.255f, green = 0f, blue = 0.245f))
+
+            setPagerPosition(
+                session = scrollSession,
+                page = 1,
+                pageOffsetFraction = 0f,
+            )
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+
+            scrollSession.finish()
+            waitForScrollToFinish(pagerState = pagerState)
+            composeRule.mainClock.advanceTimeByFrame()
+            composeRule.waitForIdle()
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+        } finally {
+            composeRule.mainClock.autoAdvance = true
+        }
+    }
+
+    @Test
+    fun emptyDuringRecovery_refillRestartsInitialFade() {
+        var items by mutableStateOf>(
+            value = persistentListOf(mediaPreviewItem(contentUri = "first")),
+        )
+        composeRule.mainClock.autoAdvance = false
+
+        try {
+            composeRule.setContent {
+                MessagingPreviewTheme {
+                    val pagerState = rememberPagerState(pageCount = { items.size })
+
+                    Box(
+                        modifier = Modifier
+                            .size(size = 100.dp)
+                            .testTag(tag = BACKGROUND_TEST_TAG),
+                    ) {
+                        MediaPreviewBackground(
+                            modifier = Modifier.fillMaxSize(),
+                            items = items,
+                            pagerState = pagerState,
+                            bitmapLoader = bitmapLoader,
+                        )
+                    }
+                }
+            }
+
+            val fallbackColor = captureBackgroundColor()
+            completeBitmapLoader(contentUri = "first", color = AndroidColor.RED)
+            waitForBitmapLoader(contentUri = "first")
+            composeRule.waitForIdle()
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+
+            composeRule.runOnIdle {
+                items = persistentListOf()
+            }
+            composeRule.mainClock.advanceTimeByFrame()
+            composeRule.waitForIdle()
+            assertBackgroundColor(expectedColor = fallbackColor)
+
+            composeRule.runOnIdle {
+                items = persistentListOf(mediaPreviewItem(contentUri = "second"))
+            }
+            composeRule.mainClock.advanceTimeByFrame()
+            completeBitmapLoader(contentUri = "second", color = AndroidColor.BLUE)
+            waitForBitmapLoader(contentUri = "second")
+            composeRule.waitForIdle()
+            assertBackgroundColor(expectedColor = fallbackColor)
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            val imageMidpointColor = Color.Blue
+                .copy(alpha = 0.5f)
+                .compositeOver(background = fallbackColor)
+            val expectedInitialMidpointColor = Color.Black
+                .copy(alpha = 0.25f)
+                .compositeOver(background = imageMidpointColor)
+            assertBackgroundColor(expectedColor = expectedInitialMidpointColor)
+
+            composeRule.mainClock.advanceTimeBy(milliseconds = RECOVERY_ANIMATION_MILLIS / 2)
+            assertBackgroundColor(expectedColor = Color(red = 0f, green = 0f, blue = 0.5f))
+        } finally {
+            composeRule.mainClock.autoAdvance = true
+        }
+    }
+
+    private fun waitForScrollSession(session: ControlledPagerScrollSession) {
+        composeRule.waitUntil {
+            session.isStarted
+        }
+    }
+
+    private fun waitForBitmapLoader(contentUri: String) {
+        composeRule.waitUntil {
+            contentUri in returnedContentUris
+        }
+    }
+
+    private fun completeBitmapLoader(contentUri: String, color: Int) {
+        deferredBitmap(contentUri = contentUri).complete(solidBitmap(color = color))
+    }
+
+    private fun deferredBitmap(contentUri: String): CompletableDeferred {
+        return deferredBitmapsByContentUri.computeIfAbsent(contentUri) {
+            CompletableDeferred()
+        }
+    }
+
+    private fun solidBitmap(color: Int): Bitmap {
+        return Bitmap.createBitmap(
+            intArrayOf(color),
+            1,
+            1,
+            Bitmap.Config.ARGB_8888,
+        )
+    }
+
+    private fun setPagerPosition(
+        session: ControlledPagerScrollSession,
+        page: Int,
+        pageOffsetFraction: Float,
+    ) {
+        val positionUpdate = session.setPosition(
+            page = page,
+            pageOffsetFraction = pageOffsetFraction,
+        )
+        composeRule.waitUntil {
+            positionUpdate.isCompleted
+        }
+        composeRule.mainClock.advanceTimeByFrame()
+        composeRule.waitForIdle()
+    }
+
+    private fun setPagerPositionAndFinish(
+        session: ControlledPagerScrollSession,
+        pagerState: PagerState,
+        page: Int,
+        pageOffsetFraction: Float,
+    ) {
+        val positionUpdate = session.setPosition(
+            page = page,
+            pageOffsetFraction = pageOffsetFraction,
+        )
+        session.finish()
+        composeRule.waitUntil {
+            positionUpdate.isCompleted && !pagerState.isScrollInProgress
+        }
+        composeRule.mainClock.advanceTimeByFrame()
+        composeRule.waitForIdle()
+    }
+
+    private fun waitForScrollToFinish(pagerState: PagerState) {
+        composeRule.waitUntil {
+            !pagerState.isScrollInProgress
+        }
+    }
+
+    private fun assertPagerPosition(
+        pagerState: PagerState,
+        expectedPage: Int,
+        expectedPageOffsetFraction: Float,
+    ) {
+        composeRule.runOnIdle {
+            assertTrue(pagerState.isScrollInProgress)
+            assertTrue(pagerState.currentPage == expectedPage)
+            assertTrue(
+                abs(pagerState.currentPageOffsetFraction - expectedPageOffsetFraction) <=
+                    FLOAT_POSITION_TOLERANCE,
+            )
+        }
+    }
+
+    private fun assertBackgroundColor(expectedColor: Color) {
+        val centerColor = captureBackgroundColor()
+        val redMatches = abs(centerColor.red - expectedColor.red) <=
+            COLOR_COMPONENT_TOLERANCE
+        val greenMatches = abs(centerColor.green - expectedColor.green) <=
+            COLOR_COMPONENT_TOLERANCE
+        val blueMatches = abs(centerColor.blue - expectedColor.blue) <=
+            COLOR_COMPONENT_TOLERANCE
+
+        assertTrue(
+            "Expected $expectedColor but rendered $centerColor",
+            redMatches && greenMatches && blueMatches,
+        )
+    }
+
+    private fun captureBackgroundColor(): Color {
+        val image = composeRule
+            .onNodeWithTag(testTag = BACKGROUND_TEST_TAG)
+            .captureToImage()
+        return image.toPixelMap()[image.width / 2, image.height / 2]
+    }
+
+    private fun assertConstantLuminancePrimaryBlend() {
+        val image = composeRule
+            .onNodeWithTag(testTag = BACKGROUND_TEST_TAG)
+            .captureToImage()
+        val centerColor = image.toPixelMap()[image.width / 2, image.height / 2]
+        val totalPrimaryIntensity = centerColor.red + centerColor.green + centerColor.blue
+
+        assertTrue(
+            "Expected a constant-luminance primary-color blend but rendered $centerColor",
+            abs(totalPrimaryIntensity - 0.5f) <= COLOR_COMPONENT_TOLERANCE,
+        )
+    }
+
+    private fun mediaPreviewItem(contentUri: String): MediaPreviewItem {
+        return MediaPreviewItem(
+            contentUri = contentUri,
+            contentType = "image/jpeg",
+            isVideo = false,
+        )
+    }
+
+    private companion object {
+        private const val BACKGROUND_TEST_TAG = "media-preview-background"
+        private const val COLOR_COMPONENT_TOLERANCE = 0.08f
+        private const val FLOAT_POSITION_TOLERANCE = 0.001f
+        private const val RECOVERY_ANIMATION_MILLIS = 180L
+    }
+}
+
+private class ControlledPagerScrollController {
+    private val sessions = Channel(capacity = Channel.UNLIMITED)
+
+    suspend fun run(pagerState: PagerState) {
+        for (session in sessions) {
+            pagerState.scroll {
+                session.markStarted()
+                for (positionUpdate in session.positionUpdates) {
+                    with(pagerState) {
+                        updateCurrentPage(
+                            page = positionUpdate.page,
+                            pageOffsetFraction = positionUpdate.pageOffsetFraction,
+                        )
+                    }
+                    positionUpdate.markCompleted()
+                }
+            }
+        }
+    }
+
+    fun beginScroll(): ControlledPagerScrollSession {
+        return ControlledPagerScrollSession().also { session ->
+            check(sessions.trySend(session).isSuccess)
+        }
+    }
+}
+
+private class ControlledPagerScrollSession {
+    private val started = CompletableDeferred()
+    val positionUpdates = Channel(capacity = Channel.UNLIMITED)
+    val isStarted: Boolean
+        get() = started.isCompleted
+
+    fun markStarted() {
+        started.complete(Unit)
+    }
+
+    fun setPosition(
+        page: Int,
+        pageOffsetFraction: Float,
+    ): ControlledPagerPositionUpdate {
+        return ControlledPagerPositionUpdate(
+            page = page,
+            pageOffsetFraction = pageOffsetFraction,
+        ).also { positionUpdate ->
+            check(positionUpdates.trySend(positionUpdate).isSuccess)
+        }
+    }
+
+    fun finish() {
+        positionUpdates.close()
+    }
+}
+
+private class ControlledPagerPositionUpdate(
+    val page: Int,
+    val pageOffsetFraction: Float,
+) {
+    private val completed = CompletableDeferred()
+    val isCompleted: Boolean
+        get() = completed.isCompleted
+
+    fun markCompleted() {
+        completed.complete(Unit)
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
new file mode 100644
index 000000000..dee00649f
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
@@ -0,0 +1,411 @@
+package com.android.messaging.ui.common.components.mediapreview
+
+import androidx.compose.ui.graphics.ImageBitmap
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class MediaPreviewBackgroundTest {
+
+    @Test
+    fun blendPages_forwardCurrentPageFlip_keepsSamePairAndContinuousAlpha() {
+        val beforeFlip = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 0,
+            currentPageOffsetFraction = 0.49f,
+            pageCount = 3,
+        )
+        val afterFlip = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 1,
+            currentPageOffsetFraction = -0.49f,
+            pageCount = 3,
+        )
+
+        assertEquals(beforeFlip, afterFlip)
+        assertEquals(
+            0.49f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 0,
+                currentPageOffsetFraction = 0.49f,
+                lowerPage = beforeFlip.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+        assertEquals(
+            0.51f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 1,
+                currentPageOffsetFraction = -0.49f,
+                lowerPage = afterFlip.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+    }
+
+    @Test
+    fun blendPages_reverseCurrentPageFlip_keepsSamePairAndContinuousAlpha() {
+        val beforeFlip = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 1,
+            currentPageOffsetFraction = -0.49f,
+            pageCount = 3,
+        )
+        val afterFlip = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 0,
+            currentPageOffsetFraction = 0.49f,
+            pageCount = 3,
+        )
+
+        assertEquals(beforeFlip, afterFlip)
+        assertEquals(
+            0.51f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 1,
+                currentPageOffsetFraction = -0.49f,
+                lowerPage = beforeFlip.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+        assertEquals(
+            0.49f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 0,
+                currentPageOffsetFraction = 0.49f,
+                lowerPage = afterFlip.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+    }
+
+    @Test
+    fun blendPages_integerBoundary_switchesPairOnFullyOpaqueSharedPage() {
+        val approachingBoundary = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 1,
+            currentPageOffsetFraction = -0.001f,
+            pageCount = 3,
+        )
+        val atBoundary = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 1,
+            currentPageOffsetFraction = 0f,
+            pageCount = 3,
+        )
+
+        assertEquals(
+            MediaPreviewBackgroundBlendPages(
+                lowerPage = 0,
+                upperPage = 1,
+            ),
+            approachingBoundary,
+        )
+        assertEquals(
+            MediaPreviewBackgroundBlendPages(
+                lowerPage = 1,
+                upperPage = 2,
+            ),
+            atBoundary,
+        )
+        assertEquals(
+            0.999f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 1,
+                currentPageOffsetFraction = -0.001f,
+                lowerPage = approachingBoundary.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+        assertEquals(
+            0f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 1,
+                currentPageOffsetFraction = 0f,
+                lowerPage = atBoundary.lowerPage,
+                pageCount = 3,
+            ),
+            FLOAT_DELTA,
+        )
+    }
+
+    @Test
+    fun blendPages_singlePage_clampsPairAndAlpha() {
+        val blendPages = resolveMediaPreviewBackgroundBlendPages(
+            currentPage = 4,
+            currentPageOffsetFraction = -0.4f,
+            pageCount = 1,
+        )
+
+        assertEquals(MediaPreviewBackgroundBlendPages(lowerPage = 0, upperPage = 0), blendPages)
+        assertEquals(
+            0f,
+            resolveMediaPreviewBackgroundUpperAlpha(
+                currentPage = 4,
+                currentPageOffsetFraction = -0.4f,
+                lowerPage = blendPages.lowerPage,
+                pageCount = 1,
+            ),
+            FLOAT_DELTA,
+        )
+    }
+
+    @Test
+    fun interactivePairReady_requiresOnlyFramesWithVisibleContribution() {
+        val distinctBlendPages = MediaPreviewBackgroundBlendPages(
+            lowerPage = 1,
+            upperPage = 2,
+        )
+
+        assertTrue(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = 0f,
+                blendPages = distinctBlendPages,
+                hasLowerFrame = true,
+                hasUpperFrame = false,
+            ),
+        )
+        assertFalse(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = 0.001f,
+                blendPages = distinctBlendPages,
+                hasLowerFrame = true,
+                hasUpperFrame = false,
+            ),
+        )
+        assertFalse(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = -0.001f,
+                blendPages = distinctBlendPages,
+                hasLowerFrame = true,
+                hasUpperFrame = false,
+            ),
+        )
+        assertTrue(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = 0.25f,
+                blendPages = distinctBlendPages,
+                hasLowerFrame = true,
+                hasUpperFrame = true,
+            ),
+        )
+        assertFalse(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = 0f,
+                blendPages = distinctBlendPages,
+                hasLowerFrame = false,
+                hasUpperFrame = true,
+            ),
+        )
+        assertTrue(
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = 0.25f,
+                blendPages = MediaPreviewBackgroundBlendPages(
+                    lowerPage = 1,
+                    upperPage = 1,
+                ),
+                hasLowerFrame = true,
+                hasUpperFrame = false,
+            ),
+        )
+    }
+
+    @Test
+    fun prefetchItems_prioritizesSettledTargetAndCurrentAndDeduplicatesUris() {
+        val items = persistentListOf(
+            mediaPreviewItem(contentUri = "a"),
+            mediaPreviewItem(contentUri = "b"),
+            mediaPreviewItem(contentUri = "c"),
+            mediaPreviewItem(contentUri = "d"),
+            mediaPreviewItem(contentUri = "b"),
+        )
+
+        val result = getMediaPreviewBackgroundPrefetchItems(
+            items = items,
+            currentPage = 2,
+            settledPage = 1,
+            targetPage = 3,
+            blendPages = MediaPreviewBackgroundBlendPages(
+                lowerPage = 1,
+                upperPage = 2,
+            ),
+        )
+
+        assertEquals(listOf("b", "d", "c", "a"), result.map { item -> item.contentUri })
+    }
+
+    @Test
+    fun transitionState_missingPair_keepsAnchorUntilReadyFrameRecovers() {
+        runTest {
+            val state = mediaPreviewBackgroundTransitionState()
+            val firstFrame = mediaPreviewBackgroundFrame(contentUri = "first")
+            val secondFrame = mediaPreviewBackgroundFrame(contentUri = "second")
+
+            state.settle(frame = firstFrame)
+            state.onScrollFrame(
+                isInteractivePairReady = false,
+                currentPageFrame = null,
+            )
+            state.onScrollFrame(
+                isInteractivePairReady = true,
+                currentPageFrame = secondFrame,
+            )
+
+            assertSame(firstFrame, state.displayedFrame)
+            assertTrue(state.isScrollFallbackLatched)
+
+            state.settle(frame = secondFrame)
+
+            assertSame(secondFrame, state.displayedFrame)
+            assertNull(state.incomingFrame)
+            assertFalse(state.isScrollFallbackLatched)
+        }
+    }
+
+    @Test
+    fun transitionState_sameSettledFrame_clearsFallbackWithoutReplacingFrame() {
+        runTest {
+            val state = mediaPreviewBackgroundTransitionState()
+            val frame = mediaPreviewBackgroundFrame(contentUri = "same")
+
+            state.settle(frame = frame)
+            state.onScrollFrame(
+                isInteractivePairReady = false,
+                currentPageFrame = null,
+            )
+            state.settle(frame = mediaPreviewBackgroundFrame(contentUri = "same"))
+
+            assertSame(frame, state.displayedFrame)
+            assertFalse(state.isScrollFallbackLatched)
+        }
+    }
+
+    @Test
+    fun transitionState_clear_removesDisplayedAndIncomingFrames() {
+        runTest {
+            val state = mediaPreviewBackgroundTransitionState()
+
+            state.settle(frame = mediaPreviewBackgroundFrame(contentUri = "first"))
+            state.clear()
+
+            assertNull(state.displayedFrame)
+            assertNull(state.incomingFrame)
+            assertFalse(state.isScrollFallbackLatched)
+        }
+    }
+
+    @Test
+    fun transitionState_readyScrollSettlesImmediatelyWithoutRecovery() {
+        runTest {
+            var recoveryCount = 0
+            val state = MediaPreviewBackgroundTransitionState(
+                recoveryAnimator = { recoveryProgress ->
+                    recoveryCount += 1
+                    recoveryProgress.snapTo(targetValue = 1f)
+                },
+            )
+            val firstFrame = mediaPreviewBackgroundFrame(contentUri = "first")
+            val secondFrame = mediaPreviewBackgroundFrame(contentUri = "second")
+
+            state.settle(frame = firstFrame)
+            state.onScrollFrame(
+                isInteractivePairReady = true,
+                currentPageFrame = firstFrame,
+            )
+            state.settle(frame = secondFrame)
+
+            assertSame(secondFrame, state.displayedFrame)
+            assertNull(state.incomingFrame)
+            assertFalse(state.isScrollFallbackLatched)
+            assertEquals(1, recoveryCount)
+        }
+    }
+
+    @Test
+    fun transitionState_scrollDuringRecovery_finishesSafelyThenRecoversLatestFrame() {
+        runTest {
+            val recoveryStarted = CompletableDeferred()
+            val releaseRecovery = CompletableDeferred()
+            val testDispatcher = StandardTestDispatcher(testScheduler)
+            var recoveryCount = 0
+            val state = MediaPreviewBackgroundTransitionState(
+                recoveryAnimator = { recoveryProgress ->
+                    recoveryCount += 1
+                    when (recoveryCount) {
+                        1 -> recoveryProgress.snapTo(targetValue = 1f)
+                        else -> {
+                            recoveryStarted.complete(Unit)
+                            releaseRecovery.await()
+                            recoveryProgress.snapTo(targetValue = 1f)
+                        }
+                    }
+                },
+            )
+            val firstFrame = mediaPreviewBackgroundFrame(contentUri = "first")
+            val secondFrame = mediaPreviewBackgroundFrame(contentUri = "second")
+            val latestFrame = mediaPreviewBackgroundFrame(contentUri = "latest")
+
+            state.settle(frame = firstFrame)
+            val recoveryJob = backgroundScope.launch(testDispatcher) {
+                state.settle(frame = secondFrame)
+            }
+            testScheduler.runCurrent()
+            recoveryStarted.await()
+
+            state.onScrollFrame(
+                isInteractivePairReady = true,
+                currentPageFrame = firstFrame,
+            )
+            assertTrue(state.isScrollFallbackLatched)
+            assertSame(secondFrame, state.incomingFrame)
+
+            releaseRecovery.complete(Unit)
+            recoveryJob.join()
+
+            assertSame(secondFrame, state.displayedFrame)
+            assertTrue(state.isScrollFallbackLatched)
+
+            state.settle(frame = latestFrame)
+
+            assertSame(latestFrame, state.displayedFrame)
+            assertFalse(state.isScrollFallbackLatched)
+        }
+    }
+
+    private fun mediaPreviewBackgroundTransitionState(): MediaPreviewBackgroundTransitionState {
+        return MediaPreviewBackgroundTransitionState(
+            recoveryAnimator = { recoveryProgress ->
+                recoveryProgress.snapTo(targetValue = 1f)
+            },
+        )
+    }
+
+    private fun mediaPreviewBackgroundFrame(contentUri: String): MediaPreviewBackgroundFrame {
+        return MediaPreviewBackgroundFrame(
+            contentUri = contentUri,
+            imageBitmap = ImageBitmap(width = 1, height = 1),
+        )
+    }
+
+    private fun mediaPreviewItem(contentUri: String): MediaPreviewItem {
+        return MediaPreviewItem(
+            contentUri = contentUri,
+            contentType = "image/jpeg",
+            isVideo = false,
+        )
+    }
+
+    private companion object {
+        private const val FLOAT_DELTA = 0.001f
+    }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapPrefetcherTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapPrefetcherTest.kt
new file mode 100644
index 000000000..e44281aec
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapPrefetcherTest.kt
@@ -0,0 +1,220 @@
+package com.android.messaging.ui.common.components.mediapreview
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class MediaPreviewBitmapPrefetcherTest {
+
+    @Test
+    fun updateRequests_preservesTwoActiveLoadsAndStartsNextCandidateByPriority() {
+        runTest {
+            val testDispatcher = StandardTestDispatcher(testScheduler)
+            val bitmapCache = MediaPreviewBitmapCache()
+            val prefetcher = MediaPreviewBitmapPrefetcher(workerDispatcher = testDispatcher)
+            val releases = mapOf(
+                "a" to CompletableDeferred(),
+                "b" to CompletableDeferred(),
+                "c" to CompletableDeferred(),
+            )
+            val startedContentUris = mutableListOf()
+            var activeLoadCount = 0
+            var maximumActiveLoadCount = 0
+            backgroundScope.launch(testDispatcher) {
+                prefetcher.runWorkers(
+                    bitmapCache = bitmapCache,
+                    bitmapLoader = { item ->
+                        startedContentUris += item.contentUri
+                        activeLoadCount += 1
+                        maximumActiveLoadCount = maxOf(
+                            maximumActiveLoadCount,
+                            activeLoadCount,
+                        )
+                        try {
+                            releases.getValue(item.contentUri).await()
+                            solidBitmap()
+                        } finally {
+                            activeLoadCount -= 1
+                        }
+                    },
+                )
+            }
+            val items = mediaPreviewItems("a", "b", "c")
+
+            prefetcher.updateRequests(
+                items = items,
+                candidates = items,
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+
+            assertEquals(listOf("a", "b"), startedContentUris)
+            assertEquals(2, maximumActiveLoadCount)
+
+            prefetcher.updateRequests(
+                items = items,
+                candidates = mediaPreviewItems("c", "b", "a"),
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+            assertEquals(listOf("a", "b"), startedContentUris)
+
+            releases.getValue("a").complete(Unit)
+            testScheduler.runCurrent()
+
+            assertEquals(listOf("a", "b", "c"), startedContentUris)
+            assertEquals(2, maximumActiveLoadCount)
+
+            releases.getValue("b").complete(Unit)
+            releases.getValue("c").complete(Unit)
+            testScheduler.runCurrent()
+
+            assertNotNull(bitmapCache["a"])
+            assertNotNull(bitmapCache["b"])
+            assertNotNull(bitmapCache["c"])
+        }
+    }
+
+    @Test
+    fun updateRequests_removedActiveItemFinishesButIsNotCached() {
+        runTest {
+            val testDispatcher = StandardTestDispatcher(testScheduler)
+            val bitmapCache = MediaPreviewBitmapCache()
+            val prefetcher = MediaPreviewBitmapPrefetcher(workerDispatcher = testDispatcher)
+            val firstRelease = CompletableDeferred()
+            val secondRelease = CompletableDeferred()
+            val startedContentUris = mutableListOf()
+            backgroundScope.launch(testDispatcher) {
+                prefetcher.runWorkers(
+                    bitmapCache = bitmapCache,
+                    bitmapLoader = { item ->
+                        startedContentUris += item.contentUri
+                        when (item.contentUri) {
+                            "first" -> firstRelease.await()
+                            else -> secondRelease.await()
+                        }
+                        solidBitmap()
+                    },
+                )
+            }
+
+            prefetcher.updateRequests(
+                items = mediaPreviewItems("first"),
+                candidates = mediaPreviewItems("first"),
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+            prefetcher.updateRequests(
+                items = mediaPreviewItems("second"),
+                candidates = mediaPreviewItems("second"),
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+
+            assertEquals(listOf("first", "second"), startedContentUris)
+
+            firstRelease.complete(Unit)
+            secondRelease.complete(Unit)
+            testScheduler.runCurrent()
+
+            assertNull(bitmapCache["first"])
+            assertNotNull(bitmapCache["second"])
+        }
+    }
+
+    @Test
+    fun failedCandidatesDoNotSpinAndCanRetryAfterLeavingCandidateSet() {
+        runTest {
+            val testDispatcher = StandardTestDispatcher(testScheduler)
+            val bitmapCache = MediaPreviewBitmapCache()
+            val prefetcher = MediaPreviewBitmapPrefetcher(workerDispatcher = testDispatcher)
+            val attemptCounts = mutableMapOf()
+            backgroundScope.launch(testDispatcher) {
+                prefetcher.runWorkers(
+                    bitmapCache = bitmapCache,
+                    bitmapLoader = { item ->
+                        val attemptCount = attemptCounts.getOrDefault(item.contentUri, 0) + 1
+                        attemptCounts[item.contentUri] = attemptCount
+                        when (item.contentUri) {
+                            "null" -> when (attemptCount) {
+                                1 -> null
+                                else -> solidBitmap()
+                            }
+                            "failure" -> throw IllegalStateException("expected test failure")
+                            else -> solidBitmap()
+                        }
+                    },
+                )
+            }
+            val items = mediaPreviewItems("null", "failure", "healthy")
+
+            prefetcher.updateRequests(
+                items = items,
+                candidates = items,
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+            prefetcher.updateRequests(
+                items = items,
+                candidates = items,
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+
+            assertEquals(1, attemptCounts["null"])
+            assertEquals(1, attemptCounts["failure"])
+            assertEquals(1, attemptCounts["healthy"])
+            assertNotNull(bitmapCache["healthy"])
+
+            prefetcher.updateRequests(
+                items = items,
+                candidates = persistentListOf(),
+                bitmapCache = bitmapCache,
+            )
+            prefetcher.updateRequests(
+                items = items,
+                candidates = mediaPreviewItems("null"),
+                bitmapCache = bitmapCache,
+            )
+            testScheduler.runCurrent()
+
+            assertEquals(2, attemptCounts["null"])
+            assertNotNull(bitmapCache["null"])
+            assertTrue(attemptCounts.getValue("healthy") <= 1)
+        }
+    }
+
+    private fun mediaPreviewItems(vararg contentUris: String): ImmutableList {
+        return contentUris
+            .map { contentUri ->
+                MediaPreviewItem(
+                    contentUri = contentUri,
+                    contentType = "image/jpeg",
+                    isVideo = false,
+                )
+            }
+            .let { items -> persistentListOf(*items.toTypedArray()) }
+    }
+
+    private fun solidBitmap(): Bitmap {
+        return Bitmap.createBitmap(
+            intArrayOf(Color.RED),
+            1,
+            1,
+            Bitmap.Config.ARGB_8888,
+        )
+    }
+}
diff --git a/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackground.kt b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackground.kt
index c8b548544..497d1d7d1 100644
--- a/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackground.kt
+++ b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackground.kt
@@ -1,5 +1,6 @@
 package com.android.messaging.ui.common.components.mediapreview
 
+import android.graphics.Bitmap
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
@@ -8,14 +9,16 @@ import androidx.compose.foundation.pager.PagerState
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
 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.graphics.graphicsLayer
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.tooling.preview.PreviewLightDark
@@ -25,7 +28,8 @@ import com.android.messaging.ui.common.components.attachment.loadMediaThumbnailB
 import com.android.messaging.ui.core.MessagingPreviewTheme
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.filterNotNull
 
 private const val MEDIA_PREVIEW_BACKGROUND_BITMAP_SIZE_PX = 40
 private const val MEDIA_PREVIEW_BACKGROUND_OVERLAY_ALPHA = 0.5f
@@ -37,184 +41,375 @@ internal fun MediaPreviewBackground(
     pagerState: PagerState,
     modifier: Modifier = Modifier,
 ) {
-    val backgroundState = rememberMediaPreviewBackgroundState(
-        items = items,
-        settledPage = pagerState.settledPage,
-    )
+    val context = LocalContext.current
 
-    MediaPreviewBackgroundContent(
+    val bitmapLoader: suspend (MediaPreviewItem) -> Bitmap? = remember(context) {
+        { item ->
+            loadMediaThumbnailBitmap(
+                contentResolver = context.contentResolver,
+                contentUri = item.contentUri.toUri(),
+                contentType = item.contentType,
+                size = IntSize(
+                    width = MEDIA_PREVIEW_BACKGROUND_BITMAP_SIZE_PX,
+                    height = MEDIA_PREVIEW_BACKGROUND_BITMAP_SIZE_PX,
+                ),
+                softenBitmap = true,
+            )
+        }
+    }
+
+    MediaPreviewBackground(
         modifier = modifier,
-        state = backgroundState,
+        items = items,
+        pagerState = pagerState,
+        bitmapLoader = bitmapLoader,
     )
 }
 
 @Composable
-private fun MediaPreviewBackgroundContent(
+internal fun MediaPreviewBackground(
+    items: ImmutableList,
+    pagerState: PagerState,
+    bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
     modifier: Modifier = Modifier,
-    state: MediaPreviewBackgroundState,
 ) {
-    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(),
-            )
+    val frames = rememberMediaPreviewBackgroundFrames(
+        items = items,
+        pagerState = pagerState,
+        bitmapLoader = bitmapLoader,
+    )
+    val transitionState = remember { MediaPreviewBackgroundTransitionState() }
 
-            Box(
-                modifier = Modifier
-                    .fillMaxSize()
-                    .background(
-                        color = Color.Black.copy(alpha = MEDIA_PREVIEW_BACKGROUND_OVERLAY_ALPHA),
-                    ),
-            )
-        }
-    }
+    MediaPreviewBackgroundTransitionEffects(
+        items = items,
+        pagerState = pagerState,
+        frames = frames,
+        transitionState = transitionState,
+    )
+
+    val renderState = resolveMediaPreviewBackgroundRenderState(
+        isScrollInProgress = pagerState.isScrollInProgress,
+        frames = frames,
+        transitionState = transitionState,
+    )
+
+    MediaPreviewBackgroundContent(
+        modifier = modifier,
+        fallbackBackgroundColor = MaterialTheme
+            .colorScheme
+            .surfaceContainerHighest
+            .copy(alpha = MEDIA_PREVIEW_BACKGROUND_FALLBACK_ALPHA),
+        pagerState = pagerState,
+        renderState = renderState,
+    )
 }
 
 @Composable
-private fun rememberMediaPreviewBackgroundState(
+private fun rememberMediaPreviewBackgroundFrames(
     items: ImmutableList,
-    settledPage: Int,
-): MediaPreviewBackgroundState {
-    val backgroundSelection = remember(
+    pagerState: PagerState,
+    bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
+): MediaPreviewBackgroundFrames {
+    val blendPages by remember(pagerState, items.size) {
+        derivedStateOf {
+            resolveMediaPreviewBackgroundBlendPages(
+                currentPage = pagerState.currentPage,
+                currentPageOffsetFraction = pagerState.currentPageOffsetFraction,
+                pageCount = items.size,
+            )
+        }
+    }
+    val itemsToPrefetch = remember(
         items,
-        settledPage,
+        pagerState.currentPage,
+        pagerState.settledPage,
+        pagerState.targetPage,
+        blendPages,
     ) {
-        getMediaPreviewBackgroundSelection(
+        getMediaPreviewBackgroundPrefetchItems(
             items = items,
-            settledPage = settledPage,
+            currentPage = pagerState.currentPage,
+            settledPage = pagerState.settledPage,
+            targetPage = pagerState.targetPage,
+            blendPages = blendPages,
         )
     }
-
-    val backgroundBitmapCache = rememberMediaPreviewBitmapCache(
+    val bitmapCache = rememberMediaPreviewBitmapCache(
         items = items,
-        itemsToPrefetch = backgroundSelection.itemsToPrefetch,
+        itemsToPrefetch = itemsToPrefetch,
+        bitmapLoader = bitmapLoader,
+    )
+    val lowerFrame = mediaPreviewBackgroundFrame(
+        items = items,
+        page = blendPages.lowerPage,
+        bitmapCache = bitmapCache,
+    )
+    val upperFrame = mediaPreviewBackgroundFrame(
+        items = items,
+        page = blendPages.upperPage,
+        bitmapCache = bitmapCache,
     )
 
-    val fallbackBackgroundColor = MaterialTheme
-        .colorScheme
-        .surfaceContainerHighest
-        .copy(alpha = MEDIA_PREVIEW_BACKGROUND_FALLBACK_ALPHA)
+    return MediaPreviewBackgroundFrames(
+        bitmapCache = bitmapCache,
+        blendPages = blendPages,
+        lowerFrame = lowerFrame,
+        upperFrame = upperFrame,
+        currentPageFrame = mediaPreviewBackgroundFrame(
+            items = items,
+            page = pagerState.currentPage,
+            bitmapCache = bitmapCache,
+        ),
+        isInteractivePairReady = rememberMediaPreviewBackgroundInteractivePairReady(
+            pagerState = pagerState,
+            blendPages = blendPages,
+            hasLowerFrame = lowerFrame != null,
+            hasUpperFrame = upperFrame != null,
+        ),
+    )
+}
 
-    val settledBackgroundBitmap = backgroundSelection
-        .itemsToPrefetch
-        .firstOrNull()
-        ?.let { item ->
-            backgroundBitmapCache[item.contentUri]
+@Composable
+private fun rememberMediaPreviewBackgroundInteractivePairReady(
+    pagerState: PagerState,
+    blendPages: MediaPreviewBackgroundBlendPages,
+    hasLowerFrame: Boolean,
+    hasUpperFrame: Boolean,
+): Boolean {
+    val isInteractivePairReady by remember(
+        pagerState,
+        blendPages,
+        hasLowerFrame,
+        hasUpperFrame,
+    ) {
+        derivedStateOf {
+            resolveMediaPreviewBackgroundInteractivePairReady(
+                currentPageOffsetFraction = pagerState.currentPageOffsetFraction,
+                blendPages = blendPages,
+                hasLowerFrame = hasLowerFrame,
+                hasUpperFrame = hasUpperFrame,
+            )
         }
+    }
 
-    val settledBackgroundImageBitmap = settledBackgroundBitmap?.asImageBitmap()
-
-    return MediaPreviewBackgroundState(
-        settledBackgroundImageBitmap = settledBackgroundImageBitmap,
-        fallbackBackgroundColor = fallbackBackgroundColor,
-    )
+    return isInteractivePairReady
 }
 
 @Composable
 private fun rememberMediaPreviewBitmapCache(
     items: ImmutableList,
     itemsToPrefetch: ImmutableList,
+    bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
 ): MediaPreviewBitmapCache {
-    val context = LocalContext.current
+    val bitmapCache = remember { MediaPreviewBitmapCache() }
+    val bitmapPrefetcher = remember { MediaPreviewBitmapPrefetcher() }
+    val currentBitmapLoader by rememberUpdatedState(newValue = bitmapLoader)
 
-    val backgroundBitmapCache = remember {
-        MediaPreviewBitmapCache()
+    LaunchedEffect(bitmapPrefetcher, bitmapCache) {
+        bitmapPrefetcher.runWorkers(
+            bitmapCache = bitmapCache,
+            bitmapLoader = { item -> currentBitmapLoader(item) },
+        )
     }
-
-    LaunchedEffect(items) {
-        backgroundBitmapCache.removeInactive(
-            activeContentUris = items
-                .asSequence()
-                .map { it.contentUri }
-                .toSet(),
+    LaunchedEffect(items, itemsToPrefetch, bitmapPrefetcher, bitmapCache) {
+        bitmapPrefetcher.updateRequests(
+            items = items,
+            candidates = itemsToPrefetch,
+            bitmapCache = bitmapCache,
         )
     }
 
-    LaunchedEffect(itemsToPrefetch) {
-        itemsToPrefetch
-            .asSequence()
-            .filter { backgroundBitmapCache[it.contentUri] == null }
-            .forEach { item ->
-                loadMediaThumbnailBitmap(
-                    contentResolver = context.contentResolver,
-                    contentUri = item.contentUri.toUri(),
-                    contentType = item.contentType,
-                    size = IntSize(
-                        width = MEDIA_PREVIEW_BACKGROUND_BITMAP_SIZE_PX,
-                        height = MEDIA_PREVIEW_BACKGROUND_BITMAP_SIZE_PX,
-                    ),
-                    softenBitmap = true,
-                )?.let { bitmap ->
-                    backgroundBitmapCache.put(
-                        contentUri = item.contentUri,
-                        bitmap = bitmap,
-                    )
-                }
+    return bitmapCache
+}
+
+@Composable
+private fun MediaPreviewBackgroundTransitionEffects(
+    items: ImmutableList,
+    pagerState: PagerState,
+    frames: MediaPreviewBackgroundFrames,
+    transitionState: MediaPreviewBackgroundTransitionState,
+) {
+    MediaPreviewBackgroundScrollEffect(
+        pagerState = pagerState,
+        frames = frames,
+        transitionState = transitionState,
+    )
+    MediaPreviewBackgroundSettledEffect(
+        items = items,
+        pagerState = pagerState,
+        bitmapCache = frames.bitmapCache,
+        transitionState = transitionState,
+    )
+}
+
+@Composable
+private fun MediaPreviewBackgroundScrollEffect(
+    pagerState: PagerState,
+    frames: MediaPreviewBackgroundFrames,
+    transitionState: MediaPreviewBackgroundTransitionState,
+) {
+    val isScrollInProgress = pagerState.isScrollInProgress
+
+    LaunchedEffect(
+        isScrollInProgress,
+        frames.isInteractivePairReady,
+        pagerState.currentPage,
+        frames.currentPageFrame,
+    ) {
+        if (isScrollInProgress) {
+            transitionState.onScrollFrame(
+                isInteractivePairReady = frames.isInteractivePairReady,
+                currentPageFrame = frames.currentPageFrame,
+            )
+        }
+    }
+}
+
+@Composable
+private fun MediaPreviewBackgroundSettledEffect(
+    items: ImmutableList,
+    pagerState: PagerState,
+    bitmapCache: MediaPreviewBitmapCache,
+    transitionState: MediaPreviewBackgroundTransitionState,
+) {
+    val currentItems by rememberUpdatedState(newValue = items)
+    val isEmpty = items.isEmpty()
+
+    LaunchedEffect(isEmpty, pagerState, bitmapCache, transitionState) {
+        if (isEmpty) {
+            transitionState.clear()
+            return@LaunchedEffect
+        }
+
+        snapshotFlow {
+            when {
+                pagerState.isScrollInProgress -> null
+                else -> mediaPreviewBackgroundFrame(
+                    items = currentItems,
+                    page = pagerState.settledPage,
+                    bitmapCache = bitmapCache,
+                )
             }
+        }
+            .filterNotNull()
+            .conflate()
+            .collect(transitionState::settle)
     }
+}
 
-    return backgroundBitmapCache
+@Composable
+private fun MediaPreviewBackgroundContent(
+    fallbackBackgroundColor: Color,
+    pagerState: PagerState,
+    renderState: MediaPreviewBackgroundRenderState,
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier = modifier
+            .fillMaxSize()
+            .background(color = fallbackBackgroundColor),
+    ) {
+        when (renderState) {
+            MediaPreviewBackgroundRenderState.Fallback -> Unit
+            is MediaPreviewBackgroundRenderState.Held -> {
+                MediaPreviewBackgroundImage(frame = renderState.frame)
+            }
+            is MediaPreviewBackgroundRenderState.Interactive -> {
+                MediaPreviewBackgroundInteractiveContent(
+                    pagerState = pagerState,
+                    renderState = renderState,
+                )
+            }
+            is MediaPreviewBackgroundRenderState.Recovering -> {
+                MediaPreviewBackgroundRecoveryContent(renderState = renderState)
+            }
+        }
+        MediaPreviewBackgroundOverlay(renderState = renderState)
+    }
 }
 
-private fun getMediaPreviewBackgroundSelection(
-    items: ImmutableList,
-    settledPage: Int,
-): MediaPreviewBackgroundSelection {
-    if (items.isEmpty()) {
-        return MediaPreviewBackgroundSelection(
-            itemsToPrefetch = persistentListOf(),
+@Composable
+private fun MediaPreviewBackgroundInteractiveContent(
+    pagerState: PagerState,
+    renderState: MediaPreviewBackgroundRenderState.Interactive,
+) {
+    MediaPreviewBackgroundImage(frame = renderState.lowerFrame)
+    renderState.upperFrame?.let { upperFrame ->
+        MediaPreviewBackgroundImage(
+            modifier = Modifier
+                .graphicsLayer {
+                    alpha = resolveMediaPreviewBackgroundUpperAlpha(
+                        currentPage = pagerState.currentPage,
+                        currentPageOffsetFraction = pagerState.currentPageOffsetFraction,
+                        lowerPage = renderState.lowerPage,
+                        pageCount = pagerState.pageCount,
+                    )
+                },
+            frame = upperFrame,
         )
     }
+}
 
-    val settledIndex = settledPage.coerceIn(
-        minimumValue = 0,
-        maximumValue = items.lastIndex,
+@Composable
+private fun MediaPreviewBackgroundRecoveryContent(
+    renderState: MediaPreviewBackgroundRenderState.Recovering,
+) {
+    renderState.displayedFrame?.let { displayedFrame ->
+        MediaPreviewBackgroundImage(frame = displayedFrame)
+    }
+    MediaPreviewBackgroundImage(
+        modifier = Modifier
+            .graphicsLayer {
+                alpha = renderState.recoveryProgress.value
+            },
+        frame = renderState.incomingFrame,
     )
+}
 
-    val settledItem = items[settledIndex]
+@Composable
+private fun MediaPreviewBackgroundOverlay(
+    renderState: MediaPreviewBackgroundRenderState,
+) {
+    if (renderState == MediaPreviewBackgroundRenderState.Fallback) {
+        return
+    }
 
-    val previousItem = items
-        .getOrNull(index = settledIndex - 1)
-        ?.takeIf { it.contentUri != settledItem.contentUri }
+    val isRecoveringWithoutDisplayedFrame =
+        renderState is MediaPreviewBackgroundRenderState.Recovering &&
+            renderState.displayedFrame == null
 
-    val nextItem = items
-        .getOrNull(index = settledIndex + 1)
-        ?.takeIf { item ->
-            item.contentUri != settledItem.contentUri &&
-                item.contentUri != previousItem?.contentUri
+    val overlayModifier = when {
+        isRecoveringWithoutDisplayedFrame -> {
+            Modifier
+                .graphicsLayer {
+                    alpha = renderState.recoveryProgress.value
+                }
         }
+        else -> Modifier
+    }
 
-    val itemsToPrefetch = listOfNotNull(
-        settledItem,
-        previousItem,
-        nextItem,
-    ).toImmutableList()
-
-    return MediaPreviewBackgroundSelection(
-        itemsToPrefetch = itemsToPrefetch,
+    Box(
+        modifier = overlayModifier
+            .fillMaxSize()
+            .background(
+                color = Color.Black.copy(alpha = MEDIA_PREVIEW_BACKGROUND_OVERLAY_ALPHA),
+            ),
     )
 }
 
-@Immutable
-private data class MediaPreviewBackgroundSelection(
-    val itemsToPrefetch: ImmutableList,
-)
-
-@Immutable
-private data class MediaPreviewBackgroundState(
-    val settledBackgroundImageBitmap: ImageBitmap?,
-    val fallbackBackgroundColor: Color,
-)
+@Composable
+private fun MediaPreviewBackgroundImage(
+    frame: MediaPreviewBackgroundFrame,
+    modifier: Modifier = Modifier,
+) {
+    Image(
+        modifier = modifier.fillMaxSize(),
+        bitmap = frame.imageBitmap,
+        contentDescription = null,
+        contentScale = ContentScale.Crop,
+        filterQuality = FilterQuality.Low,
+    )
+}
 
 @PreviewLightDark
 @Composable
diff --git a/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundState.kt b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundState.kt
new file mode 100644
index 000000000..7f7c18985
--- /dev/null
+++ b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundState.kt
@@ -0,0 +1,323 @@
+package com.android.messaging.ui.common.components.mediapreview
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.ImageBitmap
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+private const val MEDIA_PREVIEW_BACKGROUND_CROSSFADE_MILLIS = 180
+
+internal fun resolveMediaPreviewBackgroundBlendPages(
+    currentPage: Int,
+    currentPageOffsetFraction: Float,
+    pageCount: Int,
+): MediaPreviewBackgroundBlendPages {
+    if (pageCount <= 0) {
+        return MediaPreviewBackgroundBlendPages(
+            lowerPage = 0,
+            upperPage = 0,
+        )
+    }
+
+    val lastPage = pageCount - 1
+    val clampedCurrentPage = currentPage.coerceIn(
+        minimumValue = 0,
+        maximumValue = lastPage,
+    )
+
+    val unclampedLowerPage = when {
+        currentPageOffsetFraction < 0f -> clampedCurrentPage - 1
+        else -> clampedCurrentPage
+    }
+    val lowerPage = unclampedLowerPage.coerceIn(
+        minimumValue = 0,
+        maximumValue = lastPage,
+    )
+
+    return MediaPreviewBackgroundBlendPages(
+        lowerPage = lowerPage,
+        upperPage = (lowerPage + 1).coerceAtMost(maximumValue = lastPage),
+    )
+}
+
+internal fun resolveMediaPreviewBackgroundUpperAlpha(
+    currentPage: Int,
+    currentPageOffsetFraction: Float,
+    lowerPage: Int,
+    pageCount: Int,
+): Float {
+    if (pageCount <= 1) {
+        return 0f
+    }
+
+    val continuousPage = (currentPage + currentPageOffsetFraction).coerceIn(
+        minimumValue = 0f,
+        maximumValue = (pageCount - 1).toFloat(),
+    )
+    return (continuousPage - lowerPage).coerceIn(
+        minimumValue = 0f,
+        maximumValue = 1f,
+    )
+}
+
+internal fun resolveMediaPreviewBackgroundInteractivePairReady(
+    currentPageOffsetFraction: Float,
+    blendPages: MediaPreviewBackgroundBlendPages,
+    hasLowerFrame: Boolean,
+    hasUpperFrame: Boolean,
+): Boolean {
+    val isUpperFrameRequired = blendPages.lowerPage != blendPages.upperPage &&
+        currentPageOffsetFraction != 0f
+
+    return hasLowerFrame && (!isUpperFrameRequired || hasUpperFrame)
+}
+
+internal fun getMediaPreviewBackgroundPrefetchItems(
+    items: ImmutableList,
+    currentPage: Int,
+    settledPage: Int,
+    targetPage: Int,
+    blendPages: MediaPreviewBackgroundBlendPages,
+): ImmutableList {
+    if (items.isEmpty()) {
+        return persistentListOf()
+    }
+
+    val candidatePages = listOf(
+        settledPage,
+        targetPage,
+        currentPage,
+        blendPages.lowerPage,
+        blendPages.upperPage,
+        targetPage - 1,
+        targetPage + 1,
+        currentPage - 1,
+        currentPage + 1,
+        settledPage - 1,
+        settledPage + 1,
+    )
+    val seenContentUris = mutableSetOf()
+
+    return candidatePages
+        .asSequence()
+        .mapNotNull(items::getOrNull)
+        .filter { item -> seenContentUris.add(item.contentUri) }
+        .toImmutableList()
+}
+
+internal fun mediaPreviewBackgroundFrame(
+    items: ImmutableList,
+    page: Int,
+    bitmapCache: MediaPreviewBitmapCache,
+): MediaPreviewBackgroundFrame? {
+    return items
+        .getOrNull(index = page)
+        ?.let { item ->
+            bitmapCache[item.contentUri]?.let { imageBitmap ->
+                MediaPreviewBackgroundFrame(
+                    contentUri = item.contentUri,
+                    imageBitmap = imageBitmap,
+                )
+            }
+        }
+}
+
+internal fun resolveMediaPreviewBackgroundRenderState(
+    isScrollInProgress: Boolean,
+    frames: MediaPreviewBackgroundFrames,
+    transitionState: MediaPreviewBackgroundTransitionState,
+): MediaPreviewBackgroundRenderState {
+    val incomingFrame = transitionState.incomingFrame
+    val displayedFrame = transitionState.displayedFrame
+    val interactiveLowerFrame = frames.lowerFrame
+
+    val canRenderInteractiveState = isScrollInProgress &&
+        !transitionState.isScrollFallbackLatched &&
+        frames.isInteractivePairReady &&
+        interactiveLowerFrame != null
+
+    return when {
+        incomingFrame != null -> {
+            MediaPreviewBackgroundRenderState.Recovering(
+                displayedFrame = displayedFrame,
+                incomingFrame = incomingFrame,
+                recoveryProgress = transitionState.recoveryProgress,
+            )
+        }
+
+        canRenderInteractiveState -> {
+            MediaPreviewBackgroundRenderState.Interactive(
+                lowerFrame = interactiveLowerFrame,
+                upperFrame = frames.upperFrame,
+                lowerPage = frames.blendPages.lowerPage,
+            )
+        }
+
+        displayedFrame != null -> {
+            MediaPreviewBackgroundRenderState.Held(
+                frame = displayedFrame,
+            )
+        }
+
+        else -> MediaPreviewBackgroundRenderState.Fallback
+    }
+}
+
+@Stable
+internal class MediaPreviewBackgroundTransitionState(
+    private val recoveryAnimator: suspend (Animatable) -> Unit =
+        ::animateMediaPreviewBackgroundRecovery,
+) {
+    val recoveryProgress = Animatable(initialValue = 0f)
+
+    var displayedFrame by mutableStateOf(value = null)
+        private set
+
+    var incomingFrame by mutableStateOf(value = null)
+        private set
+
+    var isScrollFallbackLatched by mutableStateOf(value = false)
+        private set
+
+    private var scrollSession = 0
+    private var isScrollSessionActive = false
+
+    suspend fun clear() {
+        recoveryProgress.snapTo(targetValue = 0f)
+        displayedFrame = null
+        incomingFrame = null
+        isScrollFallbackLatched = false
+        isScrollSessionActive = false
+    }
+
+    fun onScrollFrame(
+        isInteractivePairReady: Boolean,
+        currentPageFrame: MediaPreviewBackgroundFrame?,
+    ) {
+        if (!isScrollSessionActive) {
+            scrollSession += 1
+            isScrollSessionActive = true
+            isScrollFallbackLatched = incomingFrame != null
+        }
+
+        val shouldLatchScrollFallback = !isInteractivePairReady
+        val shouldDisplayCurrentPageFrame = currentPageFrame != null &&
+            !isScrollFallbackLatched &&
+            incomingFrame == null
+
+        when {
+            shouldLatchScrollFallback -> {
+                isScrollFallbackLatched = true
+            }
+
+            shouldDisplayCurrentPageFrame -> {
+                displayedFrame = currentPageFrame
+            }
+        }
+    }
+
+    suspend fun settle(frame: MediaPreviewBackgroundFrame) {
+        val canSettleScrollSession = isScrollSessionActive &&
+            !isScrollFallbackLatched &&
+            incomingFrame == null
+
+        if (canSettleScrollSession) {
+            displayedFrame = frame
+            finishScrollSession(scrollSessionToFinish = scrollSession)
+            return
+        }
+
+        recoverTo(frame = frame)
+    }
+
+    private suspend fun recoverTo(frame: MediaPreviewBackgroundFrame) {
+        val recoveryScrollSession = scrollSession
+
+        if (displayedFrame?.contentUri == frame.contentUri) {
+            finishScrollSession(scrollSessionToFinish = recoveryScrollSession)
+            return
+        }
+
+        recoveryProgress.snapTo(targetValue = 0f)
+        incomingFrame = frame
+        recoveryAnimator(recoveryProgress)
+
+        displayedFrame = frame
+        incomingFrame = null
+        finishScrollSession(scrollSessionToFinish = recoveryScrollSession)
+    }
+
+    private fun finishScrollSession(scrollSessionToFinish: Int) {
+        if (scrollSessionToFinish == scrollSession) {
+            isScrollFallbackLatched = false
+            isScrollSessionActive = false
+        }
+    }
+}
+
+private suspend fun animateMediaPreviewBackgroundRecovery(
+    recoveryProgress: Animatable,
+) {
+    recoveryProgress.animateTo(
+        targetValue = 1f,
+        animationSpec = tween(
+            durationMillis = MEDIA_PREVIEW_BACKGROUND_CROSSFADE_MILLIS,
+            easing = LinearEasing,
+        ),
+    )
+}
+
+@Immutable
+internal data class MediaPreviewBackgroundBlendPages(
+    val lowerPage: Int,
+    val upperPage: Int,
+)
+
+@Immutable
+internal data class MediaPreviewBackgroundFrame(
+    val contentUri: String,
+    val imageBitmap: ImageBitmap,
+)
+
+@Immutable
+internal data class MediaPreviewBackgroundFrames(
+    val bitmapCache: MediaPreviewBitmapCache,
+    val blendPages: MediaPreviewBackgroundBlendPages,
+    val lowerFrame: MediaPreviewBackgroundFrame?,
+    val upperFrame: MediaPreviewBackgroundFrame?,
+    val currentPageFrame: MediaPreviewBackgroundFrame?,
+    val isInteractivePairReady: Boolean,
+)
+
+@Immutable
+internal sealed interface MediaPreviewBackgroundRenderState {
+    data object Fallback : MediaPreviewBackgroundRenderState
+
+    @Immutable
+    data class Held(
+        val frame: MediaPreviewBackgroundFrame,
+    ) : MediaPreviewBackgroundRenderState
+
+    @Immutable
+    data class Interactive(
+        val lowerFrame: MediaPreviewBackgroundFrame,
+        val upperFrame: MediaPreviewBackgroundFrame?,
+        val lowerPage: Int,
+    ) : MediaPreviewBackgroundRenderState
+
+    @Immutable
+    data class Recovering(
+        val displayedFrame: MediaPreviewBackgroundFrame?,
+        val incomingFrame: MediaPreviewBackgroundFrame,
+        val recoveryProgress: Animatable,
+    ) : MediaPreviewBackgroundRenderState
+}
diff --git a/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapCache.kt b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapCache.kt
index 4fd8ac5e0..6a3cd4cd5 100644
--- a/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapCache.kt
+++ b/src/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBitmapCache.kt
@@ -3,17 +3,30 @@ package com.android.messaging.ui.common.components.mediapreview
 import android.graphics.Bitmap
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import com.android.messaging.util.LogUtil
+import java.io.IOException
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 
 @Stable
 internal class MediaPreviewBitmapCache {
-    private val cachedBackgroundBitmapsByContentUri = mutableStateMapOf()
+    private val cachedBackgroundBitmapsByContentUri = mutableStateMapOf()
 
-    operator fun get(contentUri: String): Bitmap? {
+    operator fun get(contentUri: String): ImageBitmap? {
         return cachedBackgroundBitmapsByContentUri[contentUri]
     }
 
     fun put(contentUri: String, bitmap: Bitmap) {
-        cachedBackgroundBitmapsByContentUri[contentUri] = bitmap
+        cachedBackgroundBitmapsByContentUri[contentUri] = bitmap.asImageBitmap()
     }
 
     fun removeInactive(activeContentUris: Set) {
@@ -27,3 +40,148 @@ internal class MediaPreviewBitmapCache {
             }
     }
 }
+
+internal class MediaPreviewBitmapPrefetcher(
+    private val workerDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
+) {
+    private val requestSignal = Channel(capacity = Channel.CONFLATED)
+    private val stateMutex = Mutex()
+    private val candidateItemsByContentUri = linkedMapOf()
+    private val inFlightContentUris = mutableSetOf()
+    private val attemptedItemsByContentUri = mutableMapOf()
+    private var activeContentUris = emptySet()
+
+    suspend fun updateRequests(
+        items: ImmutableList,
+        candidates: ImmutableList,
+        bitmapCache: MediaPreviewBitmapCache,
+    ) {
+        val updatedActiveContentUris = items
+            .asSequence()
+            .map { item -> item.contentUri }
+            .toSet()
+
+        stateMutex.withLock {
+            activeContentUris = updatedActiveContentUris
+            updateCandidates(
+                candidates = candidates,
+                activeContentUris = updatedActiveContentUris,
+            )
+        }
+        bitmapCache.removeInactive(activeContentUris = updatedActiveContentUris)
+        requestSignal.trySend(Unit)
+    }
+
+    suspend fun runWorkers(
+        bitmapCache: MediaPreviewBitmapCache,
+        bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
+    ) {
+        coroutineScope {
+            repeat(times = MEDIA_PREVIEW_BACKGROUND_LOAD_CONCURRENCY) {
+                launch(workerDispatcher) {
+                    runWorker(
+                        bitmapCache = bitmapCache,
+                        bitmapLoader = bitmapLoader,
+                    )
+                }
+            }
+        }
+    }
+
+    private fun updateCandidates(
+        candidates: ImmutableList,
+        activeContentUris: Set,
+    ) {
+        val previousCandidateContentUris = candidateItemsByContentUri.keys.toSet()
+        candidateItemsByContentUri.clear()
+        candidates
+            .asSequence()
+            .filter { item -> item.contentUri in activeContentUris }
+            .forEach { item -> candidateItemsByContentUri.putIfAbsent(item.contentUri, item) }
+
+        val removedCandidateContentUris = previousCandidateContentUris -
+            candidateItemsByContentUri.keys
+        attemptedItemsByContentUri.keys.removeAll(removedCandidateContentUris)
+    }
+
+    private suspend fun runWorker(
+        bitmapCache: MediaPreviewBitmapCache,
+        bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
+    ) {
+        while (true) {
+            val item = takeNextItem(bitmapCache = bitmapCache)
+            when (item) {
+                null -> requestSignal.receive()
+                else -> loadItem(
+                    item = item,
+                    bitmapCache = bitmapCache,
+                    bitmapLoader = bitmapLoader,
+                )
+            }
+        }
+    }
+
+    private suspend fun takeNextItem(
+        bitmapCache: MediaPreviewBitmapCache,
+    ): MediaPreviewItem? {
+        return stateMutex.withLock {
+            candidateItemsByContentUri
+                .values
+                .firstOrNull { item ->
+                    bitmapCache[item.contentUri] == null &&
+                        item.contentUri !in inFlightContentUris &&
+                        attemptedItemsByContentUri[item.contentUri] != item
+                }
+                ?.also { item ->
+                    inFlightContentUris += item.contentUri
+                    attemptedItemsByContentUri[item.contentUri] = item
+                    requestSignal.trySend(Unit)
+                }
+        }
+    }
+
+    private suspend fun loadItem(
+        item: MediaPreviewItem,
+        bitmapCache: MediaPreviewBitmapCache,
+        bitmapLoader: suspend (MediaPreviewItem) -> Bitmap?,
+    ) {
+        val bitmap = try {
+            bitmapLoader(item)
+        } catch (exception: CancellationException) {
+            throw exception
+        } catch (exception: IOException) {
+            failedBitmap(exception = exception)
+        } catch (exception: IllegalArgumentException) {
+            failedBitmap(exception = exception)
+        } catch (exception: IllegalStateException) {
+            failedBitmap(exception = exception)
+        } catch (exception: SecurityException) {
+            failedBitmap(exception = exception)
+        }
+
+        val shouldCacheBitmap = stateMutex.withLock {
+            inFlightContentUris -= item.contentUri
+            item.contentUri in activeContentUris
+        }
+
+        if (bitmap != null && shouldCacheBitmap) {
+            bitmapCache.put(
+                contentUri = item.contentUri,
+                bitmap = bitmap,
+            )
+        }
+
+        requestSignal.trySend(Unit)
+    }
+
+    private fun failedBitmap(exception: Exception): Bitmap? {
+        LogUtil.w(TAG, "Failed to load a media preview background", exception)
+        return null
+    }
+
+    private companion object {
+        private const val TAG = "MediaPreviewBitmap"
+
+        private const val MEDIA_PREVIEW_BACKGROUND_LOAD_CONCURRENCY = 2
+    }
+}

From 27a6bd7fa35bce60d4028e800f98b534fef50628 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Sun, 12 Jul 2026 23:53:18 +0300
Subject: [PATCH 18/19] Split ConversationMessageBubble in two files to reduce
 functions count

---
 .../ConversationMessageAttachmentBubble.kt    | 212 ++++++++++++++++++
 .../ui/message/ConversationMessageBubble.kt   | 206 +----------------
 2 files changed, 217 insertions(+), 201 deletions(-)
 create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt

diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt
new file mode 100644
index 000000000..6e33eebb3
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt
@@ -0,0 +1,212 @@
+package com.android.messaging.ui.conversation.messages.ui.message
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+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.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+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.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.attachment.OnConversationAttachmentClick
+import com.android.messaging.ui.conversation.messages.ui.text.ConversationMessageText
+
+private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp
+private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp
+private const val MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA = 0.2f
+
+@Composable
+internal fun ConversationMessageAttachmentOnlyBubble(
+    modifier: Modifier,
+    layout: ConversationMessageLayout,
+    message: ConversationMessageUiModel,
+    isSelected: Boolean,
+    isSelectionMode: Boolean,
+    onAttachmentClick: OnConversationAttachmentClick,
+    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
+internal fun ConversationMessageAttachmentSurfaceBubble(
+    modifier: Modifier,
+    layout: ConversationMessageLayout,
+    isSelected: Boolean,
+    message: ConversationMessageUiModel,
+    isSelectionMode: Boolean,
+    onAttachmentClick: OnConversationAttachmentClick,
+    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 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 ConversationMessageAttachmentBubbleContent(
+    modifier: Modifier = Modifier,
+    layout: ConversationMessageLayout,
+    message: ConversationMessageUiModel,
+    isSelected: Boolean,
+    isSelectionMode: Boolean,
+    onAttachmentClick: OnConversationAttachmentClick,
+    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,
+        )
+
+        ConversationMessageSubject(
+            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,
+            ),
+            subjectText = content.subjectText,
+        )
+
+        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
+    }
+}
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
index 9ccaa1a36..384bf5292 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
@@ -1,23 +1,15 @@
 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.tooling.preview.PreviewLightDark
@@ -40,11 +32,8 @@ import com.android.messaging.ui.conversation.preview.previewVCardPart
 import com.android.messaging.ui.conversation.preview.previewVideoPart
 import kotlinx.collections.immutable.persistentListOf
 
-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(
@@ -106,65 +95,6 @@ internal fun ConversationMessageBubble(
     }
 }
 
-@Composable
-private fun ConversationMessageAttachmentOnlyBubble(
-    modifier: Modifier,
-    layout: ConversationMessageLayout,
-    message: ConversationMessageUiModel,
-    isSelected: Boolean,
-    isSelectionMode: Boolean,
-    onAttachmentClick: OnConversationAttachmentClick,
-    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: OnConversationAttachmentClick,
-    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,
@@ -197,7 +127,7 @@ private fun ConversationMessageTextSurfaceBubble(
 }
 
 @Composable
-private fun ConversationMessageBubbleSurface(
+internal fun ConversationMessageBubbleSurface(
     modifier: Modifier = Modifier,
     isSelected: Boolean,
     message: ConversationMessageUiModel,
@@ -226,44 +156,6 @@ private fun ConversationMessageBubbleSurface(
     }
 }
 
-@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,
@@ -321,94 +213,6 @@ private fun ConversationMessageTextBubbleContent(
     }
 }
 
-@Composable
-private fun ConversationMessageAttachmentBubbleContent(
-    modifier: Modifier = Modifier,
-    layout: ConversationMessageLayout,
-    message: ConversationMessageUiModel,
-    isSelected: Boolean,
-    isSelectionMode: Boolean,
-    onAttachmentClick: OnConversationAttachmentClick,
-    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,
-        )
-
-        ConversationMessageSubject(
-            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,
-            ),
-            subjectText = content.subjectText,
-        )
-
-        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,
@@ -443,7 +247,7 @@ private fun ConversationMessageBody(
 }
 
 @Composable
-private fun ConversationMessageSubject(
+internal fun ConversationMessageSubject(
     subjectText: String?,
     modifier: Modifier = Modifier,
 ) {
@@ -459,7 +263,7 @@ private fun ConversationMessageSubject(
 }
 
 @Composable
-private fun ConversationMessageSender(
+internal fun ConversationMessageSender(
     modifier: Modifier = Modifier,
     color: Color,
     senderDisplayName: String?,
@@ -480,7 +284,7 @@ private fun ConversationMessageSender(
 }
 
 @Composable
-private fun messageBubbleColor(
+internal fun messageBubbleColor(
     message: ConversationMessageUiModel,
     isSelected: Boolean,
 ): Color {
@@ -504,7 +308,7 @@ private fun messageBubbleContentColor(
 }
 
 @Composable
-private fun messageSenderColor(
+internal fun messageSenderColor(
     message: ConversationMessageUiModel,
     isSelected: Boolean,
 ): Color {

From 8eba334a054b9abda925429b035d1ff8001d1ac0 Mon Sep 17 00:00:00 2001
From: Artem Smirnov 
Date: Sun, 12 Jul 2026 23:53:41 +0300
Subject: [PATCH 19/19] Fix MediaPreviewBackgroundTest

---
 .../components/mediapreview/MediaPreviewBackgroundTest.kt     | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
index dee00649f..c089d1960 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/mediapreview/MediaPreviewBackgroundTest.kt
@@ -1,6 +1,6 @@
 package com.android.messaging.ui.common.components.mediapreview
 
-import androidx.compose.ui.graphics.ImageBitmap
+import io.mockk.mockk
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.launch
@@ -393,7 +393,7 @@ internal class MediaPreviewBackgroundTest {
     private fun mediaPreviewBackgroundFrame(contentUri: String): MediaPreviewBackgroundFrame {
         return MediaPreviewBackgroundFrame(
             contentUri = contentUri,
-            imageBitmap = ImageBitmap(width = 1, height = 1),
+            imageBitmap = mockk(),
         )
     }