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