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