summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Andrey Epin <ayepin@google.com> 2023-03-21 19:43:20 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2023-03-21 19:43:20 +0000
commitc8ce270730086498e2ff1de794c73c2030dd1723 (patch)
tree0e31bd050b1e27f600af6e1ece7ff6a5b6459a92 /java/src
parent3c3755b4ea535df1b5f2a9f3e57b235b660e1fcd (diff)
parentf1870096ee8ad86d45887b1de5aee70a7f93dea3 (diff)
Merge "Maintain previews aspect ratio" into udc-dev
Diffstat (limited to 'java/src')
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java3
-rw-r--r--java/src/com/android/intentresolver/util/Flow.kt84
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt259
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)
+ }
}
}
}