diff options
Diffstat (limited to 'java')
13 files changed, 425 insertions, 57 deletions
diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml index 81fa5c8e..a8a2c754 100644 --- a/java/res/layout/image_preview_image_item.xml +++ b/java/res/layout/image_preview_image_item.xml @@ -14,25 +14,30 @@ ~ limitations under the License. --> -<FrameLayout +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="@dimen/chooser_preview_image_width" - android:layout_height="@dimen/chooser_preview_image_height" > + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall"> -<com.android.intentresolver.widget.RoundedRectImageView - android:id="@+id/image" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_alignParentTop="true" - android:adjustViewBounds="false" - android:scaleType="centerCrop" - app:radius="@dimen/chooser_corner_radius_small" /> + <com.android.intentresolver.widget.RoundedRectImageView + android:id="@+id/image" + android:layout_width="0dp" + android:layout_height="match_parent" + app:layout_constraintDimensionRatio="W,1:1" + android:layout_alignParentTop="true" + android:adjustViewBounds="false" + android:scaleType="centerCrop" + app:radius="@dimen/chooser_corner_radius_small" /> <FrameLayout android:id="@+id/badge_frame" - android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintStart_toStartOf="@+id/image" + app:layout_constraintEnd_toEndOf="@+id/image" + app:layout_constraintTop_toTopOf="@+id/image" + app:layout_constraintBottom_toBottomOf="@+id/image" android:background="@drawable/content_preview_badge_bg"> <ImageView @@ -45,4 +50,4 @@ android:tint="@android:color/white" android:layout_gravity="bottom|end" /> </FrameLayout> -</FrameLayout> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml index b7cc4350..07f87e3a 100644 --- a/java/res/layout/image_preview_other_item.xml +++ b/java/res/layout/image_preview_other_item.xml @@ -17,7 +17,7 @@ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="@dimen/chooser_preview_image_width" - android:layout_height="@dimen/chooser_preview_image_height"> + android:layout_height="@dimen/chooser_preview_image_height_tall"> <TextView android:id="@+id/label" diff --git a/java/res/layout/scrollable_image_preview_view.xml b/java/res/layout/scrollable_image_preview_view.xml index c6c310e6..0d41f1ae 100644 --- a/java/res/layout/scrollable_image_preview_view.xml +++ b/java/res/layout/scrollable_image_preview_view.xml @@ -15,12 +15,21 @@ ~ limitations under the License. --> -<com.android.intentresolver.widget.ScrollableImagePreviewView - xmlns:android="http://schemas.android.com/apk/res/android" +<!-- TODO: the unnecessary FrameLayout wrapping is a workaround for ViewStub (it ignores this view's + width and height specs); remove when when the legacy image preview is removed --> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="center_horizontal" - android:paddingStart="@dimen/chooser_edge_margin_normal" - android:paddingEnd="@dimen/chooser_edge_margin_normal" - android:paddingBottom="@dimen/chooser_view_spacing" - android:background="?android:attr/colorBackground" /> + android:layout_height="wrap_content"> + + <com.android.intentresolver.widget.ScrollableImagePreviewView + android:id="@+id/scrollable_image_preview" + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:layout_gravity="center_horizontal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground" + app:itemInnerSpacing="3dp" + app:itemOuterSpacing="@dimen/chooser_edge_margin_normal" + app:maxWidthHint="@dimen/chooser_width" /> +</FrameLayout> diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index eba6b9b7..67acb3ae 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -45,4 +45,10 @@ <declare-styleable name="RoundedRectImageView"> <attr name="radius" format="dimension" /> </declare-styleable> + + <declare-styleable name="ScrollableImagePreviewView"> + <attr name="itemInnerSpacing" format="dimension" /> + <attr name="itemOuterSpacing" format="dimension" /> + <attr name="maxWidthHint" format="dimension" /> + </declare-styleable> </resources> diff --git a/java/res/values/config.xml b/java/res/values/config.xml index f8addc9f..1890bc6d 100644 --- a/java/res/values/config.xml +++ b/java/res/values/config.xml @@ -39,5 +39,5 @@ This name is in the ComponentName flattened format (package/class) [DO NOT TRANSLATE] --> <string name="config_systemImageEditor" translatable="false">@*android:string/config_systemImageEditor</string> - <integer name="config_chooser_max_targets_per_row">@*android:integer/config_chooser_max_targets_per_row</integer> + <integer name="config_chooser_max_targets_per_row">5</integer> </resources> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index af90c4ef..7daa9206 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -28,6 +28,7 @@ <dimen name="chooser_preview_image_border">1dp</dimen> <dimen name="chooser_preview_image_width">120dp</dimen> <dimen name="chooser_preview_image_height">104dp</dimen> + <dimen name="chooser_preview_image_height_tall">192dp</dimen> <dimen name="chooser_preview_image_max_dimen">200dp</dimen> <dimen name="chooser_preview_width">-1px</dimen> <dimen name="chooser_header_scroll_elevation">4dp</dimen> 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/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 08cebf68..de454cfd 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -28,11 +28,11 @@ import android.content.res.Resources; import android.database.Cursor; import android.media.MediaMetadata; import android.net.Uri; -import android.os.RemoteException; import android.provider.DocumentsContract; import android.provider.Downloads; import android.provider.OpenableColumns; import android.text.TextUtils; +import android.util.Log; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -351,7 +351,8 @@ public final class ChooserContentPreviewUi { private static String getType(ContentInterface resolver, Uri uri) { try { return resolver.getType(uri); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read content type, uri: " + uri, t); return null; } } @@ -360,7 +361,8 @@ public final class ChooserContentPreviewUi { private static Cursor query(ContentInterface resolver, Uri uri) { try { return resolver.query(uri, null, null, null); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: " + uri, t); return null; } } @@ -369,7 +371,8 @@ public final class ChooserContentPreviewUi { private static String[] getStreamTypes(ContentInterface resolver, Uri uri) { try { return resolver.getStreamTypes(uri, "*/*"); - } catch (RemoteException e) { + } catch (Throwable t) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: " + uri, t); return null; } } 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) + } } } } diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index 596b546e..8b9aaa60 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1030,7 +1030,7 @@ public class UnbundledChooserActivityTest { setupResolverControllers(resolvedComponentInfos); mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_image_area)) + onView(withId(R.id.scrollable_image_preview)) .perform(RecyclerViewActions.scrollToLastPosition()) .check((view, exception) -> { if (exception != null) { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 04a136b4..58b8a21d 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -23,6 +23,8 @@ import android.graphics.Bitmap import android.net.Uri import com.android.intentresolver.ImageLoader import com.android.intentresolver.TestFeatureFlagRepository +import com.android.intentresolver.any +import com.android.intentresolver.anyOrNull import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory import com.android.intentresolver.flags.Flags import com.android.intentresolver.mock @@ -144,6 +146,53 @@ class ChooserContentPreviewUiTest { } @Test + fun test_ChooserContentPreview_single_uri_crashing_getType_to_file_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(any())) + .thenThrow(SecurityException("Test getType() exception")) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test + fun test_ChooserContentPreview_single_uri_crashing_metadata_to_file_preview() { + val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") + val targetIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(any())).thenReturn("application/pdf") + whenever(contentResolver.query(any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(SecurityException("Test query() exception")) + whenever(contentResolver.getStreamTypes(any(), any())) + .thenThrow(SecurityException("Test getStreamType() exception")) + val testSubject = ChooserContentPreviewUi( + targetIntent, + contentResolver, + imageClassifier, + imageLoader, + actionFactory, + transitionCallback, + featureFlagRepository + ) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + verify(transitionCallback, times(1)).onAllTransitionElementsReady() + } + + @Test fun test_ChooserContentPreview_single_uri_with_preview_to_image_preview() { val uri = Uri.parse("content://$PROVIDER_NAME/test.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { @@ -283,4 +332,5 @@ class ChooserContentPreviewUiTest { .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) verify(transitionCallback, times(1)).onAllTransitionElementsReady() } + } |