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