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
+
+
@@ -113,10 +122,6 @@
- ?android:attr/selectableItemBackgroundBorderless
-
-
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()
}