summaryrefslogtreecommitdiff
path: root/java
diff options
context:
space:
mode:
Diffstat (limited to 'java')
-rw-r--r--java/res/layout/image_preview_image_item.xml33
-rw-r--r--java/res/layout/image_preview_other_item.xml2
-rw-r--r--java/res/layout/scrollable_image_preview_view.xml25
-rw-r--r--java/res/values/attrs.xml6
-rw-r--r--java/res/values/config.xml2
-rw-r--r--java/res/values/dimens.xml1
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java4
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java11
-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
-rw-r--r--java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java2
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt50
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()
}
+
}