diff options
Diffstat (limited to 'java')
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? { |