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.Size
26 import android.util.TypedValue
27 import android.view.LayoutInflater
28 import android.view.View
29 import android.view.ViewGroup
30 import android.view.animation.AlphaAnimation
31 import android.view.animation.Animation
32 import android.view.animation.Animation.AnimationListener
33 import android.view.animation.DecelerateInterpolator
34 import android.widget.ImageView
35 import android.widget.TextView
36 import androidx.annotation.VisibleForTesting
37 import androidx.constraintlayout.widget.ConstraintLayout
38 import androidx.core.view.ViewCompat
39 import androidx.recyclerview.widget.DefaultItemAnimator
40 import androidx.recyclerview.widget.LinearLayoutManager
41 import androidx.recyclerview.widget.RecyclerView
42 import com.android.intentresolver.R
43 import com.android.intentresolver.util.throttle
44 import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
45 import kotlinx.coroutines.CoroutineScope
46 import kotlinx.coroutines.Dispatchers
47 import kotlinx.coroutines.Job
48 import kotlinx.coroutines.cancel
49 import kotlinx.coroutines.flow.Flow
50 import kotlinx.coroutines.flow.MutableSharedFlow
51 import kotlinx.coroutines.flow.takeWhile
52 import kotlinx.coroutines.joinAll
53 import kotlinx.coroutines.launch
54 import kotlinx.coroutines.suspendCancellableCoroutine
55 
56 private const val TRANSITION_NAME = "screenshot_preview_image"
57 private const val PLURALS_COUNT = "count"
58 private const val ADAPTER_UPDATE_INTERVAL_MS = 150L
59 private const val MIN_ASPECT_RATIO = 0.4f
60 private const val MIN_ASPECT_RATIO_STRING = "2:5"
61 private const val MAX_ASPECT_RATIO = 2.5f
62 private const val MAX_ASPECT_RATIO_STRING = "5:2"
63 
64 private typealias CachingImageLoader = suspend (Uri, Size, Boolean) -> Bitmap?
65 
66 class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
67     constructor(context: Context) : this(context, null)
68 
69     constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
70 
71     constructor(
72         context: Context,
73         attrs: AttributeSet?,
74         defStyleAttr: Int,
75     ) : super(context, attrs, defStyleAttr) {
76         layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
77 
78         val editButtonRoleDescription: CharSequence?
79         context
80             .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0)
81             .use { a ->
82                 var innerSpacing =
83                     a.getDimensionPixelSize(
84                         R.styleable.ScrollableImagePreviewView_itemInnerSpacing,
85                         -1,
86                     )
87                 if (innerSpacing < 0) {
88                     innerSpacing =
89                         TypedValue.applyDimension(
90                                 TypedValue.COMPLEX_UNIT_DIP,
91                                 3f,
92                                 context.resources.displayMetrics,
93                             )
94                             .toInt()
95                 }
96                 outerSpacing =
97                     a.getDimensionPixelSize(
98                         R.styleable.ScrollableImagePreviewView_itemOuterSpacing,
99                         -1,
100                     )
101                 if (outerSpacing < 0) {
102                     outerSpacing =
103                         TypedValue.applyDimension(
104                                 TypedValue.COMPLEX_UNIT_DIP,
105                                 16f,
106                                 context.resources.displayMetrics,
107                             )
108                             .toInt()
109                 }
110                 super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing))
111 
112                 maxWidthHint =
113                     a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1)
114 
115                 editButtonRoleDescription =
116                     a.getText(R.styleable.ScrollableImagePreviewView_editButtonRoleDescription)
117             }
118         val itemAnimator = ItemAnimator()
119         super.setItemAnimator(itemAnimator)
120         super.setAdapter(Adapter(context, itemAnimator.getAddDuration(), editButtonRoleDescription))
121     }
122 
123     private var batchLoader: BatchPreviewLoader? = null
124     private val previewAdapter
125         get() = adapter as Adapter
126 
127     /**
128      * A hint about the maximum width this view can grow to, this helps to optimize preview loading.
129      */
130     var maxWidthHint: Int = -1
131 
132     private var isMeasured = false
133     private var maxAspectRatio = MAX_ASPECT_RATIO
134     private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING
135     private var outerSpacing: Int = 0
136 
137     var previewHeight: Int
138         get() = previewAdapter.previewHeight
139         set(value) {
140             previewAdapter.previewHeight = value
141         }
142 
143     override fun onMeasure(widthSpec: Int, heightSpec: Int) {
144         super.onMeasure(widthSpec, heightSpec)
145         if (!isMeasured) {
146             isMeasured = true
147             updateMaxWidthHint(widthSpec)
148             updateMaxAspectRatio()
149             maybeLoadAspectRatios()
150         }
151     }
152 
153     private fun updateMaxWidthHint(widthSpec: Int) {
154         if (maxWidthHint > 0) return
155         if (View.MeasureSpec.getMode(widthSpec) != View.MeasureSpec.UNSPECIFIED) {
156             maxWidthHint = View.MeasureSpec.getSize(widthSpec)
157         }
158     }
159 
160     override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
161         super.onLayout(changed, l, t, r, b)
162         setOverScrollMode(
163             if (areAllChildrenVisible) View.OVER_SCROLL_NEVER else View.OVER_SCROLL_ALWAYS
164         )
165     }
166 
167     override fun onAttachedToWindow() {
168         super.onAttachedToWindow()
169         batchLoader?.totalItemCount?.let(previewAdapter::reset)
170         maybeLoadAspectRatios()
171     }
172 
173     override fun onDetachedFromWindow() {
174         batchLoader?.cancel()
175         super.onDetachedFromWindow()
176     }
177 
178     override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
179         previewAdapter.transitionStatusElementCallback = callback
180     }
181 
182     override fun getTransitionView(): View? {
183         for (i in 0 until childCount) {
184             val child = getChildAt(i)
185             val vh = getChildViewHolder(child)
186             if (vh is PreviewViewHolder && vh.image.transitionName != null) return child
187         }
188         return null
189     }
190 
191     override fun setAdapter(adapter: RecyclerView.Adapter<*>?) {
192         error("This method is not supported")
193     }
194 
195     override fun setItemAnimator(animator: RecyclerView.ItemAnimator?) {
196         error("This method is not supported")
197     }
198 
199     fun setImageLoader(imageLoader: CachingImageLoader) {
200         previewAdapter.imageLoader = imageLoader
201     }
202 
203     fun setLoading(totalItemCount: Int) {
204         previewAdapter.reset(totalItemCount)
205     }
206 
207     fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) {
208         previewAdapter.reset(totalItemCount)
209         batchLoader?.cancel()
210         batchLoader =
211             BatchPreviewLoader(
212                 previewAdapter.imageLoader ?: error("Image loader is not set"),
213                 previews,
214                 Size(previewHeight, previewHeight),
215                 totalItemCount,
216                 onUpdate = previewAdapter::addPreviews,
217                 onCompletion = {
218                     batchLoader = null
219                     if (!previewAdapter.hasPreviews) {
220                         onNoPreviewCallback?.run()
221                     }
222                     previewAdapter.markLoaded()
223                 },
224             )
225         maybeLoadAspectRatios()
226     }
227 
228     private fun maybeLoadAspectRatios() {
229         if (isMeasured && isAttachedToWindow()) {
230             batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) }
231         }
232     }
233 
234     var onNoPreviewCallback: Runnable? = null
235 
236     private fun getMaxWidth(): Int =
237         when {
238             maxWidthHint > 0 -> maxWidthHint
239             isLaidOut -> width
240             else -> measuredWidth
241         }
242 
243     private fun updateMaxAspectRatio() {
244         val padding = outerSpacing * 2
245         val w = maxOf(padding, getMaxWidth() - padding)
246         val h = if (isLaidOut) height else measuredHeight
247         if (w > 0 && h > 0) {
248             maxAspectRatio =
249                 (w.toFloat() / h.toFloat()).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
250             maxAspectRatioString =
251                 when {
252                     maxAspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING
253                     maxAspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING
254                     else -> "$w:$h"
255                 }
256         }
257     }
258 
259     /**
260      * Sets [preview]'s aspect ratio based on the preview image size.
261      *
262      * @return adjusted preview width
263      */
264     private fun updatePreviewSize(preview: Preview, width: Int, height: Int): Int {
265         val effectiveHeight = if (isLaidOut) height else measuredHeight
266         return if (width <= 0 || height <= 0) {
267             preview.aspectRatioString = "1:1"
268             effectiveHeight
269         } else {
270             val aspectRatio =
271                 (width.toFloat() / height.toFloat()).coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
272             preview.aspectRatioString =
273                 when {
274                     aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING
275                     aspectRatio >= maxAspectRatio -> maxAspectRatioString
276                     else -> "$width:$height"
277                 }
278             (effectiveHeight * aspectRatio).toInt()
279         }
280     }
281 
282     class Preview
283     internal constructor(
284         val type: PreviewType,
285         val uri: Uri,
286         val editAction: Runnable?,
287         internal var aspectRatioString: String,
288     ) {
289         constructor(
290             type: PreviewType,
291             uri: Uri,
292             editAction: Runnable?,
293         ) : this(type, uri, editAction, "1:1")
294     }
295 
296     enum class PreviewType {
297         Image,
298         Video,
299         File,
300     }
301 
302     private class Adapter(
303         private val context: Context,
304         private val fadeInDurationMs: Long,
305         private val editButtonRoleDescription: CharSequence?,
306     ) : RecyclerView.Adapter<ViewHolder>() {
307         private val previews = ArrayList<Preview>()
308         private val imagePreviewDescription =
309             context.resources.getString(R.string.image_preview_a11y_description)
310         private val videoPreviewDescription =
311             context.resources.getString(R.string.video_preview_a11y_description)
312         private val filePreviewDescription =
313             context.resources.getString(R.string.file_preview_a11y_description)
314         var imageLoader: CachingImageLoader? = null
315         private var firstImagePos = -1
316         private var totalItemCount: Int = 0
317 
318         private var isLoading = false
319         private val hasOtherItem
320             get() = previews.size < totalItemCount
321 
322         val hasPreviews: Boolean
323             get() = previews.isNotEmpty()
324 
325         var transitionStatusElementCallback: TransitionElementStatusCallback? = null
326 
327         private var previewSize: Size = Size(0, 0)
328         var previewHeight: Int
329             get() = previewSize.height
330             set(value) {
331                 previewSize = Size(value, value)
332             }
333 
334         fun reset(totalItemCount: Int) {
335             firstImagePos = -1
336             previews.clear()
337             this.totalItemCount = maxOf(0, totalItemCount)
338             isLoading = this.totalItemCount > 0
339             notifyDataSetChanged()
340         }
341 
342         fun markLoaded() {
343             if (!isLoading) return
344             isLoading = false
345             if (hasOtherItem) {
346                 notifyItemChanged(previews.size)
347             } else {
348                 notifyItemRemoved(previews.size)
349             }
350         }
351 
352         fun addPreviews(newPreviews: Collection<Preview>) {
353             if (newPreviews.isEmpty()) return
354             val insertPos = previews.size
355             val hadOtherItem = hasOtherItem
356             val oldItemCount = getItemCount()
357             previews.addAll(newPreviews)
358             if (firstImagePos < 0) {
359                 val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image }
360                 if (pos >= 0) firstImagePos = insertPos + pos
361             }
362             if (insertPos == 0) {
363                 if (oldItemCount > 0) {
364                     notifyItemRangeRemoved(0, oldItemCount)
365                 }
366                 notifyItemRangeInserted(insertPos, getItemCount())
367             } else {
368                 notifyItemRangeInserted(insertPos, newPreviews.size)
369                 when {
370                     hadOtherItem && !hasOtherItem -> {
371                         notifyItemRemoved(previews.size)
372                     }
373                     !hadOtherItem && hasOtherItem -> {
374                         notifyItemInserted(previews.size)
375                     }
376                     else -> notifyItemChanged(previews.size)
377                 }
378             }
379         }
380 
381         override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder {
382             val view = LayoutInflater.from(context).inflate(itemType, parent, false)
383             return when (itemType) {
384                 R.layout.image_preview_other_item -> OtherItemViewHolder(view)
385                 R.layout.image_preview_loading_item -> LoadingItemViewHolder(view)
386                 else ->
387                     PreviewViewHolder(
388                         view,
389                         imagePreviewDescription,
390                         videoPreviewDescription,
391                         filePreviewDescription,
392                     )
393             }
394         }
395 
396         override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0
397 
398         override fun getItemViewType(position: Int): Int =
399             when {
400                 position == previews.size && isLoading -> R.layout.image_preview_loading_item
401                 position == previews.size -> R.layout.image_preview_other_item
402                 else -> R.layout.image_preview_image_item
403             }
404 
405         override fun onBindViewHolder(vh: ViewHolder, position: Int) {
406             when (vh) {
407                 is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size)
408                 is LoadingItemViewHolder -> vh.bind()
409                 is PreviewViewHolder ->
410                     vh.bind(
411                         previews[position],
412                         imageLoader ?: error("ImageLoader is missing"),
413                         previewSize,
414                         fadeInDurationMs,
415                         isSharedTransitionElement = position == firstImagePos,
416                         editButtonRoleDescription,
417                         previewReadyCallback =
418                             if (
419                                 position == firstImagePos && transitionStatusElementCallback != null
420                             ) {
421                                 this::onTransitionElementReady
422                             } else {
423                                 null
424                             },
425                     )
426             }
427         }
428 
429         override fun onViewRecycled(vh: ViewHolder) {
430             vh.unbind()
431         }
432 
433         override fun onFailedToRecycleView(vh: ViewHolder): Boolean {
434             vh.unbind()
435             return super.onFailedToRecycleView(vh)
436         }
437 
438         private fun onTransitionElementReady(name: String) {
439             transitionStatusElementCallback?.apply {
440                 onTransitionElementReady(name)
441                 onAllTransitionElementsReady()
442             }
443             transitionStatusElementCallback = null
444         }
445     }
446 
447     private sealed class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
448         abstract fun unbind()
449     }
450 
451     private class PreviewViewHolder(
452         view: View,
453         private val imagePreviewDescription: String,
454         private val videoPreviewDescription: String,
455         private val filePreviewDescription: String,
456     ) : ViewHolder(view) {
457         val image = view.requireViewById<ImageView>(R.id.image)
458         private val badgeFrame = view.requireViewById<View>(R.id.badge_frame)
459         private val badge = view.requireViewById<ImageView>(R.id.badge)
460         private val editActionContainer = view.findViewById<View?>(R.id.edit)
461         private var scope: CoroutineScope? = null
462 
463         fun bind(
464             preview: Preview,
465             imageLoader: CachingImageLoader,
466             previewSize: Size,
467             fadeInDurationMs: Long,
468             isSharedTransitionElement: Boolean,
469             editButtonRoleDescription: CharSequence?,
470             previewReadyCallback: ((String) -> Unit)?,
471         ) {
472             image.setImageDrawable(null)
473             image.alpha = 1f
474             image.clearAnimation()
475             (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params ->
476                 params.dimensionRatio = preview.aspectRatioString
477             }
478             image.transitionName =
479                 if (isSharedTransitionElement) {
480                     TRANSITION_NAME
481                 } else {
482                     null
483                 }
484             when (preview.type) {
485                 PreviewType.Image -> {
486                     itemView.contentDescription = imagePreviewDescription
487                     badgeFrame.visibility = View.GONE
488                 }
489                 PreviewType.Video -> {
490                     itemView.contentDescription = videoPreviewDescription
491                     badge.setImageResource(R.drawable.ic_file_video)
492                     badgeFrame.visibility = View.VISIBLE
493                 }
494                 else -> {
495                     itemView.contentDescription = filePreviewDescription
496                     badge.setImageResource(R.drawable.chooser_file_generic)
497                     badgeFrame.visibility = View.VISIBLE
498                 }
499             }
500             preview.editAction?.also { onClick ->
501                 editActionContainer?.apply {
502                     setOnClickListener { onClick.run() }
503                     visibility = View.VISIBLE
504                     if (editButtonRoleDescription != null) {
505                         ViewCompat.setAccessibilityDelegate(
506                             this,
507                             ViewRoleDescriptionAccessibilityDelegate(editButtonRoleDescription),
508                         )
509                     }
510                 }
511             }
512             resetScope().launch {
513                 loadImage(preview, previewSize, imageLoader)
514                 if (preview.type == PreviewType.Image && previewReadyCallback != null) {
515                     image.waitForPreDraw()
516                     previewReadyCallback(TRANSITION_NAME)
517                 } else if (image.isAttachedToWindow()) {
518                     fadeInPreview(fadeInDurationMs)
519                 }
520             }
521         }
522 
523         private suspend fun loadImage(
524             preview: Preview,
525             previewSize: Size,
526             imageLoader: CachingImageLoader,
527         ) {
528             val bitmap =
529                 runCatching {
530                         // it's expected for all loading/caching optimizations to be implemented by
531                         // the loader
532                         imageLoader(preview.uri, previewSize, true)
533                     }
534                     .getOrNull()
535             image.setImageBitmap(bitmap)
536         }
537 
538         private suspend fun fadeInPreview(durationMs: Long) =
539             suspendCancellableCoroutine { continuation ->
540                 val animation =
541                     AlphaAnimation(0f, 1f).apply {
542                         duration = durationMs
543                         interpolator = DecelerateInterpolator()
544                         setAnimationListener(
545                             object : AnimationListener {
546                                 override fun onAnimationStart(animation: Animation?) = Unit
547 
548                                 override fun onAnimationRepeat(animation: Animation?) = Unit
549 
550                                 override fun onAnimationEnd(animation: Animation?) {
551                                     continuation.resumeWith(Result.success(Unit))
552                                 }
553                             }
554                         )
555                     }
556                 image.startAnimation(animation)
557                 continuation.invokeOnCancellation {
558                     image.clearAnimation()
559                     image.alpha = 1f
560                 }
561             }
562 
563         private fun resetScope(): CoroutineScope =
564             CoroutineScope(Dispatchers.Main.immediate).also {
565                 scope?.cancel()
566                 scope = it
567             }
568 
569         override fun unbind() {
570             scope?.cancel()
571             scope = null
572         }
573     }
574 
575     private class OtherItemViewHolder(view: View) : ViewHolder(view) {
576         private val label = view.requireViewById<TextView>(R.id.label)
577 
578         fun bind(count: Int) {
579             label.text =
580                 PluralsMessageFormatter.format(
581                     itemView.context.resources,
582                     mapOf(PLURALS_COUNT to count),
583                     R.string.other_files,
584                 )
585         }
586 
587         override fun unbind() = Unit
588     }
589 
590     private class LoadingItemViewHolder(view: View) : ViewHolder(view) {
591         fun bind() = Unit
592 
593         override fun unbind() = Unit
594     }
595 
596     private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) :
597         ItemDecoration() {
598         override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
599             val itemCount = parent.adapter?.itemCount ?: return
600             val pos = parent.getChildAdapterPosition(view)
601             var startMargin = if (pos == 0) outerSpacing else innerSpacing
602             var endMargin = if (pos == itemCount - 1) outerSpacing else 0
603 
604             if (ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL) {
605                 outRect.set(endMargin, 0, startMargin, 0)
606             } else {
607                 outRect.set(startMargin, 0, endMargin, 0)
608             }
609         }
610     }
611 
612     /**
613      * ItemAnimator to handle a special case of addng first image items into the view. The view is
614      * used with wrap_content width spec thus after adding the first views it, generally, changes
615      * its size and position breaking the animation. This class handles that by preserving loading
616      * idicator position in this special case.
617      */
618     private inner class ItemAnimator() : DefaultItemAnimator() {
619         private var animatedVH: ViewHolder? = null
620         private var originalTranslation = 0f
621 
622         override fun recordPreLayoutInformation(
623             state: State,
624             viewHolder: RecyclerView.ViewHolder,
625             changeFlags: Int,
626             payloads: MutableList<Any>,
627         ): ItemHolderInfo {
628             return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let {
629                 holderInfo ->
630                 if (viewHolder is LoadingItemViewHolder && getChildCount() == 1) {
631                     LoadingItemHolderInfo(holderInfo, parentLeft = left)
632                 } else {
633                     holderInfo
634                 }
635             }
636         }
637 
638         override fun animateDisappearance(
639             viewHolder: RecyclerView.ViewHolder,
640             preLayoutInfo: ItemHolderInfo,
641             postLayoutInfo: ItemHolderInfo?,
642         ): Boolean {
643             if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) {
644                 val view = viewHolder.itemView
645                 animatedVH = viewHolder
646                 originalTranslation = view.getTranslationX()
647                 view.setTranslationX(
648                     (preLayoutInfo.parentLeft - left + preLayoutInfo.left).toFloat() - view.left
649                 )
650             }
651             return super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
652         }
653 
654         override fun onRemoveFinished(viewHolder: RecyclerView.ViewHolder) {
655             if (animatedVH === viewHolder) {
656                 viewHolder.itemView.setTranslationX(originalTranslation)
657                 animatedVH = null
658             }
659             super.onRemoveFinished(viewHolder)
660         }
661 
662         private inner class LoadingItemHolderInfo(holderInfo: ItemHolderInfo, val parentLeft: Int) :
663             ItemHolderInfo() {
664             init {
665                 left = holderInfo.left
666                 top = holderInfo.top
667                 right = holderInfo.right
668                 bottom = holderInfo.bottom
669                 changeFlags = holderInfo.changeFlags
670             }
671         }
672     }
673 
674     @VisibleForTesting
675     class BatchPreviewLoader(
676         private val imageLoader: CachingImageLoader,
677         private val previews: Flow<Preview>,
678         private val previewSize: Size,
679         val totalItemCount: Int,
680         private val onUpdate: (List<Preview>) -> Unit,
681         private val onCompletion: () -> Unit,
682     ) {
683         private var scope: CoroutineScope = createScope()
684 
685         private fun createScope() = CoroutineScope(Dispatchers.Main.immediate)
686 
687         fun cancel() {
688             scope.cancel()
689             scope = createScope()
690         }
691 
692         fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) {
693             val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount)
694             var blockStart = 0 // inclusive
695             var blockEnd = 0 // exclusive
696 
697             // replay 2 items to guarantee that we'd get at least one update
698             val reportFlow = MutableSharedFlow<Any>(replay = 2)
699             val updateEvent = Any()
700             val completedEvent = Any()
701 
702             // collects updates from [reportFlow] throttling adapter updates;
703             scope.launch(Dispatchers.Main) {
704                 reportFlow
705                     .takeWhile { it !== completedEvent }
706                     .throttle(ADAPTER_UPDATE_INTERVAL_MS)
707                     .collect {
708                         val updates = ArrayList<Preview>(blockEnd - blockStart)
709                         while (blockStart < blockEnd) {
710                             if (previewInfos[blockStart].width > 0) {
711                                 updates.add(previewInfos[blockStart].preview)
712                             }
713                             blockStart++
714                         }
715                         if (updates.isNotEmpty()) {
716                             onUpdate(updates)
717                         }
718                     }
719                 onCompletion()
720             }
721 
722             // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow]
723             // when a next sequential block of preview aspect ratios is loaded: initially emits when
724             // enough preview elements is loaded to fill the viewport.
725             scope.launch {
726                 var blockWidth = 0
727                 var isFirstBlock = true
728 
729                 val jobs = ArrayList<Job>()
730                 previews.collect { preview ->
731                     val i = previewInfos.size
732                     val pair = PreviewWidthInfo(preview)
733                     previewInfos.add(pair)
734 
735                     val job = launch {
736                         pair.width =
737                             runCatching {
738                                     // TODO: decide on adding a timeout. The worst case I can
739                                     //  imagine is one of the first images never loads so we never
740                                     //  fill the initial viewport and does not show the previews at
741                                     //  all.
742                                     imageLoader(preview.uri, previewSize, isFirstBlock)?.let {
743                                         bitmap ->
744                                         previewSizeUpdater(preview, bitmap.width, bitmap.height)
745                                     } ?: 0
746                                 }
747                                 .getOrDefault(0)
748 
749                         if (i == blockEnd) {
750                             while (
751                                 blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0
752                             ) {
753                                 blockWidth += previewInfos[blockEnd].width
754                                 blockEnd++
755                             }
756                             if (isFirstBlock && blockWidth >= maxWidth) {
757                                 isFirstBlock = false
758                             }
759                             if (!isFirstBlock) {
760                                 reportFlow.emit(updateEvent)
761                             }
762                         }
763                     }
764                     jobs.add(job)
765                 }
766                 jobs.joinAll()
767                 // in case all previews have failed to load
768                 reportFlow.emit(updateEvent)
769                 reportFlow.emit(completedEvent)
770             }
771         }
772     }
773 
774     private class PreviewWidthInfo(val preview: Preview) {
775         // -1 encodes that the preview has not been processed,
776         // 0 means failed, > 0 is a preview width
777         var width: Int = -1
778     }
779 }
780