• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 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.intentresolver.widget
18 
19 import android.content.Context
20 import android.graphics.Bitmap
21 import android.graphics.Rect
22 import android.net.Uri
23 import android.util.AttributeSet
24 import android.util.PluralsMessageFormatter
25 import android.util.TypedValue
26 import android.view.LayoutInflater
27 import android.view.View
28 import android.view.ViewGroup
29 import android.widget.ImageView
30 import android.widget.TextView
31 import androidx.annotation.VisibleForTesting
32 import androidx.constraintlayout.widget.ConstraintLayout
33 import androidx.core.view.ViewCompat
34 import androidx.recyclerview.widget.LinearLayoutManager
35 import androidx.recyclerview.widget.RecyclerView
36 import com.android.intentresolver.R
37 import com.android.intentresolver.util.throttle
38 import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
39 import kotlinx.coroutines.CoroutineScope
40 import kotlinx.coroutines.Dispatchers
41 import kotlinx.coroutines.Job
42 import kotlinx.coroutines.cancel
43 import kotlinx.coroutines.flow.Flow
44 import kotlinx.coroutines.flow.MutableSharedFlow
45 import kotlinx.coroutines.flow.takeWhile
46 import kotlinx.coroutines.joinAll
47 import kotlinx.coroutines.launch
48 
49 private const val TRANSITION_NAME = "screenshot_preview_image"
50 private const val PLURALS_COUNT = "count"
51 private const val ADAPTER_UPDATE_INTERVAL_MS = 150L
52 private const val MIN_ASPECT_RATIO = 0.4f
53 private const val MIN_ASPECT_RATIO_STRING = "2:5"
54 private const val MAX_ASPECT_RATIO = 2.5f
55 private const val MAX_ASPECT_RATIO_STRING = "5:2"
56 
57 private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap?
58 
59 class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
60     constructor(context: Context) : this(context, null)
61     constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
62     constructor(
63         context: Context,
64         attrs: AttributeSet?,
65         defStyleAttr: Int
66     ) : super(context, attrs, defStyleAttr) {
67         layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
68         adapter = Adapter(context)
69 
70         context
71             .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0)
72             .use { a ->
73                 var innerSpacing =
74                     a.getDimensionPixelSize(
75                         R.styleable.ScrollableImagePreviewView_itemInnerSpacing,
76                         -1
77                     )
78                 if (innerSpacing < 0) {
79                     innerSpacing =
80                         TypedValue.applyDimension(
81                                 TypedValue.COMPLEX_UNIT_DIP,
82                                 3f,
83                                 context.resources.displayMetrics
84                             )
85                             .toInt()
86                 }
87                 outerSpacing =
88                     a.getDimensionPixelSize(
89                         R.styleable.ScrollableImagePreviewView_itemOuterSpacing,
90                         -1
91                     )
92                 if (outerSpacing < 0) {
93                     outerSpacing =
94                         TypedValue.applyDimension(
95                                 TypedValue.COMPLEX_UNIT_DIP,
96                                 16f,
97                                 context.resources.displayMetrics
98                             )
99                             .toInt()
100                 }
101                 addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
102 
103                 maxWidthHint =
104                     a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1)
105             }
106     }
107 
108     private var batchLoader: BatchPreviewLoader? = null
109     private val previewAdapter
110         get() = adapter as Adapter
111 
112     /**
113      * A hint about the maximum width this view can grow to, this helps to optimize preview loading.
114      */
115     var maxWidthHint: Int = -1
116     private var requestedHeight: Int = 0
117     private var isMeasured = false
118     private var maxAspectRatio = MAX_ASPECT_RATIO
119     private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING
120     private var outerSpacing: Int = 0
121 
122     override fun onMeasure(widthSpec: Int, heightSpec: Int) {
123         super.onMeasure(widthSpec, heightSpec)
124         if (!isMeasured) {
125             isMeasured = true
126             updateMaxWidthHint(widthSpec)
127             updateMaxAspectRatio()
128             maybeLoadAspectRatios()
129         }
130     }
131 
132     private fun updateMaxWidthHint(widthSpec: Int) {
133         if (maxWidthHint > 0) return
134         if (View.MeasureSpec.getMode(widthSpec) != View.MeasureSpec.UNSPECIFIED) {
135             maxWidthHint = View.MeasureSpec.getSize(widthSpec)
136         }
137     }
138 
139     override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
140         super.onLayout(changed, l, t, r, b)
141         setOverScrollMode(
142             if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS
143         )
144     }
145 
146     override fun onAttachedToWindow() {
147         super.onAttachedToWindow()
148         batchLoader?.totalItemCount?.let(previewAdapter::reset)
149         maybeLoadAspectRatios()
150     }
151 
152     override fun onDetachedFromWindow() {
153         batchLoader?.cancel()
154         super.onDetachedFromWindow()
155     }
156 
157     override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
158         previewAdapter.transitionStatusElementCallback = callback
159     }
160 
161     override fun getTransitionView(): View? {
162         for (i in 0 until childCount) {
163             val child = getChildAt(i)
164             val vh = getChildViewHolder(child)
165             if (vh is PreviewViewHolder && vh.image.transitionName != null) return child
166         }
167         return null
168     }
169 
170     fun setImageLoader(imageLoader: CachingImageLoader) {
171         previewAdapter.imageLoader = imageLoader
172     }
173 
174     fun setLoading(totalItemCount: Int) {
175         previewAdapter.reset(totalItemCount)
176     }
177 
178     fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) {
179         previewAdapter.reset(totalItemCount)
180         batchLoader?.cancel()
181         batchLoader =
182             BatchPreviewLoader(
183                 previewAdapter.imageLoader ?: error("Image loader is not set"),
184                 previews,
185                 totalItemCount,
186                 onUpdate = previewAdapter::addPreviews,
187                 onCompletion = {
188                     batchLoader = null
189                     if (!previewAdapter.hasPreviews) {
190                         onNoPreviewCallback?.run()
191                     }
192                     previewAdapter.markLoaded()
193                 }
194             )
195         maybeLoadAspectRatios()
196     }
197 
198     private fun maybeLoadAspectRatios() {
199         if (isMeasured && isAttachedToWindow()) {
200             batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) }
201         }
202     }
203 
204     var onNoPreviewCallback: Runnable? = null
205 
206     private fun getMaxWidth(): Int =
207         when {
208             maxWidthHint > 0 -> maxWidthHint
209             isLaidOut -> width
210             else -> measuredWidth
211         }
212 
213     private fun updateMaxAspectRatio() {
214         val padding = outerSpacing * 2
215         val w = maxOf(padding, getMaxWidth() - padding)
216         val h = if (isLaidOut) height else measuredHeight
217         if (w > 0 && h > 0) {
218             maxAspectRatio =
219                 (w.toFloat() / h.toFloat()).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
220             maxAspectRatioString =
221                 when {
222                     maxAspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING
223                     maxAspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING
224                     else -> "$w:$h"
225                 }
226         }
227     }
228 
229     /**
230      * Sets [preview]'s aspect ratio based on the preview image size.
231      *
232      * @return adjusted preview width
233      */
234     private fun updatePreviewSize(preview: Preview, width: Int, height: Int): Int {
235         val effectiveHeight = if (isLaidOut) height else measuredHeight
236         return if (width <= 0 || height <= 0) {
237             preview.aspectRatioString = "1:1"
238             effectiveHeight
239         } else {
240             val aspectRatio =
241                 (width.toFloat() / height.toFloat()).coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
242             preview.aspectRatioString =
243                 when {
244                     aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING
245                     aspectRatio >= maxAspectRatio -> maxAspectRatioString
246                     else -> "$width:$height"
247                 }
248             (effectiveHeight * aspectRatio).toInt()
249         }
250     }
251 
252     class Preview
253     internal constructor(
254         val type: PreviewType,
255         val uri: Uri,
256         val editAction: Runnable?,
257         internal var aspectRatioString: String
258     ) {
259         constructor(
260             type: PreviewType,
261             uri: Uri,
262             editAction: Runnable?
263         ) : this(type, uri, editAction, "1:1")
264     }
265 
266     enum class PreviewType {
267         Image,
268         Video,
269         File
270     }
271 
272     private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() {
273         private val previews = ArrayList<Preview>()
274         private val imagePreviewDescription =
275             context.resources.getString(R.string.image_preview_a11y_description)
276         private val videoPreviewDescription =
277             context.resources.getString(R.string.video_preview_a11y_description)
278         private val filePreviewDescription =
279             context.resources.getString(R.string.file_preview_a11y_description)
280         var imageLoader: CachingImageLoader? = null
281         private var firstImagePos = -1
282         private var totalItemCount: Int = 0
283 
284         private var isLoading = false
285         private val hasOtherItem
286             get() = previews.size < totalItemCount
287         val hasPreviews: Boolean
288             get() = previews.isNotEmpty()
289 
290         var transitionStatusElementCallback: TransitionElementStatusCallback? = null
291 
292         fun reset(totalItemCount: Int) {
293             firstImagePos = -1
294             previews.clear()
295             this.totalItemCount = maxOf(0, totalItemCount)
296             isLoading = this.totalItemCount > 0
297             notifyDataSetChanged()
298         }
299 
300         fun markLoaded() {
301             if (!isLoading) return
302             isLoading = false
303             if (hasOtherItem) {
304                 notifyItemChanged(previews.size)
305             } else {
306                 notifyItemRemoved(previews.size)
307             }
308         }
309 
310         fun addPreviews(newPreviews: Collection<Preview>) {
311             if (newPreviews.isEmpty()) return
312             val insertPos = previews.size
313             val hadOtherItem = hasOtherItem
314             val wasEmpty = previews.isEmpty()
315             previews.addAll(newPreviews)
316             if (firstImagePos < 0) {
317                 val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image }
318                 if (pos >= 0) firstImagePos = insertPos + pos
319             }
320             if (wasEmpty) {
321                 // we don't want any item animation in that case
322                 notifyDataSetChanged()
323             } else {
324                 notifyItemRangeInserted(insertPos, newPreviews.size)
325                 when {
326                     hadOtherItem && !hasOtherItem -> {
327                         notifyItemRemoved(previews.size)
328                     }
329                     !hadOtherItem && hasOtherItem -> {
330                         notifyItemInserted(previews.size)
331                     }
332                     else -> notifyItemChanged(previews.size)
333                 }
334             }
335         }
336 
337         override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
338             val view = LayoutInflater.from(context).inflate(itemType, parent, false)
339             return when (itemType) {
340                 R.layout.image_preview_other_item -> OtherItemViewHolder(view)
341                 R.layout.image_preview_loading_item -> LoadingItemViewHolder(view)
342                 else ->
343                     PreviewViewHolder(
344                         view,
345                         imagePreviewDescription,
346                         videoPreviewDescription,
347                         filePreviewDescription,
348                     )
349             }
350         }
351 
352         override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0
353 
354         override fun getItemViewType(position: Int): Int =
355             when {
356                 position == previews.size && isLoading -> R.layout.image_preview_loading_item
357                 position == previews.size -> R.layout.image_preview_other_item
358                 else -> R.layout.image_preview_image_item
359             }
360 
361         override fun onBindViewHolder(vh: ViewHolder, position: Int) {
362             when (vh) {
363                 is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size)
364                 is LoadingItemViewHolder -> vh.bind()
365                 is PreviewViewHolder ->
366                     vh.bind(
367                         previews[position],
368                         imageLoader ?: error("ImageLoader is missing"),
369                         isSharedTransitionElement = position == firstImagePos,
370                         previewReadyCallback =
371                             if (
372                                 position == firstImagePos && transitionStatusElementCallback != null
373                             ) {
374                                 this::onTransitionElementReady
375                             } else {
376                                 null
377                             }
378                     )
379             }
380         }
381 
382         override fun onViewRecycled(vh: ViewHolder) {
383             vh.unbind()
384         }
385 
386         override fun onFailedToRecycleView(vh: ViewHolder): Boolean {
387             vh.unbind()
388             return super.onFailedToRecycleView(vh)
389         }
390 
391         private fun onTransitionElementReady(name: String) {
392             transitionStatusElementCallback?.apply {
393                 onTransitionElementReady(name)
394                 onAllTransitionElementsReady()
395             }
396             transitionStatusElementCallback = null
397         }
398     }
399 
400     private sealed class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
401         abstract fun unbind()
402     }
403 
404     private class PreviewViewHolder(
405         view: View,
406         private val imagePreviewDescription: String,
407         private val videoPreviewDescription: String,
408         private val filePreviewDescription: String,
409     ) : ViewHolder(view) {
410         val image = view.requireViewById<ImageView>(R.id.image)
411         private val badgeFrame = view.requireViewById<View>(R.id.badge_frame)
412         private val badge = view.requireViewById<ImageView>(R.id.badge)
413         private val editActionContainer = view.findViewById<View?>(R.id.edit)
414         private var scope: CoroutineScope? = null
415 
416         fun bind(
417             preview: Preview,
418             imageLoader: CachingImageLoader,
419             isSharedTransitionElement: Boolean,
420             previewReadyCallback: ((String) -> Unit)?
421         ) {
422             image.setImageDrawable(null)
423             (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params ->
424                 params.dimensionRatio = preview.aspectRatioString
425             }
426             image.transitionName =
427                 if (isSharedTransitionElement) {
428                     TRANSITION_NAME
429                 } else {
430                     null
431                 }
432             when (preview.type) {
433                 PreviewType.Image -> {
434                     itemView.contentDescription = imagePreviewDescription
435                     badgeFrame.visibility = View.GONE
436                 }
437                 PreviewType.Video -> {
438                     itemView.contentDescription = videoPreviewDescription
439                     badge.setImageResource(R.drawable.ic_file_video)
440                     badgeFrame.visibility = View.VISIBLE
441                 }
442                 else -> {
443                     itemView.contentDescription = filePreviewDescription
444                     badge.setImageResource(R.drawable.chooser_file_generic)
445                     badgeFrame.visibility = View.VISIBLE
446                 }
447             }
448             preview.editAction?.also { onClick ->
449                 editActionContainer?.apply {
450                     setOnClickListener { onClick.run() }
451                     visibility = View.VISIBLE
452                 }
453             }
454             resetScope().launch {
455                 loadImage(preview, imageLoader)
456                 if (preview.type == PreviewType.Image) {
457                     previewReadyCallback?.let { callback ->
458                         image.waitForPreDraw()
459                         callback(TRANSITION_NAME)
460                     }
461                 }
462             }
463         }
464 
465         private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) {
466             val bitmap =
467                 runCatching {
468                         // it's expected for all loading/caching optimizations to be implemented by
469                         // the loader
470                         imageLoader(preview.uri, true)
471                     }
472                     .getOrNull()
473             image.setImageBitmap(bitmap)
474         }
475 
476         private fun resetScope(): CoroutineScope =
477             CoroutineScope(Dispatchers.Main.immediate).also {
478                 scope?.cancel()
479                 scope = it
480             }
481 
482         override fun unbind() {
483             scope?.cancel()
484             scope = null
485         }
486     }
487 
488     private class OtherItemViewHolder(view: View) : ViewHolder(view) {
489         private val label = view.requireViewById<TextView>(R.id.label)
490 
491         fun bind(count: Int) {
492             label.text =
493                 PluralsMessageFormatter.format(
494                     itemView.context.resources,
495                     mapOf(PLURALS_COUNT to count),
496                     R.string.other_files
497                 )
498         }
499 
500         override fun unbind() = Unit
501     }
502 
503     private class LoadingItemViewHolder(view: View) : ViewHolder(view) {
504         fun bind() = Unit
505         override fun unbind() = Unit
506     }
507 
508     private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) :
509         ItemDecoration() {
510         override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
511             val itemCount = parent.adapter?.itemCount ?: return
512             val pos = parent.getChildAdapterPosition(view)
513             var startMargin = if (pos == 0) outerSpacing else innerSpacing
514             var endMargin = if (pos == itemCount - 1) outerSpacing else 0
515 
516             if (ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL) {
517                 outRect.set(endMargin, 0, startMargin, 0)
518             } else {
519                 outRect.set(startMargin, 0, endMargin, 0)
520             }
521         }
522     }
523 
524     @VisibleForTesting
525     class BatchPreviewLoader(
526         private val imageLoader: CachingImageLoader,
527         private val previews: Flow<Preview>,
528         val totalItemCount: Int,
529         private val onUpdate: (List<Preview>) -> Unit,
530         private val onCompletion: () -> Unit,
531     ) {
532         private var scope: CoroutineScope = createScope()
533 
534         private fun createScope() = CoroutineScope(Dispatchers.Main.immediate)
535 
536         fun cancel() {
537             scope.cancel()
538             scope = createScope()
539         }
540 
541         fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) {
542             val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount)
543             var blockStart = 0 // inclusive
544             var blockEnd = 0 // exclusive
545 
546             // replay 2 items to guarantee that we'd get at least one update
547             val reportFlow = MutableSharedFlow<Any>(replay = 2)
548             val updateEvent = Any()
549             val completedEvent = Any()
550 
551             // collects updates from [reportFlow] throttling adapter updates;
552             scope.launch(Dispatchers.Main) {
553                 reportFlow
554                     .takeWhile { it !== completedEvent }
555                     .throttle(ADAPTER_UPDATE_INTERVAL_MS)
556                     .collect {
557                         val updates = ArrayList<Preview>(blockEnd - blockStart)
558                         while (blockStart < blockEnd) {
559                             if (previewInfos[blockStart].width > 0) {
560                                 updates.add(previewInfos[blockStart].preview)
561                             }
562                             blockStart++
563                         }
564                         if (updates.isNotEmpty()) {
565                             onUpdate(updates)
566                         }
567                     }
568                 onCompletion()
569             }
570 
571             // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow]
572             // when a next sequential block of preview aspect ratios is loaded: initially emits when
573             // enough preview elements is loaded to fill the viewport.
574             scope.launch {
575                 var blockWidth = 0
576                 var isFirstBlock = true
577 
578                 val jobs = ArrayList<Job>()
579                 previews.collect { preview ->
580                     val i = previewInfos.size
581                     val pair = PreviewWidthInfo(preview)
582                     previewInfos.add(pair)
583 
584                     val job = launch {
585                         pair.width =
586                             runCatching {
587                                     // TODO: decide on adding a timeout. The worst case I can
588                                     //  imagine is one of the first images never loads so we never
589                                     //  fill the initial viewport and does not show the previews at
590                                     //  all.
591                                     imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
592                                         previewSizeUpdater(preview, bitmap.width, bitmap.height)
593                                     }
594                                         ?: 0
595                                 }
596                                 .getOrDefault(0)
597 
598                         if (i == blockEnd) {
599                             while (
600                                 blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0
601                             ) {
602                                 blockWidth += previewInfos[blockEnd].width
603                                 blockEnd++
604                             }
605                             if (isFirstBlock && blockWidth >= maxWidth) {
606                                 isFirstBlock = false
607                             }
608                             if (!isFirstBlock) {
609                                 reportFlow.emit(updateEvent)
610                             }
611                         }
612                     }
613                     jobs.add(job)
614                 }
615                 jobs.joinAll()
616                 // in case all previews have failed to load
617                 reportFlow.emit(updateEvent)
618                 reportFlow.emit(completedEvent)
619             }
620         }
621     }
622 
623     private class PreviewWidthInfo(val preview: Preview) {
624         // -1 encodes that the preview has not been processed,
625         // 0 means failed, > 0 is a preview width
626         var width: Int = -1
627     }
628 }
629