• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.photopicker.features.preview
18 
19 import androidx.compose.foundation.background
20 import androidx.compose.foundation.focusable
21 import androidx.compose.foundation.layout.Arrangement
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.foundation.layout.Column
24 import androidx.compose.foundation.layout.Row
25 import androidx.compose.foundation.layout.Spacer
26 import androidx.compose.foundation.layout.WindowInsets
27 import androidx.compose.foundation.layout.WindowInsetsSides
28 import androidx.compose.foundation.layout.fillMaxSize
29 import androidx.compose.foundation.layout.fillMaxWidth
30 import androidx.compose.foundation.layout.only
31 import androidx.compose.foundation.layout.padding
32 import androidx.compose.foundation.layout.size
33 import androidx.compose.foundation.layout.systemBars
34 import androidx.compose.foundation.layout.windowInsetsPadding
35 import androidx.compose.foundation.pager.HorizontalPager
36 import androidx.compose.foundation.pager.PagerState
37 import androidx.compose.foundation.pager.rememberPagerState
38 import androidx.compose.foundation.shape.CircleShape
39 import androidx.compose.material.icons.Icons
40 import androidx.compose.material.icons.automirrored.filled.ArrowBack
41 import androidx.compose.material.icons.filled.PhotoLibrary
42 import androidx.compose.material.icons.outlined.Circle
43 import androidx.compose.material3.ButtonDefaults
44 import androidx.compose.material3.FilledTonalButton
45 import androidx.compose.material3.Icon
46 import androidx.compose.material3.IconButton
47 import androidx.compose.material3.MaterialTheme
48 import androidx.compose.material3.SnackbarHost
49 import androidx.compose.material3.SnackbarHostState
50 import androidx.compose.material3.Surface
51 import androidx.compose.material3.Text
52 import androidx.compose.material3.TextButton
53 import androidx.compose.runtime.Composable
54 import androidx.compose.runtime.LaunchedEffect
55 import androidx.compose.runtime.getValue
56 import androidx.compose.runtime.mutableStateOf
57 import androidx.compose.runtime.remember
58 import androidx.compose.runtime.rememberCoroutineScope
59 import androidx.compose.runtime.setValue
60 import androidx.compose.ui.Alignment
61 import androidx.compose.ui.Modifier
62 import androidx.compose.ui.focus.focusRequester
63 import androidx.compose.ui.graphics.Color
64 import androidx.compose.ui.graphics.vector.ImageVector
65 import androidx.compose.ui.res.stringResource
66 import androidx.compose.ui.res.vectorResource
67 import androidx.compose.ui.semantics.onClick
68 import androidx.compose.ui.semantics.semantics
69 import androidx.compose.ui.semantics.traversalIndex
70 import androidx.compose.ui.unit.dp
71 import androidx.lifecycle.compose.collectAsStateWithLifecycle
72 import androidx.paging.compose.LazyPagingItems
73 import androidx.paging.compose.collectAsLazyPagingItems
74 import com.android.photopicker.R
75 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
76 import com.android.photopicker.core.configuration.PhotopickerConfiguration
77 import com.android.photopicker.core.events.Event
78 import com.android.photopicker.core.events.Events
79 import com.android.photopicker.core.events.LocalEvents
80 import com.android.photopicker.core.events.Telemetry
81 import com.android.photopicker.core.features.FeatureToken
82 import com.android.photopicker.core.features.Location
83 import com.android.photopicker.core.glide.RESOLUTION_REQUESTED
84 import com.android.photopicker.core.glide.Resolution
85 import com.android.photopicker.core.glide.loadMedia
86 import com.android.photopicker.core.navigation.LocalNavController
87 import com.android.photopicker.core.navigation.PhotopickerDestinations
88 import com.android.photopicker.core.obtainViewModel
89 import com.android.photopicker.core.selection.LocalSelection
90 import com.android.photopicker.core.selection.SelectionStrategy
91 import com.android.photopicker.core.selection.SelectionStrategy.Companion.determineSelectionStrategy
92 import com.android.photopicker.core.theme.CustomAccentColorScheme
93 import com.android.photopicker.core.theme.LocalFixedAccentColors
94 import com.android.photopicker.data.model.Media
95 import com.android.photopicker.extensions.navigateToPreviewSelection
96 import com.android.photopicker.util.HierarchicalFocusCoordinator
97 import com.android.photopicker.util.LocalLocalizationHelper
98 import com.android.photopicker.util.getMediaContentDescription
99 import com.android.photopicker.util.rememberActiveFocusRequester
100 import java.text.DateFormat
101 import kotlinx.coroutines.flow.StateFlow
102 import kotlinx.coroutines.launch
103 
104 /**
105  * Entry point for the [PhotopickerDestinations.PREVIEW_SELECTION] and
106  * [PhotopickerDestinations.PREVIEW_MEDIA]route.
107  *
108  * This composable will snapshot the current selection when created so that photos are not removed
109  * from the list of preview-able photos.
110  */
111 @Composable
112 fun PreviewSelection(
113     viewModel: PreviewViewModel = obtainViewModel(),
114     previewItemFlow: StateFlow<Media?>? = null,
115 ) {
116     val currentSelection by LocalSelection.current.flow.collectAsStateWithLifecycle()
117 
118     val previewSingleItem =
119         when (previewItemFlow) {
120             null -> false
121             else -> true
122         }
123 
124     val selection =
125         when (previewSingleItem) {
126             true -> {
127                 checkNotNull(previewItemFlow) { "Flow cannot be null for previewSingleItem" }
128                 val media by previewItemFlow.collectAsStateWithLifecycle()
129                 val localMedia = media
130                 if (localMedia != null) {
131                     viewModel
132                         .getPreviewMediaIncludingPreGrantedItems(
133                             setOf(localMedia),
134                             LocalPhotopickerConfiguration.current,
135                             /* isSingleItemPreview */ true,
136                         )
137                         .collectAsLazyPagingItems()
138                 } else {
139                     null
140                 }
141             }
142             false -> {
143                 val selectionSnapshot by viewModel.selectionSnapshot.collectAsStateWithLifecycle()
144                 viewModel
145                     .getPreviewMediaIncludingPreGrantedItems(
146                         selectionSnapshot,
147                         LocalPhotopickerConfiguration.current,
148                         /* isSingleItemPreview */ false,
149                     )
150                     .collectAsLazyPagingItems()
151             }
152         }
153 
154     if (selection != null) {
155         val dateFormat =
156             LocalLocalizationHelper.current.getLocalizedDateTimeFormatter(
157                 DateFormat.MEDIUM,
158                 DateFormat.SHORT,
159             )
160         // Only snapshot the selection once when the composable is created.
161         LaunchedEffect(Unit) { viewModel.takeNewSelectionSnapshot() }
162         val navController = LocalNavController.current
163 
164         Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
165             Column(
166                 modifier =
167                     // This is inside an edge-to-edge dialog, so apply padding to ensure the
168                     // UI buttons stay above the navigation bar.
169                     Modifier.windowInsetsPadding(
170                         WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
171                     )
172             ) {
173                 Row(
174                     modifier =
175                         Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 4.dp, start = 8.dp)
176                 ) {
177                     // back button
178                     IconButton(onClick = { navController.popBackStack() }) {
179                         Icon(
180                             imageVector = Icons.AutoMirrored.Filled.ArrowBack,
181                             // For accessibility
182                             contentDescription = stringResource(R.string.photopicker_back_option),
183                             tint = Color.White,
184                         )
185                     }
186                 }
187 
188                 /** SnackbarHost api for launching Snackbars */
189                 val snackbarHostState = remember { SnackbarHostState() }
190 
191                 // Page count equal to size of selection
192                 val state = rememberPagerState { selection.itemCount }
193 
194                 Box(modifier = Modifier.weight(1f)) {
195                     if (selection.itemCount > 0) {
196                         // Add the pager to show the media.
197                         PreviewPager(
198                             Modifier.align(Alignment.Center),
199                             selection,
200                             state,
201                             snackbarHostState,
202                             /* singleItemPreview */ previewSingleItem,
203                             dateFormat,
204                         )
205 
206                         // Only show the selection button if not in single select.
207                         if (LocalPhotopickerConfiguration.current.selectionLimit > 1) {
208                             IconButton(
209                                 modifier = Modifier.align(Alignment.TopStart).padding(start = 8.dp),
210                                 onClick = {
211                                     val media = selection.get(state.currentPage)
212                                     media?.let { viewModel.toggleInSelection(it, {}) }
213                                 },
214                             ) {
215                                 if (currentSelection.contains(selection.get(state.currentPage))) {
216                                     val deselectActionLabel =
217                                         stringResource(
218                                             R.string.photopicker_deselect_action_description
219                                         )
220                                     Icon(
221                                         ImageVector.vectorResource(
222                                             R.drawable.photopicker_selected_media
223                                         ),
224                                         modifier =
225                                             Modifier
226                                                 // Background is necessary because the icon has
227                                                 // negative
228                                                 // space.
229                                                 .background(
230                                                     MaterialTheme.colorScheme.onPrimary,
231                                                     CircleShape,
232                                                 )
233                                                 .semantics {
234                                                     onClick(
235                                                         label = deselectActionLabel,
236                                                         action = null,
237                                                     )
238                                                 },
239                                         contentDescription =
240                                             stringResource(R.string.photopicker_item_selected),
241                                         tint =
242                                             CustomAccentColorScheme.current
243                                                 .getAccentColorIfDefinedOrElse(
244                                                     /* fallback */ MaterialTheme.colorScheme.primary
245                                                 ),
246                                     )
247                                 } else {
248                                     val selectActionLabel =
249                                         stringResource(
250                                             R.string.photopicker_select_action_description
251                                         )
252                                     Icon(
253                                         Icons.Outlined.Circle,
254                                         contentDescription =
255                                             stringResource(R.string.photopicker_item_not_selected),
256                                         tint = Color.White,
257                                         modifier =
258                                             Modifier.semantics {
259                                                 onClick(label = selectActionLabel, action = null)
260                                             },
261                                     )
262                                 }
263                             }
264                         }
265                     }
266                     // Photopicker is (generally) inside of a BottomSheet, and the preview route
267                     // is inside a dialog, so this requires a custom [SnackbarHost] to draw on
268                     // top of those elements that do not play nicely with snackbars. Peace was
269                     // never an option.
270                     SnackbarHost(
271                         snackbarHostState,
272                         modifier = Modifier.align(Alignment.BottomCenter),
273                     )
274                 }
275 
276                 // Bottom row of action buttons
277                 Row(
278                     modifier =
279                         Modifier.fillMaxWidth()
280                             .padding(bottom = 48.dp, start = 4.dp, end = 16.dp, top = 12.dp),
281                     horizontalArrangement = Arrangement.SpaceBetween,
282                 ) {
283                     val config = LocalPhotopickerConfiguration.current
284                     val strategy = remember(config) { determineSelectionStrategy(config) }
285                     if (previewSingleItem || strategy == SelectionStrategy.GRANTS_AWARE_SELECTION) {
286                         Spacer(Modifier.size(8.dp))
287                     } else {
288                         SelectionButton(currentSelection = currentSelection)
289                     }
290 
291                     FilledTonalButton(
292                         onClick = {
293                             if (config.selectionLimit == 1) {
294                                 val media = selection.get(state.currentPage)
295                                 media?.let { viewModel.toggleInSelection(it, {}) }
296                             } else {
297                                 navController.popBackStack()
298                             }
299                         },
300                         colors =
301                             ButtonDefaults.filledTonalButtonColors(
302                                 containerColor =
303                                     CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
304                                         /* fallback */ MaterialTheme.colorScheme.primary
305                                     ),
306                                 contentColor =
307                                     CustomAccentColorScheme.current
308                                         .getTextColorForAccentComponentsIfDefinedOrElse(
309                                             /* fallback */ MaterialTheme.colorScheme.onPrimary
310                                         ),
311                             ),
312                     ) {
313                         Text(
314                             text =
315                                 when (config.selectionLimit) {
316                                     1 ->
317                                         stringResource(
318                                             R.string.photopicker_select_current_button_label
319                                         )
320                                     else -> stringResource(R.string.photopicker_done_button_label)
321                                 }
322                         )
323                     }
324                 }
325             }
326         }
327     }
328 }
329 
330 @Composable
SelectionButtonnull331 private fun SelectionButton(
332     currentSelection: Set<Media>,
333     viewModel: PreviewViewModel = obtainViewModel(),
334 ) {
335     TextButton(
336         onClick = {
337             if (currentSelection.size > 0 && viewModel.selectionSnapshot.value.size > 0) {
338                 // Deselect All in current selection
339                 viewModel.toggleInSelection(currentSelection, {})
340             } else {
341                 // Select All in snapshot
342                 viewModel.toggleInSelection(viewModel.selectionSnapshot.value, {})
343             }
344         },
345         colors =
346             ButtonDefaults.textButtonColors(
347                 contentColor =
348                     // The background color for Preview is always fixed to Black, so when the
349                     // custom accent color is defined, switch to a White color for this button
350                     // so it doesn't clash with the custom color.
351                     if (CustomAccentColorScheme.current.isAccentColorDefined()) Color.White
352                     else LocalFixedAccentColors.current.primaryFixedDim
353             ),
354     ) {
355         val localizationHelper = LocalLocalizationHelper.current
356         if (currentSelection.size > 0) {
357             Icon(ImageVector.vectorResource(R.drawable.tab_close), contentDescription = null)
358             Spacer(Modifier.size(8.dp))
359             Text(
360                 stringResource(
361                     R.string.photopicker_deselect_button_label,
362                     localizationHelper.getLocalizedCount(currentSelection.size),
363                 )
364             )
365         } else {
366             Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
367             Spacer(Modifier.size(8.dp))
368             Text(
369                 stringResource(
370                     R.string.photopicker_select_button_label,
371                     localizationHelper.getLocalizedCount(viewModel.selectionSnapshot.value.size),
372                 )
373             )
374         }
375     }
376 }
377 
378 /**
379  * Composable that creates a [HorizontalPager] and shows items in the provided selection set.
380  *
381  * @param modifier
382  * @param selection selected items that should be included in the pager.
383  * @param state
384  * @param snackbarHostState
385  */
386 @Composable
PreviewPagernull387 private fun PreviewPager(
388     modifier: Modifier,
389     selection: LazyPagingItems<Media>,
390     state: PagerState,
391     snackbarHostState: SnackbarHostState,
392     singleItemPreview: Boolean,
393     dateFormat: DateFormat,
394 ) {
395     // Preview session state to keep track if the video player's audio is muted.
396     var audioIsMuted by remember { mutableStateOf(true) }
397 
398     HorizontalPager(
399         state = state,
400         modifier = modifier.semantics(mergeDescendants = true) { traversalIndex = -1f },
401     ) { page ->
402         HierarchicalFocusCoordinator(requiresFocus = { state.currentPage == page }) {
403             val focusRequester = rememberActiveFocusRequester()
404             val media = selection.get(page)
405             if (media != null) {
406                 Box(modifier = Modifier.focusRequester(focusRequester).focusable(true)) {
407                     val pageDescription =
408                         stringResource(
409                             R.string.pohtopicker_horizontal_pager_description,
410                             state.currentPage + 1,
411                             state.pageCount,
412                         )
413                     val mediaDescription = getMediaContentDescription(media, dateFormat)
414                     val contentDescription = mediaDescription + pageDescription
415                     when (media) {
416                         is Media.Image -> ImageUi(media, singleItemPreview, contentDescription)
417                         is Media.Video ->
418                             VideoUi(
419                                 media,
420                                 audioIsMuted,
421                                 { audioIsMuted = it },
422                                 snackbarHostState,
423                                 singleItemPreview,
424                                 contentDescription,
425                             )
426                     }
427                 }
428             }
429         }
430     }
431 }
432 
433 /**
434  * Composable that loads a [Media.Image] in [Resolution.FULL] for the user to preview.
435  *
436  * @param image
437  */
438 @Composable
ImageUinull439 private fun ImageUi(image: Media.Image, singleItemPreview: Boolean, contentDescription: String) {
440     if (singleItemPreview) {
441         val events = LocalEvents.current
442         val scope = rememberCoroutineScope()
443         val configuration = LocalPhotopickerConfiguration.current
444 
445         scope.launch {
446             val mediaType =
447                 if (image.mimeType.contains("gif")) {
448                     Telemetry.MediaType.GIF
449                 } else {
450                     Telemetry.MediaType.PHOTO
451                 }
452             // Mark entry into preview mode by long pressing on the media item
453             events.dispatch(
454                 Event.LogPhotopickerPreviewInfo(
455                     FeatureToken.PREVIEW.token,
456                     configuration.sessionId,
457                     Telemetry.PreviewModeEntry.LONG_PRESS,
458                     previewItemCount = 1,
459                     mediaType,
460                     Telemetry.VideoPlayBackInteractions.UNSET_VIDEO_PLAYBACK_INTERACTION,
461                 )
462             )
463         }
464     }
465     loadMedia(
466         media = image,
467         resolution = Resolution.FULL,
468         modifier = Modifier.fillMaxSize(),
469         contentDescription = contentDescription,
470         // by default loadMedia center crops, so use a custom request builder
471         requestBuilderTransformation = { media, resolution, builder ->
472             builder.set(RESOLUTION_REQUESTED, resolution).signature(media.getSignature(resolution))
473         },
474     )
475 }
476 
477 /**
478  * Composable for [Location.SELECTION_BAR_SECONDARY_ACTION] Creates a button that launches the
479  * [PhotopickerDestinations.PREVIEW_SELECTION] route.
480  */
481 @Composable
PreviewSelectionButtonnull482 fun PreviewSelectionButton(modifier: Modifier) {
483     val navController = LocalNavController.current
484     val events = LocalEvents.current
485     val scope = rememberCoroutineScope()
486     // TODO(b/353659535): Use Selection.size api when available
487     val currentSelection by LocalSelection.current.flow.collectAsStateWithLifecycle()
488     val previewItemCount = currentSelection.size
489     val configuration = LocalPhotopickerConfiguration.current
490     if (currentSelection.isNotEmpty()) {
491         TextButton(
492             onClick = {
493                 scope.launch {
494                     logPreviewSelectionButtonClicked(configuration, previewItemCount, events)
495                 }
496                 navController.navigateToPreviewSelection()
497             },
498             modifier = modifier,
499         ) {
500             Text(
501                 stringResource(R.string.photopicker_preview_button_label),
502                 color =
503                     CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
504                         /* fallback */ MaterialTheme.colorScheme.primary
505                     ),
506             )
507         }
508     }
509 }
510 
511 /**
512  * Dispatches all the relevant logging events for the picker's preview mode when the Preview button
513  * is clicked
514  */
logPreviewSelectionButtonClickednull515 private suspend fun logPreviewSelectionButtonClicked(
516     configuration: PhotopickerConfiguration,
517     previewItemCount: Int,
518     events: Events,
519 ) {
520     // Log preview item details
521     events.dispatch(
522         Event.LogPhotopickerPreviewInfo(
523             FeatureToken.PREVIEW.token,
524             configuration.sessionId,
525             Telemetry.PreviewModeEntry.VIEW_SELECTED,
526             previewItemCount,
527             Telemetry.MediaType.UNSET_MEDIA_TYPE,
528             Telemetry.VideoPlayBackInteractions.UNSET_VIDEO_PLAYBACK_INTERACTION,
529         )
530     )
531 
532     // Log preview related UI events including clicking the 'preview' button
533     events.dispatch(
534         Event.LogPhotopickerUIEvent(
535             FeatureToken.PREVIEW.token,
536             configuration.sessionId,
537             configuration.callingPackageUid ?: -1,
538             Telemetry.UiEvent.ENTER_PICKER_PREVIEW_MODE,
539         )
540     )
541 
542     events.dispatch(
543         Event.LogPhotopickerUIEvent(
544             FeatureToken.PREVIEW.token,
545             configuration.sessionId,
546             configuration.callingPackageUid ?: -1,
547             Telemetry.UiEvent.PICKER_CLICK_VIEW_SELECTED,
548         )
549     )
550 }
551