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