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/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/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/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 + } +} 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..538ae856f --- /dev/null +++ b/app/src/androidTest/java/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenContentTest.kt @@ -0,0 +1,691 @@ +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.WindowInsets +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.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 +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_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 +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 +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.Assert.assertTrue +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 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() + + 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 + 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() + 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 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() + 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 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() + 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 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() + 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( + uiState: PhotoViewerUiState = loadedPhotoViewerUiState(), + onPageSettled: (Int) -> Unit = {}, + onToggleDisplayMode: () -> Unit = {}, + onEnterImmersiveMode: () -> Unit = {}, + onCloseClick: () -> Unit = {}, + onForwardClick: () -> Unit = {}, + onSaveClick: () -> Unit = {}, + onShareClick: () -> Unit = {}, + ): MutableState { + val uiState = mutableStateOf(value = uiState) + + 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 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, + firstItemIsIncoming: Boolean = true, + firstItemIsDraft: Boolean = false, + itemCount: Int = 2, + displayMode: PhotoViewerDisplayMode = PhotoViewerDisplayMode.Carousel, + isClosing: Boolean = false, + ): PhotoViewerUiState { + val firstItem = photoViewerItem( + index = 1, + senderName = FIRST_SENDER, + canUseActions = firstItemCanUseActions, + isIncoming = firstItemIsIncoming, + isDraft = firstItemIsDraft, + ) + 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, + ) + } + + private fun photoViewerItem( + 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, + isDraft = isDraft, + canUseActions = canUseActions, + ) + } + + 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 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" + 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..613bb0c95 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/media/repository/PhotoViewerRepositoryImplTest.kt @@ -0,0 +1,419 @@ +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.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 +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, + normalizePhotoViewerUri = NormalizePhotoViewerUriImpl(), + 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(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, + isDraft = true, + ), + result.items[1], + ) + } + } + + @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) { + 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_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) { + 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/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/PreparePhotoViewerSendUriImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUriImplTest.kt new file mode 100644 index 000000000..816888d28 --- /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.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@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).toList() + + assertTrue(result.isEmpty()) + } + } +} 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/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/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..c089d1960 --- /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 io.mockk.mockk +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 = mockk(), + ) + } + + 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/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/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/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/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..e4949d892 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModelTest.kt @@ -0,0 +1,581 @@ +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.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 +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.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 +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 actions_emitEffects() { + 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), + initialIndex = 0, + ), + ) + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onSaveClick() + assertEquals( + PhotoViewerEffect.Save( + uri = photo1.contentUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + + viewModel.onShareClick() + assertEquals( + PhotoViewerEffect.Share( + uri = photo1.contentUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + + viewModel.onForwardClick() + assertEquals( + PhotoViewerEffect.Forward( + uri = photo1.contentUri, + contentType = IMAGE_JPEG, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } + } + } + + @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) { + 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) { + 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, + normalizePhotoViewerUri: NormalizePhotoViewerUri = NormalizePhotoViewerUriImpl(), + preparePhotoViewerSendUri: PreparePhotoViewerSendUri = FakePreparePhotoViewerSendUri(), + ): PhotoViewerViewModel { + return PhotoViewerViewModel( + photoViewerRepository = repository, + normalizePhotoViewerUri = normalizePhotoViewerUri, + preparePhotoViewerSendUri = preparePhotoViewerSendUri, + 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(), + initialPhotoOccurrenceIndex = 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 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, + isIncoming = true, + senderName = "Ada Lovelace", + senderDestination = "+1555123000$index", + receivedTimestampMillis = 1_735_689_600_000L + index, + isDraft = false, + ) + } + } + + 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/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
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..8224652d9
--- /dev/null
+++ b/src/com/android/messaging/data/media/model/PhotoViewerItem.kt
@@ -0,0 +1,16 @@
+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 isIncoming: Boolean,
+    val senderName: String?,
+    val senderDestination: String?,
+    val receivedTimestampMillis: Long,
+    val isDraft: Boolean,
+    val canUseActions: Boolean = true,
+)
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..3f6ec0bf9
--- /dev/null
+++ b/src/com/android/messaging/data/media/repository/PhotoViewerRepository.kt
@@ -0,0 +1,241 @@
+@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.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
+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,
+        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
+    private val defaultDispatcher: CoroutineDispatcher,
+) : PhotoViewerRepository {
+
+    override fun getPhotoViewerItems(
+        photosUri: Uri,
+        initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
+    ): Flow {
+        return observeUri(uri = photosUri)
+            .flowOn(defaultDispatcher)
+            .flatMapConcat {
+                loadPhotoViewerItems(
+                    photosUri = photosUri,
+                    initialPhotoUri = initialPhotoUri,
+                    initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
+                ).recoverPhotoViewerItemsLoadFailure(photosUri = photosUri)
+            }
+            .recoverPhotoViewerItemsLoadFailure(photosUri = photosUri)
+    }
+
+    private fun loadPhotoViewerItems(
+        photosUri: Uri,
+        initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
+    ): Flow {
+        return typedFlow { queryItems(photosUri = photosUri) }
+            .flowOn(messagingDbDispatcher)
+            .map { queriedItems ->
+                val loadResult: PhotoViewerItemsLoadResult = PhotoViewerItemsLoadResult.Loaded(
+                    photoViewerItems = buildPhotoViewerItems(
+                        queriedItems = queriedItems,
+                        initialPhotoUri = initialPhotoUri,
+                        initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
+                    ),
+                )
+
+                loadResult
+            }
+            .flowOn(defaultDispatcher)
+    }
+
+    private fun buildPhotoViewerItems(
+        queriedItems: List,
+        initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
+    ): PhotoViewerItems {
+        val items = queriedItems.toImmutableList()
+
+        return PhotoViewerItems(
+            items = items,
+            initialIndex = resolveInitialIndex(
+                items = items,
+                initialPhotoUri = initialPhotoUri,
+                initialPhotoOccurrenceIndex = initialPhotoOccurrenceIndex,
+            ),
+        )
+    }
+
+    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 -> {
+                val contentUri = contentUriString.toUri()
+
+                PhotoViewerItem(
+                    contentUri = contentUri,
+                    contentType = contentType,
+                    isIncoming = MessageData.getIsIncoming(status),
+                    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,
+                    canUseActions = !MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri),
+                )
+            }
+        }
+    }
+
+    private fun resolveInitialIndex(
+        items: List,
+        initialPhotoUri: Uri,
+        initialPhotoOccurrenceIndex: Int,
+    ): Int {
+        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 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..aa7b7d8ca
--- /dev/null
+++ b/src/com/android/messaging/di/photoviewer/PhotoViewerBindsModule.kt
@@ -0,0 +1,44 @@
+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
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@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(
+        impl: PhotoViewerRepositoryImpl,
+    ): PhotoViewerRepository
+
+    @Binds
+    @Reusable
+    abstract fun bindPreparePhotoViewerSendUri(
+        impl: PreparePhotoViewerSendUriImpl,
+    ): PreparePhotoViewerSendUri
+}
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/PreparePhotoViewerSendUri.kt b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
new file mode 100644
index 000000000..7e0ab768c
--- /dev/null
+++ b/src/com/android/messaging/domain/photoviewer/usecase/PreparePhotoViewerSendUri.kt
@@ -0,0 +1,47 @@
+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 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
+
+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 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)
+    }
+
+    private companion object {
+        private const val TAG = "PhotoViewerSendUri"
+    }
+}
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 48137b077..438e844c9 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;
@@ -303,22 +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 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());
+            final Rect initialPhotoBounds, final Uri photosUri,
+            final int initialPhotoOccurrenceIndex) {
+        activity.startActivity(PhotoViewerActivity.createIntent(
+                activity, initialPhoto, photosUri, initialPhotoBounds,
+                initialPhotoOccurrenceIndex));
         activity.overridePendingTransition(0, 0);
     }
 
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..f576e480a
--- /dev/null
+++ b/src/com/android/messaging/ui/common/components/PagerIndicator.kt
@@ -0,0 +1,50 @@
+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/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/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
+    }
+}
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/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 daa9d1acd..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
@@ -27,6 +19,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
@@ -39,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(
@@ -54,7 +44,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 +54,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,35 +95,6 @@ internal fun ConversationMessageBubble(
     }
 }
 
-@Composable
-private fun ConversationMessageAttachmentSurfaceBubble(
-    modifier: Modifier,
-    layout: ConversationMessageLayout,
-    isSelected: Boolean,
-    message: ConversationMessageUiModel,
-    isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
-    onExternalUriClick: (String) -> Unit,
-    onMessageLongClick: () -> Unit,
-) {
-    ConversationMessageBubbleSurface(
-        modifier = modifier,
-        isSelected = isSelected,
-        message = message,
-        layout = layout,
-    ) {
-        ConversationMessageAttachmentBubbleContent(
-            layout = layout,
-            message = message,
-            isSelected = isSelected,
-            isSelectionMode = isSelectionMode,
-            onAttachmentClick = onAttachmentClick,
-            onExternalUriClick = onExternalUriClick,
-            onMessageLongClick = onMessageLongClick,
-        )
-    }
-}
-
 @Composable
 private fun ConversationMessageTextSurfaceBubble(
     modifier: Modifier,
@@ -149,7 +103,7 @@ private fun ConversationMessageTextSurfaceBubble(
     message: ConversationMessageUiModel,
     isSelectionMode: Boolean,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -173,7 +127,7 @@ private fun ConversationMessageTextSurfaceBubble(
 }
 
 @Composable
-private fun ConversationMessageBubbleSurface(
+internal fun ConversationMessageBubbleSurface(
     modifier: Modifier = Modifier,
     isSelected: Boolean,
     message: ConversationMessageUiModel,
@@ -202,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,
@@ -247,7 +163,7 @@ private fun ConversationMessageTextBubbleContent(
     isSelected: Boolean,
     isSelectionMode: Boolean,
     simDisplayName: String?,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -297,100 +213,12 @@ private fun ConversationMessageTextBubbleContent(
     }
 }
 
-@Composable
-private fun ConversationMessageAttachmentBubbleContent(
-    modifier: Modifier = Modifier,
-    layout: ConversationMessageLayout,
-    message: ConversationMessageUiModel,
-    isSelected: Boolean,
-    isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
-    onExternalUriClick: (String) -> Unit,
-    onMessageLongClick: () -> Unit,
-) {
-    val content = layout.content
-    val hasHeader = layout.showSender || !content.subjectText.isNullOrBlank()
-    val hasBodyText = !content.bodyText.isNullOrBlank()
-
-    Column(
-        modifier = modifier.fillMaxWidth(),
-    ) {
-        ConversationMessageSender(
-            modifier = Modifier.padding(
-                start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING,
-                top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING,
-                end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING,
-                bottom = conversationMessageSenderBottomPadding(content),
-            ),
-            color = messageSenderColor(
-                message = message,
-                isSelected = isSelected,
-            ),
-            senderDisplayName = message.senderDisplayName,
-            showSender = layout.showSender,
-        )
-
-        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,
     isIncoming: Boolean,
     isSelectionMode: Boolean,
-    onAttachmentClick: (contentType: String, contentUri: String) -> Unit,
+    onAttachmentClick: OnConversationAttachmentClick,
     onExternalUriClick: (String) -> Unit,
     onMessageLongClick: () -> Unit,
 ) {
@@ -419,7 +247,7 @@ private fun ConversationMessageBody(
 }
 
 @Composable
-private fun ConversationMessageSubject(
+internal fun ConversationMessageSubject(
     subjectText: String?,
     modifier: Modifier = Modifier,
 ) {
@@ -435,7 +263,7 @@ private fun ConversationMessageSubject(
 }
 
 @Composable
-private fun ConversationMessageSender(
+internal fun ConversationMessageSender(
     modifier: Modifier = Modifier,
     color: Color,
     senderDisplayName: String?,
@@ -456,7 +284,7 @@ private fun ConversationMessageSender(
 }
 
 @Composable
-private fun messageBubbleColor(
+internal fun messageBubbleColor(
     message: ConversationMessageUiModel,
     isSelected: Boolean,
 ): Color {
@@ -480,7 +308,7 @@ private fun messageBubbleContentColor(
 }
 
 @Composable
-private fun messageSenderColor(
+internal fun messageSenderColor(
     message: ConversationMessageUiModel,
     isSelected: Boolean,
 ): Color {
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/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/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/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/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..e5b255933
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerActivity.kt
@@ -0,0 +1,118 @@
+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
+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(
+            statusBarStyle = SystemBarStyle.dark(scrim = Color.TRANSPARENT),
+            navigationBarStyle = SystemBarStyle.dark(scrim = Color.TRANSPARENT),
+        )
+
+        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(),
+            initialPhotoOccurrenceIndex = intent.getIntExtra(
+                EXTRA_INITIAL_PHOTO_OCCURRENCE_INDEX,
+                0,
+            ),
+            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_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 =
+            "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
+        @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())
+                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..ce6e13dd1
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/PhotoViewerTestTags.kt
@@ -0,0 +1,17 @@
+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_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"
+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/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..7c4fc296b
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerMetadataSheet.kt
@@ -0,0 +1,227 @@
+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_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
+
+@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 = photoViewerParticipantTitle(item = item),
+            valueModifier = Modifier.testTag(tag = PHOTO_VIEWER_METADATA_SENDER_TEST_TAG),
+        )
+        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,
+        )
+    }
+}
+
+@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(
+    modifier: Modifier = Modifier,
+    label: String,
+    value: String,
+    valueModifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.spacedBy(space = 4.dp),
+    ) {
+        Text(
+            text = label,
+            style = MaterialTheme.typography.labelLarge,
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+        )
+        Text(
+            modifier = valueModifier,
+            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..d2a4ab88c
--- /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).coerceAtLeast(minimumValue = 0.dp),
+    )
+}
+
+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/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/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..fe2899c19
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTopBar.kt
@@ -0,0 +1,277 @@
+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.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
+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_TIMESTAMP_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,
+    navigationBarInsets: WindowInsets = WindowInsets.navigationBars,
+    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,
+                navigationBarInsets = navigationBarInsets,
+                onMetadataClick = onMetadataClick,
+                onCloseClick = onCloseClick,
+                onForwardClick = onForwardClick,
+                onSaveClick = onSaveClick,
+                onShareClick = onShareClick,
+            )
+        }
+    }
+}
+
+@Composable
+private fun PhotoViewerTopBarContent(
+    item: PhotoViewerItem?,
+    actionsEnabled: Boolean,
+    navigationBarInsets: WindowInsets,
+    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()
+            .windowInsetsPadding(
+                insets = navigationBarInsets.only(sides = WindowInsetsSides.Horizontal),
+            )
+            .padding(all = 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 = photoViewerParticipantTitle(item = item),
+            style = MaterialTheme.typography.titleSmall,
+            color = contentColor,
+            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,
+            )
+        }
+    }
+}
+
+@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/PhotoViewerTransformInput.kt b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt
new file mode 100644
index 000000000..6ec982a13
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/component/PhotoViewerTransformInput.kt
@@ -0,0 +1,132 @@
+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 = shouldStartPhotoViewerTransform(
+                    isZoomed = gestureState.isZoomed(),
+                    accumulatedZoom = accumulatedZoom,
+                    centroidSize = event.calculateCentroidSize(useCurrent = false),
+                    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
+    }
+}
+
+internal fun shouldStartPhotoViewerTransform(
+    isZoomed: Boolean,
+    accumulatedZoom: Float,
+    centroidSize: Float,
+    accumulatedPan: Offset,
+    touchSlop: Float,
+    pressedPointerCount: Int,
+): Boolean {
+    val zoomMotion = abs(1f - accumulatedZoom) * centroidSize
+    val panMotion = accumulatedPan.getDistance()
+
+    return when {
+        pressedPointerCount > 1 && zoomMotion > touchSlop -> true
+        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
new file mode 100644
index 000000000..7d557309f
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/component/ZoomablePhotoContainer.kt
@@ -0,0 +1,350 @@
+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.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.detectTapGestures
+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.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
+
+@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 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,
+                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,
+    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,
+        )
+        .photoViewerTransformInput(
+            contentKey = contentKey,
+            gestureState = gestureState,
+            currentOnEnterImmersiveMode = currentOnEnterImmersiveMode,
+        )
+        .photoViewerDismissDragInput(
+            contentKey = contentKey,
+            gestureState = gestureState,
+            dismissDragState = dismissDragState,
+            currentOnCloseClick = currentOnCloseClick,
+        )
+        .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 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
+                }
+
+                pressedChanges.size > 1 -> {
+                    dismissDragState.reset()
+                    isGestureActive = false
+                }
+
+                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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+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 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 ||
+        shouldStartPhotoViewerDismissDrag(
+            accumulatedDrag = accumulatedDrag,
+            touchSlop = touchSlop,
+        )
+
+    val dismissDragAmount = when {
+        isDismissDragActive -> dragAmount
+        else -> {
+            (accumulatedDrag.y - touchSlop)
+                .coerceAtLeast(minimumValue = 0f)
+        }
+    }
+
+    return PhotoViewerDismissDragDecision(
+        isActive = canHandleDismissDrag && shouldStartDismissDrag,
+        dragAmount = dismissDragAmount,
+    )
+}
+
+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>,
+) {
+    when {
+        dismissDragState.shouldCloseOnDragEnd() -> {
+            currentOnCloseClick.value()
+        }
+
+        else -> {
+            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..4471dd757
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/model/PhotoViewerLaunchRequest.kt
@@ -0,0 +1,28 @@
+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,
+    val initialPhotoOccurrenceIndex: Int = 0,
+)
+
+@Immutable
+internal data class PhotoViewerLaunchRequestKey(
+    val initialPhotoUri: String,
+    val photosUri: String,
+    val initialPhotoOccurrenceIndex: Int,
+)
+
+internal fun photoViewerLaunchRequestKey(
+    launchRequest: PhotoViewerLaunchRequest,
+): PhotoViewerLaunchRequestKey {
+    return PhotoViewerLaunchRequestKey(
+        initialPhotoUri = launchRequest.initialPhotoUri,
+        photosUri = launchRequest.photosUri,
+        initialPhotoOccurrenceIndex = launchRequest.initialPhotoOccurrenceIndex,
+    )
+}
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..5d8205f04
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/preview/PhotoViewerPreviewData.kt
@@ -0,0 +1,26 @@
+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",
+        isIncoming = true,
+        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..72196f4eb
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerContent.kt
@@ -0,0 +1,248 @@
+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.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+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.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
+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
+private val PhotoViewerPageIndicatorBottomPadding = 24.dp
+
+@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 }
+    }
+    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,
+            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 = shouldShowChrome,
+            item = currentItem,
+            actionsEnabled = actionsEnabled,
+            onMetadataClick = onMetadataClick,
+            onCloseClick = onCloseClick,
+            onForwardClick = onForwardClick,
+            onSaveClick = onSaveClick,
+            onShareClick = onShareClick,
+        )
+    }
+}
+
+@Composable
+private fun PhotoViewerMediaContent(
+    uiState: PhotoViewerUiState,
+    isPageIndicatorVisible: Boolean,
+    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,
+    )
+
+    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,
+        )
+
+        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
+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..e61c441ea
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreen.kt
@@ -0,0 +1,162 @@
+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,
+    )
+    PhotoViewerSystemBarsEffect(displayMode = uiState.displayMode)
+
+    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?.canUseActions == true
+    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..989c8b85c
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerScreenEffects.kt
@@ -0,0 +1,116 @@
+package com.android.messaging.ui.photoviewer.screen
+
+import android.content.ActivityNotFoundException
+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
+
+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 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 fun sharePhoto(
+    context: Context,
+    uri: Uri,
+    contentType: String,
+) {
+    try {
+        Intent(Intent.ACTION_SEND)
+            .apply {
+                type = contentType
+                putExtra(Intent.EXTRA_STREAM, uri)
+                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 fun forwardPhoto(
+    context: Context,
+    uri: Uri,
+    contentType: String,
+) {
+    try {
+        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)
+    }
+}
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..d3d8a7c1b
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerSystemBarsEffect.kt
@@ -0,0 +1,57 @@
+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.runtime.remember
+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()
+    val controller = remember(window, view) {
+        createWindowInsetsController(
+            window = window,
+            view = view,
+        )
+    }
+
+    DisposableEffect(controller) {
+        controller?.systemBarsBehavior = WindowInsetsControllerCompat
+            .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+        val systemBars = WindowInsetsCompat.Type.systemBars()
+        onDispose {
+            controller?.show(systemBars)
+        }
+    }
+
+    LaunchedEffect(controller, displayMode) {
+        val systemBars = WindowInsetsCompat.Type.systemBars()
+        when (displayMode) {
+            PhotoViewerDisplayMode.Carousel -> controller?.show(systemBars)
+            PhotoViewerDisplayMode.Immersive -> controller?.hide(systemBars)
+        }
+    }
+}
+
+private fun createWindowInsetsController(
+    window: Window?,
+    view: View,
+): WindowInsetsControllerCompat? {
+    return window?.let {
+        WindowInsetsControllerCompat(
+            it,
+            view,
+        )
+    }
+}
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..38515adc9
--- /dev/null
+++ b/src/com/android/messaging/ui/photoviewer/screen/PhotoViewerViewModel.kt
@@ -0,0 +1,385 @@
+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.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
+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.map
+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,
+    private val normalizePhotoViewerUri: NormalizePhotoViewerUri,
+    private val preparePhotoViewerSendUri: PreparePhotoViewerSendUri,
+    @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(),
+                    initialPhotoOccurrenceIndex = launchRequest.initialPhotoOccurrenceIndex,
+                )
+                .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(
+                        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() {
+        sendPreparedPhotoEffect { item, preparedUri ->
+            PhotoViewerEffect.Share(
+                uri = preparedUri,
+                contentType = item.contentType,
+            )
+        }
+    }
+
+    override fun onForwardClick() {
+        sendPreparedPhotoEffect { item, preparedUri ->
+            PhotoViewerEffect.Forward(
+                uri = preparedUri,
+                contentType = item.contentType,
+            )
+        }
+    }
+
+    private fun sendEffect(effect: PhotoViewerEffect) {
+        viewModelScope.launch(defaultDispatcher) {
+            effectsChannel.send(element = effect)
+        }
+    }
+
+    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(
+                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)
+            ?.takeIf { item -> item.canUseActions }
+    }
+
+    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 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()
 }