diff options
| author | 2023-03-21 19:43:20 +0000 | |
|---|---|---|
| committer | 2023-03-21 19:43:20 +0000 | |
| commit | c8ce270730086498e2ff1de794c73c2030dd1723 (patch) | |
| tree | 0e31bd050b1e27f600af6e1ece7ff6a5b6459a92 /java/src | |
| parent | 3c3755b4ea535df1b5f2a9f3e57b235b660e1fcd (diff) | |
| parent | f1870096ee8ad86d45887b1de5aee70a7f93dea3 (diff) | |
Merge "Maintain previews aspect ratio" into udc-dev
Diffstat (limited to 'java/src')
4 files changed, 322 insertions, 28 deletions
diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 2a73c42a..341e1d52 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1297,7 +1297,9 @@ public class ChooserActivity extends ResolverActivity implements final int cacheSize; if (mFeatureFlagRepository.isEnabled(Flags.SHARESHEET_SCROLLABLE_IMAGE_PREVIEW)) { float chooserWidth = getResources().getDimension(R.dimen.chooser_width); - float imageWidth = getResources().getDimension(R.dimen.chooser_preview_image_width); + // imageWidth = imagePreviewHeight / minAspectRatio (see ScrollableImagePreviewView) + float imageWidth = + getResources().getDimension(R.dimen.chooser_preview_image_height_tall) * 5 / 2; cacheSize = (int) (Math.ceil(chooserWidth / imageWidth) + 2); } else { cacheSize = 3; diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index ee24d18f..c4e6feb7 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -152,8 +152,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { stub.setLayoutResource(R.layout.scrollable_image_preview_view); stub.inflate(); } - return previewLayout.findViewById( - com.android.internal.R.id.content_preview_image_area); + return previewLayout.findViewById(R.id.scrollable_image_preview); } private void setTextInImagePreviewVisibility( diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt new file mode 100644 index 00000000..1155b9fe --- /dev/null +++ b/java/src/com/android/intentresolver/util/Flow.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch + +/** + * Returns a flow that mirrors the original flow, but delays values following emitted values for the + * given [periodMs]. If the original flow emits more than one value during this period, only the + * latest value is emitted. + * + * Example: + * + * ```kotlin + * flow { + * emit(1) // t=0ms + * delay(90) + * emit(2) // t=90ms + * delay(90) + * emit(3) // t=180ms + * delay(1010) + * emit(4) // t=1190ms + * delay(1010) + * emit(5) // t=2200ms + * }.throttle(1000) + * ``` + * + * produces the following emissions at the following times + * + * ```text + * 1 (t=0ms), 3 (t=1000ms), 4 (t=2000ms), 5 (t=3000ms) + * ``` + */ +// A SystemUI com.android.systemui.util.kotlin.throttle copy. +fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = channelFlow { + coroutineScope { + var previousEmitTimeMs = 0L + var delayJob: Job? = null + var sendJob: Job? = null + val outerScope = this + + collect { + delayJob?.cancel() + sendJob?.join() + val currentTimeMs = SystemClock.elapsedRealtime() + val timeSinceLastEmit = currentTimeMs - previousEmitTimeMs + val timeUntilNextEmit = maxOf(0L, periodMs - timeSinceLastEmit) + if (timeUntilNextEmit > 0L) { + // We create delayJob to allow cancellation during the delay period + delayJob = launch { + delay(timeUntilNextEmit) + sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = SystemClock.elapsedRealtime() + } + } + } else { + send(it) + previousEmitTimeMs = currentTimeMs + } + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index c02a10a2..d1b0f5b4 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.widget import android.content.Context +import android.graphics.Bitmap import android.graphics.Rect import android.net.Uri import android.util.AttributeSet @@ -27,20 +28,32 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R +import com.android.intentresolver.util.throttle import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlin.math.sign +import java.util.ArrayDeque +import kotlin.math.roundToInt private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" +private const val ADAPTER_UPDATE_INTERVAL_MS = 150L +private const val MIN_ASPECT_RATIO = 0.4f +private const val MIN_ASPECT_RATIO_STRING = "2:5" +private const val MAX_ASPECT_RATIO = 2.5f +private const val MAX_ASPECT_RATIO_STRING = "5:2" class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) @@ -50,14 +63,53 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = Adapter(context) - val spacing = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 5f, context.resources.displayMetrics - ).toInt() - addItemDecoration(SpacingDecoration(spacing)) + + context.obtainStyledAttributes( + attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0 + ).use { a -> + var innerSpacing = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_itemInnerSpacing, -1 + ) + if (innerSpacing < 0) { + innerSpacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 3f, context.resources.displayMetrics + ).toInt() + } + var outerSpacing = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_itemOuterSpacing, -1 + ) + if (outerSpacing < 0) { + outerSpacing = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16f, context.resources.displayMetrics + ).toInt() + } + addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) + + maxWidthHint = a.getDimensionPixelSize( + R.styleable.ScrollableImagePreviewView_maxWidthHint, -1 + ) + } } + private var batchLoader: BatchPreviewLoader? = null private val previewAdapter get() = adapter as Adapter + /** + * A hint about the maximum width this view can grow to, this helps to optimize preview + * loading. + */ + var maxWidthHint: Int = -1 + private var requestedHeight: Int = 0 + private var isMeasured = false + + override fun onMeasure(widthSpec: Int, heightSpec: Int) { + super.onMeasure(widthSpec, heightSpec) + if (!isMeasured) { + isMeasured = true + batchLoader?.loadAspectRatios(getMaxWidth(), this::calcPreviewWidth) + } + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) setOverScrollMode( @@ -69,32 +121,103 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewAdapter.transitionStatusElementCallback = callback } - fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: ImageLoader) = - previewAdapter.setPreviews(previews, otherItemCount, imageLoader) + fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: ImageLoader) { + previewAdapter.reset(0, imageLoader) + batchLoader?.cancel() + batchLoader = BatchPreviewLoader( + previewAdapter, + imageLoader, + previews, + otherItemCount, + ).apply { + if (isMeasured) { + loadAspectRatios(getMaxWidth(), this@ScrollableImagePreviewView::calcPreviewWidth) + } + } + } + + private fun getMaxWidth(): Int = + when { + maxWidthHint > 0 -> maxWidthHint + isLaidOut -> width + else -> measuredWidth + } + + private fun calcPreviewWidth(bitmap: Bitmap): Int { + val effectiveHeight = if (isLaidOut) height else measuredHeight + return if (bitmap.width <= 0 || bitmap.height <= 0) { + effectiveHeight + } else { + val ar = (bitmap.width.toFloat() / bitmap.height.toFloat()) + .coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + (effectiveHeight * ar).roundToInt() + } + } + + class Preview internal constructor( + val type: PreviewType, + val uri: Uri, + internal var aspectRatioString: String + ) { + constructor(type: PreviewType, uri: Uri) : this(type, uri, "1:1") + + internal var bitmap: Bitmap? = null + + internal fun updateAspectRatio(width: Int, height: Int) { + if (width <= 0 || height <= 0) return + val aspectRatio = width.toFloat() / height.toFloat() + aspectRatioString = when { + aspectRatio <= MIN_ASPECT_RATIO -> MIN_ASPECT_RATIO_STRING + aspectRatio >= MAX_ASPECT_RATIO -> MAX_ASPECT_RATIO_STRING + else -> "$width:$height" + } + } + } - class Preview(val type: PreviewType, val uri: Uri) enum class PreviewType { Image, Video, File } - private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() { + private class Adapter( + private val context: Context + ) : RecyclerView.Adapter<ViewHolder>() { private val previews = ArrayList<Preview>() private var imageLoader: ImageLoader? = null private var firstImagePos = -1 + private var totalItemCount: Int = 0 + + private val hasOtherItem get() = previews.size < totalItemCount + var transitionStatusElementCallback: TransitionElementStatusCallback? = null - private var otherItemCount = 0 - fun setPreviews( - previews: List<Preview>, otherItemCount: Int, imageLoader: ImageLoader - ) { - this.previews.clear() - this.previews.addAll(previews) + fun reset(totalItemCount: Int, imageLoader: ImageLoader) { this.imageLoader = imageLoader - firstImagePos = previews.indexOfFirst { it.type == PreviewType.Image } - this.otherItemCount = maxOf(0, otherItemCount) + firstImagePos = -1 + previews.clear() + this.totalItemCount = maxOf(0, totalItemCount) notifyDataSetChanged() } + fun addPreviews(newPreviews: Collection<Preview>) { + if (newPreviews.isEmpty()) return + val insertPos = previews.size + val hadOtherItem = hasOtherItem + previews.addAll(newPreviews) + if (firstImagePos < 0) { + val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } + if (pos >= 0) firstImagePos = insertPos + pos + } + notifyItemRangeInserted(insertPos, newPreviews.size) + when { + hadOtherItem && previews.size >= totalItemCount -> { + notifyItemRemoved(previews.size) + } + !hadOtherItem && previews.size < totalItemCount -> { + notifyItemInserted(previews.size) + } + } + } + override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { val view = LayoutInflater.from(context).inflate(itemType, parent, false); return if (itemType == R.layout.image_preview_other_item) { @@ -104,7 +227,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - override fun getItemCount(): Int = previews.size + otherItemCount.sign + override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0 override fun getItemViewType(position: Int): Int { return if (position == previews.size) { @@ -116,7 +239,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun onBindViewHolder(vh: ViewHolder, position: Int) { when (vh) { - is OtherItemViewHolder -> vh.bind(otherItemCount) + is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size) is PreviewViewHolder -> vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), @@ -163,6 +286,9 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) + (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> + params.dimensionRatio = preview.aspectRatioString + } image.transitionName = if (previewReadyCallback != null) { TRANSITION_NAME } else { @@ -180,7 +306,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } resetScope().launch { - loadImage(preview.uri, imageLoader) + loadImage(preview, imageLoader) if (preview.type == PreviewType.Image) { previewReadyCallback?.let { callback -> image.waitForPreDraw() @@ -190,11 +316,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private suspend fun loadImage(uri: Uri, imageLoader: ImageLoader) { - val bitmap = runCatching { + private suspend fun loadImage(preview: Preview, imageLoader: ImageLoader) { + val bitmap = preview.bitmap ?: runCatching { // it's expected for all loading/caching optimizations to be implemented by the // loader - imageLoader(uri) + imageLoader(preview.uri) }.getOrNull() image.setImageBitmap(bitmap) } @@ -225,9 +351,92 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun unbind() = Unit } - private class SpacingDecoration(private val margin: Int) : ItemDecoration() { + private class SpacingDecoration( + private val innerSpacing: Int, + private val outerSpacing: Int + ) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { - outRect.set(margin, 0, margin, 0) + val itemCount = parent.adapter?.itemCount ?: return + val pos = parent.getChildAdapterPosition(view) + var leftMargin = if (pos == 0) outerSpacing else innerSpacing + var rightMargin = if (pos == itemCount - 1) outerSpacing else 0 + outRect.set(leftMargin, 0, rightMargin, 0) + } + } + + private class BatchPreviewLoader( + private val adapter: Adapter, + private val imageLoader: ImageLoader, + previews: List<Preview>, + otherItemCount: Int, + ) { + private val pendingPreviews = ArrayDeque<Preview>(previews) + private val totalItemCount = previews.size + otherItemCount + private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate + + fun cancel() { + scope?.cancel() + scope = null + } + + fun loadAspectRatios(maxWidth: Int, previewWidthCalculator: (Bitmap) -> Int) { + val scope = this.scope ?: return + val updates = ArrayDeque<Preview>(pendingPreviews.size) + // replay 2 items to guarantee that we'd get at least one update + val reportFlow = MutableSharedFlow<Any>(replay = 2) + var isFirstUpdate = true + val updateEvent = Any() + val completedEvent = Any() + // throttle adapter updates by waiting on the channel, the channel first notified + // when enough preview elements is loaded and then periodically with a delay + scope.launch(Dispatchers.Main) { + reportFlow + .takeWhile { it !== completedEvent } + .throttle(ADAPTER_UPDATE_INTERVAL_MS) + .collect { + if (isFirstUpdate) { + isFirstUpdate = false + adapter.reset(totalItemCount, imageLoader) + } + if (updates.isNotEmpty()) { + adapter.addPreviews(updates) + updates.clear() + } + } + } + + scope.launch { + var loadedPreviewWidth = 0 + List<Job>(4) { + launch { + while (pendingPreviews.isNotEmpty()) { + val preview = pendingPreviews.poll() ?: continue + val bitmap = runCatching { + // TODO: decide on adding a timeout + imageLoader(preview.uri) + }.getOrNull() ?: continue + preview.updateAspectRatio(bitmap.width, bitmap.height) + updates.add(preview) + if (loadedPreviewWidth < maxWidth) { + loadedPreviewWidth += previewWidthCalculator(bitmap) + // cache bitmaps for the first preview items to aovid potential + // double-loading (in case those values are evicted from the image + // loader's cache) + preview.bitmap = bitmap + if (loadedPreviewWidth >= maxWidth) { + // notify that the preview now can be displayed + reportFlow.emit(updateEvent) + } + } else { + reportFlow.emit(updateEvent) + } + } + } + }.joinAll() + // in case all previews have failed to load + reportFlow.emit(updateEvent) + reportFlow.emit(completedEvent) + } } } } |