summaryrefslogtreecommitdiff
path: root/java/src
diff options
context:
space:
mode:
author Andrey Epin <ayepin@google.com> 2023-08-11 10:45:36 -0700
committer Andrey Epin <ayepin@google.com> 2023-08-17 21:24:58 -0700
commitea59592a728c2fb00070f15873e85939b806a3d9 (patch)
tree9fd811c70b55087cf2fb13a4463673eee54746b9 /java/src
parentec63bbe163af26cf64e8b122dfb923d1a70a62f7 (diff)
Load preivews eagerly
Currently, content preview is not started until the metadata for all the shared URIs is loaded. This CL changes it to eagerly loaded previews i.e. previews are getting loaded as soon as the corresponding metadata becomes available. This is achieved by: * Adding support for a Flow as a preview source into ScrollableImgePreviewView (patchset #1); Specifically, as a Flow may never complete, the flow collection is cancelled in `onDetachFromWindow` to avoid possible memory leaks. The view is used inside a RecyclerView (the single-profile case) and can be attached and detached multiple times per one `setPreviews` call. Thus BatchPreviewLoader is made relaunchable and captures a notion of a pending loading job; `#batchLoader` value gets updated accordingly. (patchset #8) * Make PreviewDataProvider expose a Flow of shared URIs metadata, FileInfo (patchset #2, #3); * Make content preview classes to pass the Flow from PreviewDataProvider to ScrollableImagePreviewView (patchset #4). Bug: 292157413 Test: manual testing with ShareTest app: with and without artificial image loading delays, orientation changes and on single- and multi-profile cases. Test: unit tests Test: integration test Change-Id: Ib663bab8917624493a9ba619e64e4cb81fa35a93
Diffstat (limited to 'java/src')
-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
7 files changed, 239 insertions, 169 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
+ }
}