summaryrefslogtreecommitdiff
path: root/java
diff options
context:
space:
mode:
Diffstat (limited to 'java')
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java14
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java2
-rw-r--r--java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt51
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt124
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java28
-rw-r--r--java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt172
-rw-r--r--java/tests/src/com/android/intentresolver/TestContentProvider.kt32
-rw-r--r--java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java116
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt19
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt212
-rw-r--r--java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt52
-rw-r--r--java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt23
13 files changed, 539 insertions, 323 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
index d4874cac..d279f11f 100644
--- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java
@@ -16,6 +16,8 @@
package com.android.intentresolver.contentpreview;
+import static androidx.lifecycle.LifecycleKt.getCoroutineScope;
+
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE;
import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT;
@@ -156,23 +158,25 @@ public final class ChooserContentPreviewUi {
typeClassifier,
headlineGenerator);
if (previewData.getUriCount() > 0) {
- previewData.getFileMetadataForImagePreview(
- mLifecycle, previewUi::updatePreviewMetadata);
+ JavaFlowHelper.collectToList(
+ getCoroutineScope(mLifecycle),
+ previewData.getImagePreviewFileInfoFlow(),
+ previewUi::updatePreviewMetadata);
}
return previewUi;
}
- UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi(
+ return new UnifiedContentPreviewUi(
+ getCoroutineScope(mLifecycle),
isSingleImageShare,
targetIntent.getType(),
actionFactory,
imageLoader,
typeClassifier,
transitionElementStatusCallback,
+ previewData.getImagePreviewFileInfoFlow(),
previewData.getUriCount(),
headlineGenerator);
- previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles);
- return unifiedContentPreviewUi;
}
public int getPreferredContentPreview() {
diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
index 07071236..2d81794e 100644
--- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java
@@ -85,7 +85,7 @@ abstract class ContentPreviewUi {
}
}
- protected static ScrollableImagePreviewView.PreviewType getPreviewType(
+ static ScrollableImagePreviewView.PreviewType getPreviewType(
MimeTypeClassifier typeClassifier, String mimeType) {
if (mimeType == null) {
return ScrollableImagePreviewView.PreviewType.File;
diff --git a/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt
new file mode 100644
index 00000000..b29c5774
--- /dev/null
+++ b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+@file:JvmName("JavaFlowHelper")
+
+package com.android.intentresolver.contentpreview
+
+import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview
+import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+
+internal fun mapFileIntoToPreview(
+ flow: Flow<FileInfo>,
+ typeClassifier: MimeTypeClassifier,
+ editAction: Runnable?
+): Flow<Preview> =
+ flow
+ .filter { it.previewUri != null }
+ .map { fileInfo ->
+ Preview(
+ ContentPreviewUi.getPreviewType(typeClassifier, fileInfo.mimeType),
+ requireNotNull(fileInfo.previewUri),
+ editAction
+ )
+ }
+
+internal fun <T> collectToList(
+ clientScope: CoroutineScope,
+ flow: Flow<T>,
+ callback: Consumer<List<T>>
+) {
+ clientScope.launch { callback.accept(flow.toList()) }
+}
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
index 8ab3a272..fd5ce3f8 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt
@@ -38,14 +38,18 @@ import com.android.intentresolver.measurements.runTracing
import com.android.intentresolver.util.ownedByCurrentUser
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.take
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -69,30 +73,45 @@ private const val TIMEOUT_MS = 1_000L
@OpenForTesting
open class PreviewDataProvider
@VisibleForTesting
+@JvmOverloads
constructor(
+ private val scope: CoroutineScope,
private val targetIntent: Intent,
private val contentResolver: ContentInterface,
- private val typeClassifier: MimeTypeClassifier,
- private val dispatcher: CoroutineDispatcher,
+ private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
) {
- constructor(
- targetIntent: Intent,
- contentResolver: ContentInterface,
- ) : this(
- targetIntent,
- contentResolver,
- DefaultMimeTypeClassifier,
- Dispatchers.IO,
- )
private val records = targetIntent.contentUris.map { UriRecord(it) }
+ private val fileInfoSharedFlow: SharedFlow<FileInfo> by lazy {
+ // Alternatively, we could just use [shareIn()] on a [flow] -- and it would be, arguably,
+ // cleaner -- but we'd lost the ability to trace the traverse as [runTracing] does not
+ // generally work over suspend function invocations.
+ MutableSharedFlow<FileInfo>(replay = records.size).apply {
+ scope.launch {
+ runTracing("image-preview-metadata") {
+ for (record in records) {
+ tryEmit(FileInfo.Builder(record.uri).readFromRecord(record).build())
+ }
+ }
+ }
+ }
+ }
+
/** returns number of shared URIs, see [Intent.EXTRA_STREAM] */
@get:OpenForTesting
open val uriCount: Int
get() = records.size
/**
+ * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and
+ * [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
+ */
+ @get:OpenForTesting
+ open val imagePreviewFileInfoFlow: Flow<FileInfo>
+ get() = fileInfoSharedFlow.take(records.size)
+
+ /**
* Preview type to use. The type is determined asynchronously with a timeout; the fall-back
* values is [ContentPreviewType.CONTENT_PREVIEW_FILE]
*/
@@ -107,10 +126,17 @@ constructor(
if (!targetIntent.isSend || records.isEmpty()) {
CONTENT_PREVIEW_TEXT
} else {
- runBlocking(dispatcher) {
- withTimeoutOrNull(TIMEOUT_MS) {
- loadPreviewType()
- } ?: CONTENT_PREVIEW_FILE
+ try {
+ runBlocking(scope.coroutineContext) {
+ withTimeoutOrNull(TIMEOUT_MS) { loadPreviewType() } ?: CONTENT_PREVIEW_FILE
+ }
+ } catch (e: CancellationException) {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "An attempt to read preview type from a cancelled scope",
+ e
+ )
+ CONTENT_PREVIEW_FILE
}
}
}
@@ -123,46 +149,22 @@ constructor(
open val firstFileInfo: FileInfo? by lazy {
runTracing("first-uri-metadata") {
records.firstOrNull()?.let { record ->
- runBlocking(dispatcher) {
- val builder = FileInfo.Builder(record.uri)
- withTimeoutOrNull(TIMEOUT_MS) {
- builder.readFromRecord(record)
+ val builder = FileInfo.Builder(record.uri)
+ try {
+ runBlocking(scope.coroutineContext) {
+ withTimeoutOrNull(TIMEOUT_MS) { builder.readFromRecord(record) }
}
- builder.build()
- }
- }
- }
- }
-
- /**
- * Returns a collection of [FileInfo], for each shared URI in order, with [FileInfo.mimeType]
- * and [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
- */
- @OpenForTesting
- open fun getFileMetadataForImagePreview(
- callerLifecycle: Lifecycle,
- callback: Consumer<List<FileInfo>>,
- ) {
- callerLifecycle.coroutineScope.launch {
- val result = withContext(dispatcher) {
- getFileMetadataForImagePreview()
- }
- callback.accept(result)
- }
- }
-
- private fun getFileMetadataForImagePreview(): List<FileInfo> =
- runTracing("image-preview-metadata") {
- ArrayList<FileInfo>(records.size).also { result ->
- for (record in records) {
- result.add(
- FileInfo.Builder(record.uri)
- .readFromRecord(record)
- .build()
+ } catch (e: CancellationException) {
+ Log.w(
+ ContentPreviewUi.TAG,
+ "An attempt to read first file info from a cancelled scope",
+ e
)
}
+ builder.build()
}
}
+ }
private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder {
withMimeType(record.mimeType)
@@ -186,9 +188,7 @@ constructor(
throw IndexOutOfBoundsException("There are no shared URIs")
}
callerLifecycle.coroutineScope.launch {
- val result = withContext(dispatcher) {
- getFirstFileName()
- }
+ val result = scope.async { getFirstFileName() }.await()
callback.accept(result)
}
}
@@ -237,8 +237,7 @@ constructor(
}
resultDeferred.complete(CONTENT_PREVIEW_FILE)
}
- resultDeferred.await()
- .also { job.cancel() }
+ resultDeferred.await().also { job.cancel() }
}
}
@@ -251,8 +250,8 @@ constructor(
val isImageType: Boolean
get() = typeClassifier.isImageType(mimeType)
val supportsImageType: Boolean by lazy {
- contentResolver.getStreamTypesSafe(uri)
- ?.firstOrNull(typeClassifier::isImageType) != null
+ contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) !=
+ null
}
val supportsThumbnail: Boolean
get() = query.supportsThumbnail
@@ -264,9 +263,8 @@ constructor(
private val query by lazy { readQueryResult() }
private fun readQueryResult(): QueryResult {
- val cursor = contentResolver.querySafe(uri)
- ?.takeIf { it.moveToFirst() }
- ?: return QueryResult()
+ val cursor =
+ contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult()
var flagColIdx = -1
var displayIconUriColIdx = -1
diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
index 331b0cb6..6013f5a0 100644
--- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
+++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt
@@ -25,11 +25,15 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import com.android.intentresolver.ChooserRequestParameters
import com.android.intentresolver.R
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */
-class PreviewViewModel(private val application: Application) : BasePreviewViewModel() {
+class PreviewViewModel(
+ private val application: Application,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
+) : BasePreviewViewModel() {
private var previewDataProvider: PreviewDataProvider? = null
private var imageLoader: ImagePreviewImageLoader? = null
@@ -38,15 +42,18 @@ class PreviewViewModel(private val application: Application) : BasePreviewViewMo
chooserRequest: ChooserRequestParameters
): PreviewDataProvider =
previewDataProvider
- ?: PreviewDataProvider(chooserRequest.targetIntent, application.contentResolver).also {
- previewDataProvider = it
- }
+ ?: PreviewDataProvider(
+ viewModelScope + dispatcher,
+ chooserRequest.targetIntent,
+ application.contentResolver
+ )
+ .also { previewDataProvider = it }
@MainThread
override fun createOrReuseImageLoader(): ImageLoader =
imageLoader
?: ImagePreviewImageLoader(
- viewModelScope + Dispatchers.IO,
+ viewModelScope + dispatcher,
thumbnailSize =
application.resources.getDimensionPixelSize(
R.dimen.chooser_preview_image_max_dimen
diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
index 5db5020e..8e635aba 100644
--- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
+++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java
@@ -31,10 +31,12 @@ import com.android.intentresolver.widget.ActionRow;
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback;
import com.android.intentresolver.widget.ScrollableImagePreviewView;
-import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import kotlinx.coroutines.CoroutineScope;
+import kotlinx.coroutines.flow.Flow;
+
class UnifiedContentPreviewUi extends ContentPreviewUi {
private final boolean mShowEditAction;
@Nullable
@@ -44,6 +46,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
private final MimeTypeClassifier mTypeClassifier;
private final TransitionElementStatusCallback mTransitionElementStatusCallback;
private final HeadlineGenerator mHeadlineGenerator;
+ private final Flow<FileInfo> mFileInfoFlow;
private final int mItemCount;
@Nullable
private List<FileInfo> mFiles;
@@ -51,12 +54,14 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
private ViewGroup mContentPreviewView;
UnifiedContentPreviewUi(
+ CoroutineScope scope,
boolean isSingleImage,
@Nullable String intentMimeType,
ChooserContentPreviewUi.ActionFactory actionFactory,
ImageLoader imageLoader,
MimeTypeClassifier typeClassifier,
TransitionElementStatusCallback transitionElementStatusCallback,
+ Flow<FileInfo> fileInfoFlow,
int itemCount,
HeadlineGenerator headlineGenerator) {
mShowEditAction = isSingleImage;
@@ -65,8 +70,11 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
mImageLoader = imageLoader;
mTypeClassifier = typeClassifier;
mTransitionElementStatusCallback = transitionElementStatusCallback;
+ mFileInfoFlow = fileInfoFlow;
mItemCount = itemCount;
mHeadlineGenerator = headlineGenerator;
+
+ JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles);
}
@Override
@@ -81,7 +89,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
return layout;
}
- public void setFiles(List<FileInfo> files) {
+ private void setFiles(List<FileInfo> files) {
mImageLoader.prePopulate(files.stream()
.map(FileInfo::getPreviewUri)
.filter(Objects::nonNull)
@@ -106,6 +114,12 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
imagePreview.setImageLoader(mImageLoader);
imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE));
imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback);
+ imagePreview.setPreviews(
+ JavaFlowHelper.mapFileIntoToPreview(
+ mFileInfoFlow,
+ mTypeClassifier,
+ mShowEditAction ? mActionFactory.getEditButtonRunnable() : null),
+ mItemCount);
if (mFiles != null) {
updatePreviewWithFiles(mContentPreviewView, mFiles);
@@ -135,7 +149,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
return;
}
- List<ScrollableImagePreviewView.Preview> previews = new ArrayList<>();
boolean allImages = true;
boolean allVideos = true;
for (FileInfo fileInfo : files) {
@@ -143,17 +156,8 @@ class UnifiedContentPreviewUi extends ContentPreviewUi {
getPreviewType(mTypeClassifier, fileInfo.getMimeType());
allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image;
allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video;
-
- if (fileInfo.getPreviewUri() != null) {
- Runnable editAction =
- mShowEditAction ? mActionFactory.getEditButtonRunnable() : null;
- previews.add(
- new ScrollableImagePreviewView.Preview(
- previewType, fileInfo.getPreviewUri(), editAction));
- }
}
- imagePreview.setPreviews(previews, count - previews.size());
displayHeadline(contentPreviewView, count, allImages, allVideos);
}
diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
index d9844d7b..3bbafc40 100644
--- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
+++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt
@@ -39,14 +39,12 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
-import kotlinx.coroutines.plus
private const val TRANSITION_NAME = "screenshot_preview_image"
private const val PLURALS_COUNT = "count"
@@ -127,7 +125,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
isMeasured = true
updateMaxWidthHint(widthSpec)
updateMaxAspectRatio()
- batchLoader?.loadAspectRatios(getMaxWidth(), this::updatePreviewSize)
+ maybeLoadAspectRatios()
}
}
@@ -145,6 +143,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
)
}
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ batchLoader?.totalItemCount?.let(previewAdapter::reset)
+ maybeLoadAspectRatios()
+ }
+
+ override fun onDetachedFromWindow() {
+ batchLoader?.cancel()
+ super.onDetachedFromWindow()
+ }
+
override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) {
previewAdapter.transitionStatusElementCallback = callback
}
@@ -166,30 +175,30 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
previewAdapter.reset(totalItemCount)
}
- fun setPreviews(previews: List<Preview>, otherItemCount: Int) {
- previewAdapter.reset(previews.size + otherItemCount)
+ fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) {
+ previewAdapter.reset(totalItemCount)
batchLoader?.cancel()
batchLoader =
BatchPreviewLoader(
- previewAdapter.imageLoader ?: error("Image loader is not set"),
- previews,
- otherItemCount,
- onUpdate = previewAdapter::addPreviews,
- onCompletion = {
- if (!previewAdapter.hasPreviews) {
- onNoPreviewCallback?.run()
- }
- previewAdapter.markLoaded()
- }
- )
- .apply {
- if (isMeasured) {
- loadAspectRatios(
- getMaxWidth(),
- this@ScrollableImagePreviewView::updatePreviewSize
- )
+ previewAdapter.imageLoader ?: error("Image loader is not set"),
+ previews,
+ totalItemCount,
+ onUpdate = previewAdapter::addPreviews,
+ onCompletion = {
+ batchLoader = null
+ if (!previewAdapter.hasPreviews) {
+ onNoPreviewCallback?.run()
}
+ previewAdapter.markLoaded()
}
+ )
+ maybeLoadAspectRatios()
+ }
+
+ private fun maybeLoadAspectRatios() {
+ if (isMeasured && isAttachedToWindow()) {
+ batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) }
+ }
}
var onNoPreviewCallback: Runnable? = null
@@ -320,6 +329,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
!hadOtherItem && hasOtherItem -> {
notifyItemInserted(previews.size)
}
+ else -> notifyItemChanged(previews.size)
}
}
}
@@ -464,7 +474,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
}
private fun resetScope(): CoroutineScope =
- (MainScope() + Dispatchers.Main.immediate).also {
+ CoroutineScope(Dispatchers.Main.immediate).also {
scope?.cancel()
scope = it
}
@@ -514,26 +524,22 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
@VisibleForTesting
class BatchPreviewLoader(
private val imageLoader: CachingImageLoader,
- previews: List<Preview>,
- otherItemCount: Int,
+ private val previews: Flow<Preview>,
+ val totalItemCount: Int,
private val onUpdate: (List<Preview>) -> Unit,
private val onCompletion: () -> Unit,
) {
- private val previews: List<Preview> =
- if (previews is RandomAccess) previews else ArrayList(previews)
- private val totalItemCount = previews.size + otherItemCount
- private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate
+ private var scope: CoroutineScope = createScope()
+
+ private fun createScope() = CoroutineScope(Dispatchers.Main.immediate)
fun cancel() {
- scope?.cancel()
- scope = null
+ scope.cancel()
+ scope = createScope()
}
fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) {
- val scope = this.scope ?: return
- // -1 encodes that the preview has not been processed,
- // 0 means failed, > 0 is a preview width
- val previewWidths = IntArray(previews.size) { -1 }
+ val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount)
var blockStart = 0 // inclusive
var blockEnd = 0 // exclusive
@@ -542,23 +548,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
val updateEvent = Any()
val completedEvent = Any()
- // throttle adapter updates using flow; the flow first emits when enough preview
- // elements is loaded to fill the viewport and then each time a subsequent block of
- // previews is loaded
+ // collects updates from [reportFlow] throttling adapter updates;
scope.launch(Dispatchers.Main) {
reportFlow
.takeWhile { it !== completedEvent }
.throttle(ADAPTER_UPDATE_INTERVAL_MS)
- .onCompletion { cause ->
- if (cause == null) {
- onCompletion()
- }
- }
.collect {
val updates = ArrayList<Preview>(blockEnd - blockStart)
while (blockStart < blockEnd) {
- if (previewWidths[blockStart] > 0) {
- updates.add(previews[blockStart])
+ if (previewInfos[blockStart].width > 0) {
+ updates.add(previewInfos[blockStart].preview)
}
blockStart++
}
@@ -566,57 +565,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView {
onUpdate(updates)
}
}
+ onCompletion()
}
+ // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow]
+ // when a next sequential block of preview aspect ratios is loaded: initially emits when
+ // enough preview elements is loaded to fill the viewport.
scope.launch {
var blockWidth = 0
var isFirstBlock = true
- var nextIdx = 0
- List<Job>(4) {
- launch {
- while (true) {
- val i = nextIdx++
- if (i >= previews.size) break
- val preview = previews[i]
-
- previewWidths[i] =
- runCatching {
- // TODO: decide on adding a timeout
- imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
- previewSizeUpdater(
- preview,
- bitmap.width,
- bitmap.height
- )
- }
- ?: 0
- }
- .getOrDefault(0)
-
- if (blockEnd != i) continue
- while (
- blockEnd < previewWidths.size && previewWidths[blockEnd] >= 0
- ) {
- blockWidth += previewWidths[blockEnd]
- blockEnd++
- }
- if (isFirstBlock) {
- if (blockWidth >= maxWidth) {
- isFirstBlock = false
- // notify that the preview now can be displayed
- reportFlow.emit(updateEvent)
+
+ val jobs = ArrayList<Job>()
+ previews.collect { preview ->
+ val i = previewInfos.size
+ val pair = PreviewWidthInfo(preview)
+ previewInfos.add(pair)
+
+ val job = launch {
+ pair.width =
+ runCatching {
+ // TODO: decide on adding a timeout. The worst case I can
+ // imagine is one of the first images never loads so we never
+ // fill the initial viewport and does not show the previews at
+ // all.
+ imageLoader(preview.uri, isFirstBlock)?.let { bitmap ->
+ previewSizeUpdater(preview, bitmap.width, bitmap.height)
}
- } else {
- reportFlow.emit(updateEvent)
+ ?: 0
}
+ .getOrDefault(0)
+
+ if (i == blockEnd) {
+ while (
+ blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0
+ ) {
+ blockWidth += previewInfos[blockEnd].width
+ blockEnd++
+ }
+ if (isFirstBlock && blockWidth >= maxWidth) {
+ isFirstBlock = false
+ }
+ if (!isFirstBlock) {
+ reportFlow.emit(updateEvent)
}
}
}
- .joinAll()
+ jobs.add(job)
+ }
+ jobs.joinAll()
// in case all previews have failed to load
reportFlow.emit(updateEvent)
reportFlow.emit(completedEvent)
}
}
}
+
+ private class PreviewWidthInfo(val preview: Preview) {
+ // -1 encodes that the preview has not been processed,
+ // 0 means failed, > 0 is a preview width
+ var width: Int = -1
+ }
}
diff --git a/java/tests/src/com/android/intentresolver/TestContentProvider.kt b/java/tests/src/com/android/intentresolver/TestContentProvider.kt
index b3b53baa..426f9af2 100644
--- a/java/tests/src/com/android/intentresolver/TestContentProvider.kt
+++ b/java/tests/src/com/android/intentresolver/TestContentProvider.kt
@@ -30,15 +30,23 @@ class TestContentProvider : ContentProvider() {
sortOrder: String?
): Cursor? = null
- override fun getType(uri: Uri): String?
- = runCatching {
- uri.getQueryParameter("mimeType")
- }.getOrNull()
+ override fun getType(uri: Uri): String? =
+ runCatching { uri.getQueryParameter(PARAM_MIME_TYPE) }.getOrNull()
- override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>?
- = runCatching {
- uri.getQueryParameter("streamType")?.let { arrayOf(it) }
- }.getOrNull()
+ override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? {
+ val delay =
+ runCatching { uri.getQueryParameter(PARAM_STREAM_TYPE_TIMEOUT)?.toLong() ?: 0L }
+ .getOrDefault(0L)
+ if (delay > 0) {
+ try {
+ Thread.sleep(delay)
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ return runCatching { uri.getQueryParameter(PARAM_STREAM_TYPE)?.let { arrayOf(it) } }
+ .getOrNull()
+ }
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
@@ -52,4 +60,10 @@ class TestContentProvider : ContentProvider() {
): Int = 0
override fun onCreate(): Boolean = true
-} \ No newline at end of file
+
+ companion object {
+ const val PARAM_MIME_TYPE = "mimeType"
+ const val PARAM_STREAM_TYPE = "streamType"
+ const val PARAM_STREAM_TYPE_TIMEOUT = "streamTypeTo"
+ }
+}
diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
index 28a45051..5709c912 100644
--- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
+++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java
@@ -136,6 +136,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -1042,6 +1046,63 @@ public class UnbundledChooserActivityTest {
}
@Test
+ public void testPartiallyLoadedMetadata_previewIsShownForTheLoadedPart()
+ throws InterruptedException {
+ Uri imgOneUri = createTestContentProviderUri("image/png", null);
+ Uri imgTwoUri = createTestContentProviderUri("image/png", null)
+ .buildUpon()
+ .path("image-2.png")
+ .build();
+ Uri docUri = createTestContentProviderUri("application/pdf", "image/png", 3_000);
+ ArrayList<Uri> uris = new ArrayList<>(2);
+ // two large previews to fill the screen and be presented right away and one
+ // document that would be delayed by the URI metadata reading
+ uris.add(imgOneUri);
+ uris.add(imgTwoUri);
+ uris.add(docUri);
+
+ Intent sendIntent = createSendUriIntentWithPreview(uris);
+ Map<Uri, Bitmap> bitmaps = new HashMap<>();
+ bitmaps.put(imgOneUri, createWideBitmap(Color.RED));
+ bitmaps.put(imgTwoUri, createWideBitmap(Color.GREEN));
+ bitmaps.put(docUri, createWideBitmap(Color.BLUE));
+ ChooserActivityOverrideData.getInstance().imageLoader =
+ new TestPreviewImageLoader(bitmaps);
+
+ List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2);
+ setupResolverControllers(resolvedComponentInfos);
+
+ assertThat(launchActivityWithTimeout(Intent.createChooser(sendIntent, null), 1_000))
+ .isTrue();
+ waitForIdle();
+
+ onView(withId(R.id.scrollable_image_preview))
+ .check((view, exception) -> {
+ if (exception != null) {
+ throw exception;
+ }
+ RecyclerView recyclerView = (RecyclerView) view;
+ assertThat(recyclerView.getChildCount()).isAtLeast(1);
+ // the first view is a preview
+ View imageView = recyclerView.getChildAt(0).findViewById(R.id.image);
+ assertThat(imageView).isNotNull();
+ })
+ .perform(RecyclerViewActions.scrollToLastPosition())
+ .check((view, exception) -> {
+ if (exception != null) {
+ throw exception;
+ }
+ RecyclerView recyclerView = (RecyclerView) view;
+ assertThat(recyclerView.getChildCount()).isAtLeast(1);
+ // check that the last view is a loading indicator
+ View loadingIndicator =
+ recyclerView.getChildAt(recyclerView.getChildCount() - 1);
+ assertThat(loadingIndicator).isNotNull();
+ });
+ waitForIdle();
+ }
+
+ @Test
public void testImageAndTextPreview() {
final Uri uri = createTestContentProviderUri("image/png", null);
final String sharedText = "text-" + System.currentTimeMillis();
@@ -2641,15 +2702,25 @@ public class UnbundledChooserActivityTest {
private Uri createTestContentProviderUri(
@Nullable String mimeType, @Nullable String streamType) {
+ return createTestContentProviderUri(mimeType, streamType, 0);
+ }
+
+ private Uri createTestContentProviderUri(
+ @Nullable String mimeType, @Nullable String streamType, long streamTypeTimeout) {
String packageName =
InstrumentationRegistry.getInstrumentation().getContext().getPackageName();
Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png")
.buildUpon();
if (mimeType != null) {
- builder.appendQueryParameter("mimeType", mimeType);
+ builder.appendQueryParameter(TestContentProvider.PARAM_MIME_TYPE, mimeType);
}
if (streamType != null) {
- builder.appendQueryParameter("streamType", streamType);
+ builder.appendQueryParameter(TestContentProvider.PARAM_STREAM_TYPE, streamType);
+ }
+ if (streamTypeTimeout > 0) {
+ builder.appendQueryParameter(
+ TestContentProvider.PARAM_STREAM_TYPE_TIMEOUT,
+ Long.toString(streamTypeTimeout));
}
return builder.build();
}
@@ -2779,11 +2850,44 @@ public class UnbundledChooserActivityTest {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
}
+ private boolean launchActivityWithTimeout(Intent intent, long timeout)
+ throws InterruptedException {
+ final int initialState = 0;
+ final int completedState = 1;
+ final int timeoutState = 2;
+ final AtomicInteger state = new AtomicInteger(initialState);
+ final CountDownLatch cdl = new CountDownLatch(1);
+
+ ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
+ try {
+ executor.execute(() -> {
+ mActivityRule.launchActivity(intent);
+ state.compareAndSet(initialState, completedState);
+ cdl.countDown();
+ });
+ executor.schedule(
+ () -> {
+ state.compareAndSet(initialState, timeoutState);
+ cdl.countDown();
+ },
+ timeout,
+ TimeUnit.MILLISECONDS);
+ cdl.await();
+ return state.get() == completedState;
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
private Bitmap createBitmap() {
return createBitmap(200, 200);
}
private Bitmap createWideBitmap() {
+ return createWideBitmap(Color.RED);
+ }
+
+ private Bitmap createWideBitmap(int bgColor) {
WindowManager windowManager = InstrumentationRegistry.getInstrumentation()
.getTargetContext()
.getSystemService(WindowManager.class);
@@ -2792,15 +2896,19 @@ public class UnbundledChooserActivityTest {
Rect bounds = windowManager.getMaximumWindowMetrics().getBounds();
width = bounds.width() + 200;
}
- return createBitmap(width, 100);
+ return createBitmap(width, 100, bgColor);
}
private Bitmap createBitmap(int width, int height) {
+ return createBitmap(width, height, Color.RED);
+ }
+
+ private Bitmap createBitmap(int width, int height, int bgColor) {
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint();
- paint.setColor(Color.RED);
+ paint.setColor(bgColor);
paint.setStyle(Paint.Style.FILL);
canvas.drawPaint(paint);
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
index 9bfd2052..008cc162 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
+++ b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt
@@ -20,7 +20,7 @@ import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.Lifecycle
-import com.android.intentresolver.any
+import com.android.intentresolver.TestLifecycleOwner
import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
@@ -28,13 +28,14 @@ import com.android.intentresolver.widget.ActionRow
import com.android.intentresolver.widget.ImagePreviewView
import com.google.common.truth.Truth.assertThat
import java.util.function.Consumer
+import kotlinx.coroutines.flow.MutableSharedFlow
import org.junit.Test
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
class ChooserContentPreviewUiTest {
- private val lifecycle = mock<Lifecycle>()
+ private val lifecycleOwner = TestLifecycleOwner()
private val previewData = mock<PreviewDataProvider>()
private val headlineGenerator = mock<HeadlineGenerator>()
private val imageLoader =
@@ -64,7 +65,7 @@ class ChooserContentPreviewUiTest {
whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT)
val testSubject =
ChooserContentPreviewUi(
- lifecycle,
+ lifecycleOwner.lifecycle,
previewData,
Intent(Intent.ACTION_VIEW),
imageLoader,
@@ -83,7 +84,7 @@ class ChooserContentPreviewUiTest {
whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE)
val testSubject =
ChooserContentPreviewUi(
- lifecycle,
+ lifecycleOwner.lifecycle,
previewData,
Intent(Intent.ACTION_SEND),
imageLoader,
@@ -104,9 +105,10 @@ class ChooserContentPreviewUiTest {
whenever(previewData.uriCount).thenReturn(2)
whenever(previewData.firstFileInfo)
.thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
+ whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow())
val testSubject =
ChooserContentPreviewUi(
- lifecycle,
+ lifecycleOwner.lifecycle,
previewData,
Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") },
imageLoader,
@@ -116,7 +118,7 @@ class ChooserContentPreviewUiTest {
)
assertThat(testSubject.mContentPreviewUi)
.isInstanceOf(FilesPlusTextContentPreviewUi::class.java)
- verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any())
+ verify(previewData, times(1)).imagePreviewFileInfoFlow
verify(transitionCallback, times(1)).onAllTransitionElementsReady()
}
@@ -127,9 +129,10 @@ class ChooserContentPreviewUiTest {
whenever(previewData.uriCount).thenReturn(2)
whenever(previewData.firstFileInfo)
.thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build())
+ whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow())
val testSubject =
ChooserContentPreviewUi(
- lifecycle,
+ lifecycleOwner.lifecycle,
previewData,
Intent(Intent.ACTION_SEND),
imageLoader,
@@ -140,7 +143,7 @@ class ChooserContentPreviewUiTest {
assertThat(testSubject.preferredContentPreview)
.isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java)
- verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any())
+ verify(previewData, times(1)).imagePreviewFileInfoFlow
verify(transitionCallback, never()).onAllTransitionElementsReady()
}
}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
index 145b89ad..6599baa9 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
+++ b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt
@@ -22,18 +22,15 @@ import android.database.MatrixCursor
import android.media.MediaMetadata
import android.net.Uri
import android.provider.DocumentsContract
-import androidx.lifecycle.Lifecycle
-import com.android.intentresolver.TestLifecycleOwner
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
+import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Before
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.mockito.Mockito.any
import org.mockito.Mockito.never
@@ -44,27 +41,13 @@ import org.mockito.Mockito.verify
class PreviewDataProviderTest {
private val contentResolver = mock<ContentInterface>()
private val mimeTypeClassifier = DefaultMimeTypeClassifier
-
- private val lifecycleOwner = TestLifecycleOwner()
- private val dispatcher = UnconfinedTestDispatcher()
-
- @Before
- fun setup() {
- Dispatchers.setMain(dispatcher)
- lifecycleOwner.state = Lifecycle.State.CREATED
- }
-
- @After
- fun cleanup() {
- lifecycleOwner.state = Lifecycle.State.DESTROYED
- Dispatchers.resetMain()
- }
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
@Test
fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() {
val targetIntent = Intent(Intent.ACTION_VIEW)
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
verify(contentResolver, never()).getType(any())
@@ -73,14 +56,14 @@ class PreviewDataProviderTest {
@Test
fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/notes.txt")
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
+ val targetIntent =
+ Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
}
whenever(contentResolver.getType(uri)).thenReturn("text/plain")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -90,12 +73,9 @@ class PreviewDataProviderTest {
@Test
fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() {
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
- type = "image/png"
- }
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { type = "image/png" }
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT)
verify(contentResolver, never()).getType(any())
@@ -104,13 +84,10 @@ class PreviewDataProviderTest {
@Test
fun test_sendSingleImage_resolvesToImagePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/image.png")
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
whenever(contentResolver.getType(uri)).thenReturn("image/png")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -122,13 +99,10 @@ class PreviewDataProviderTest {
@Test
fun test_sendSingleNonImage_resolvesToFilePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/paper.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -141,14 +115,13 @@ class PreviewDataProviderTest {
fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/image.png")
val targetIntent =
- Intent(Intent.ACTION_SEND)
- .apply {
- type = "image/png"
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure"))
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -161,17 +134,16 @@ class PreviewDataProviderTest {
fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/image.png")
val targetIntent =
- Intent(Intent.ACTION_SEND)
- .apply {
- type = "image/png"
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ }
whenever(contentResolver.getStreamTypes(uri, "*/*"))
.thenThrow(SecurityException("test failure"))
whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null))
.thenThrow(SecurityException("test failure"))
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -183,14 +155,11 @@ class PreviewDataProviderTest {
@Test
fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() {
val uri = Uri.parse("content://org.pkg.app/paper.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
whenever(contentResolver.getStreamTypes(uri, "*/*"))
.thenReturn(arrayOf("application/pdf", "image/png"))
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -221,15 +190,12 @@ class PreviewDataProviderTest {
private fun testMetadataToImagePreview(columns: Array<String>, values: Array<Any>) {
val uri = Uri.parse("content://org.pkg.app/test.pdf")
- val targetIntent = Intent(Intent.ACTION_SEND)
- .apply {
- putExtra(Intent.EXTRA_STREAM, uri)
- }
+ val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) }
whenever(contentResolver.getType(uri)).thenReturn("application/pdf")
whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null))
.thenReturn(MatrixCursor(columns).apply { addRow(values) })
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(1)
@@ -243,20 +209,19 @@ class PreviewDataProviderTest {
val uri1 = Uri.parse("content://org.pkg.app/test.png")
val uri2 = Uri.parse("content://org.pkg.app/test.jpg")
val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE)
- .apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
whenever(contentResolver.getType(uri1)).thenReturn("image/png")
whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(2)
@@ -273,18 +238,17 @@ class PreviewDataProviderTest {
whenever(contentResolver.getType(uri1)).thenReturn("image/png")
whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE)
- .apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(2)
@@ -299,21 +263,20 @@ class PreviewDataProviderTest {
val uri1 = Uri.parse("content://org.pkg.app/test.mp4")
val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE)
- .apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4")
whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png"))
whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE)
assertThat(testSubject.uriCount).isEqualTo(2)
@@ -327,20 +290,19 @@ class PreviewDataProviderTest {
val uri1 = Uri.parse("content://org.pkg.app/test.html")
val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
val targetIntent =
- Intent(Intent.ACTION_SEND_MULTIPLE)
- .apply {
- putExtra(
- Intent.EXTRA_STREAM,
- ArrayList<Uri>().apply {
- add(uri1)
- add(uri2)
- }
- )
- }
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
whenever(contentResolver.getType(uri1)).thenReturn("text/html")
whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
val testSubject =
- PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher)
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE)
assertThat(testSubject.uriCount).isEqualTo(2)
@@ -348,4 +310,40 @@ class PreviewDataProviderTest {
assertThat(testSubject.firstFileInfo?.previewUri).isNull()
verify(contentResolver, times(2)).getType(any())
}
+
+ @Test
+ fun test_imagePreviewFileInfoFlow_dataLoadedOnce() =
+ testScope.runTest {
+ val uri1 = Uri.parse("content://org.pkg.app/test.html")
+ val uri2 = Uri.parse("content://org.pkg.app/test.pdf")
+ val targetIntent =
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(
+ Intent.EXTRA_STREAM,
+ ArrayList<Uri>().apply {
+ add(uri1)
+ add(uri2)
+ }
+ )
+ }
+ whenever(contentResolver.getType(uri1)).thenReturn("text/html")
+ whenever(contentResolver.getType(uri2)).thenReturn("application/pdf")
+ whenever(contentResolver.getStreamTypes(uri1, "*/*"))
+ .thenReturn(arrayOf("text/html", "image/jpeg"))
+ whenever(contentResolver.getStreamTypes(uri2, "*/*"))
+ .thenReturn(arrayOf("application/pdf", "image/png"))
+ val testSubject =
+ PreviewDataProvider(testScope, targetIntent, contentResolver, mimeTypeClassifier)
+
+ val fileInfoListOne = testSubject.imagePreviewFileInfoFlow.toList()
+ val fileInfoListTwo = testSubject.imagePreviewFileInfoFlow.toList()
+
+ assertThat(fileInfoListOne).hasSize(2)
+ assertThat(fileInfoListOne).containsAtLeastElementsIn(fileInfoListTwo).inOrder()
+
+ verify(contentResolver, times(1)).getType(uri1)
+ verify(contentResolver, times(1)).getStreamTypes(uri1, "*/*")
+ verify(contentResolver, times(1)).getType(uri2)
+ verify(contentResolver, times(1)).getStreamTypes(uri2, "*/*")
+ }
}
diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
index 08331209..e7de0b7b 100644
--- a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
+++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt
@@ -25,6 +25,13 @@ import com.android.intentresolver.R.layout.chooser_grid
import com.android.intentresolver.mock
import com.android.intentresolver.whenever
import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.anyInt
@@ -33,6 +40,7 @@ import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class UnifiedContentPreviewUiTest {
+ private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher())
private val actionFactory =
mock<ChooserContentPreviewUi.ActionFactory> {
whenever(createCustomActions()).thenReturn(emptyList())
@@ -129,24 +137,30 @@ class UnifiedContentPreviewUiTest {
}
private fun testLoadingHeadline(intentMimeType: String, files: List<FileInfo>?) {
- val testSubject =
- UnifiedContentPreviewUi(
- /*isSingleImage=*/ false,
- intentMimeType,
- actionFactory,
- imageLoader,
- DefaultMimeTypeClassifier,
- object : TransitionElementStatusCallback {
- override fun onTransitionElementReady(name: String) = Unit
- override fun onAllTransitionElementsReady() = Unit
- },
- /*itemCount=*/ 2,
- headlineGenerator
- )
- val layoutInflater = LayoutInflater.from(context)
- val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup
-
- files?.let(testSubject::setFiles)
- testSubject.display(context.resources, LayoutInflater.from(context), gridLayout)
+ testScope.runTest {
+ val endMarker = FileInfo.Builder(Uri.EMPTY).build()
+ val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1)
+ val testSubject =
+ UnifiedContentPreviewUi(
+ testScope,
+ /*isSingleImage=*/ false,
+ intentMimeType,
+ actionFactory,
+ imageLoader,
+ DefaultMimeTypeClassifier,
+ object : TransitionElementStatusCallback {
+ override fun onTransitionElementReady(name: String) = Unit
+ override fun onAllTransitionElementsReady() = Unit
+ },
+ files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker },
+ /*itemCount=*/ 2,
+ headlineGenerator
+ )
+ val layoutInflater = LayoutInflater.from(context)
+ val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup
+
+ testSubject.display(context.resources, LayoutInflater.from(context), gridLayout)
+ emptySourceFlow.tryEmit(endMarker)
+ }
}
}
diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
index a0211308..4f4223c0 100644
--- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
+++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt
@@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
@@ -67,7 +68,13 @@ class BatchPreviewLoaderTest {
val uriTwo = createUri(2)
imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne))
val testSubject =
- BatchPreviewLoader(imageLoader, previews(uriOne, uriTwo), 0, onUpdate, onCompletion)
+ BatchPreviewLoader(
+ imageLoader,
+ previews(uriOne, uriTwo),
+ totalItemCount = 2,
+ onUpdate,
+ onCompletion
+ )
testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
dispatcher.scheduler.advanceUntilIdle()
@@ -87,7 +94,7 @@ class BatchPreviewLoaderTest {
BatchPreviewLoader(
imageLoader,
previews(uriOne, uriTwo, uriThree),
- 0,
+ totalItemCount = 3,
onUpdate,
onCompletion
)
@@ -115,7 +122,7 @@ class BatchPreviewLoaderTest {
}
imageLoader.setUriLoadingOrder(*loadingOrder)
val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), 0, onUpdate, onCompletion)
+ BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion)
testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
dispatcher.scheduler.advanceUntilIdle()
@@ -144,7 +151,7 @@ class BatchPreviewLoaderTest {
val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) }
imageLoader.setUriLoadingOrder(*loadingOrder)
val testSubject =
- BatchPreviewLoader(imageLoader, previews(*uris), 0, onUpdate, onCompletion)
+ BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion)
testSubject.loadAspectRatios(200) { _, _, _ -> 100 }
dispatcher.scheduler.advanceUntilIdle()
@@ -161,9 +168,11 @@ class BatchPreviewLoaderTest {
private fun fail(uri: Uri) = uri to false
private fun succeed(uri: Uri) = uri to true
private fun previews(vararg uris: Uri) =
- uris.fold(ArrayList<Preview>(uris.size)) { acc, uri ->
- acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) }
- }
+ uris
+ .fold(ArrayList<Preview>(uris.size)) { acc, uri ->
+ acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) }
+ }
+ .asFlow()
}
private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? {