diff options
| author | 2023-08-17 20:58:55 +0000 | |
|---|---|---|
| committer | 2023-08-17 20:58:55 +0000 | |
| commit | 78e502c0507818e874b133fac66e1d207d550a60 (patch) | |
| tree | bccb3ea024c1094dd0d87f74d86671ef691b5b8c /java | |
| parent | e1d5fbe34cdd95ef746bc915715a6699cd4f0d0d (diff) | |
| parent | 3864b3fb003144dad57924ae0143fa5a4df6c849 (diff) | |
Merge "Add loading state to the image preview UI" into udc-qpr-dev
Diffstat (limited to 'java')
6 files changed, 273 insertions, 56 deletions
diff --git a/java/res/layout/image_preview_loading_item.xml b/java/res/layout/image_preview_loading_item.xml new file mode 100644 index 00000000..85020e9a --- /dev/null +++ b/java/res/layout/image_preview_loading_item.xml @@ -0,0 +1,32 @@ +<!-- + ~ 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. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height_tall"> + + <ProgressBar + android:id="@+id/loading_indicator" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateTint="?androidprv:attr/materialColorPrimary" + android:indeterminateTintMode="src_in" /> + +</FrameLayout> diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index e8367c4e..18cbb8af 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -163,10 +163,12 @@ public final class ChooserContentPreviewUi { UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi( isSingleImageShare, + targetIntent.getType(), actionFactory, imageLoader, typeClassifier, transitionElementStatusCallback, + previewData.getUriCount(), headlineGenerator); previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles); return unifiedContentPreviewUi; diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 6385f2b6..5db5020e 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -37,11 +37,14 @@ import java.util.Objects; class UnifiedContentPreviewUi extends ContentPreviewUi { private final boolean mShowEditAction; + @Nullable + private final String mIntentMimeType; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; + private final int mItemCount; @Nullable private List<FileInfo> mFiles; @Nullable @@ -49,16 +52,20 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { UnifiedContentPreviewUi( boolean isSingleImage, + @Nullable String intentMimeType, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, + int itemCount, HeadlineGenerator headlineGenerator) { mShowEditAction = isSingleImage; + mIntentMimeType = intentMimeType; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; + mItemCount = itemCount; mHeadlineGenerator = headlineGenerator; } @@ -96,11 +103,19 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ScrollableImagePreviewView imagePreview = mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setImageLoader(mImageLoader); imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); if (mFiles != null) { updatePreviewWithFiles(mContentPreviewView, mFiles); + } else { + displayHeadline( + mContentPreviewView, + mItemCount, + mTypeClassifier.isImageType(mIntentMimeType), + mTypeClassifier.isVideoType(mIntentMimeType)); + imagePreview.setLoading(mItemCount); } return mContentPreviewView; @@ -138,14 +153,18 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } } - imagePreview.setPreviews(previews, count - previews.size(), mImageLoader); + imagePreview.setPreviews(previews, count - previews.size()); + displayHeadline(contentPreviewView, count, allImages, allVideos); + } + private void displayHeadline( + ViewGroup layout, int count, boolean allImages, boolean allVideos) { if (allImages) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getImagesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getVideosHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getVideosHeadline(count)); } else { - displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count)); } } } diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 583a2887..d9844d7b 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -158,22 +158,28 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } - fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: CachingImageLoader) { - previewAdapter.reset(0, imageLoader) + fun setImageLoader(imageLoader: CachingImageLoader) { + previewAdapter.imageLoader = imageLoader + } + + fun setLoading(totalItemCount: Int) { + previewAdapter.reset(totalItemCount) + } + + fun setPreviews(previews: List<Preview>, otherItemCount: Int) { + previewAdapter.reset(previews.size + otherItemCount) batchLoader?.cancel() batchLoader = BatchPreviewLoader( - imageLoader, + previewAdapter.imageLoader ?: error("Image loader is not set"), previews, otherItemCount, - onReset = { totalItemCount -> - previewAdapter.reset(totalItemCount, imageLoader) - }, onUpdate = previewAdapter::addPreviews, onCompletion = { if (!previewAdapter.hasPreviews) { onNoPreviewCallback?.run() } + previewAdapter.markLoaded() } ) .apply { @@ -262,10 +268,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { context.resources.getString(R.string.video_preview_a11y_description) private val filePreviewDescription = context.resources.getString(R.string.file_preview_a11y_description) - private var imageLoader: CachingImageLoader? = null + var imageLoader: CachingImageLoader? = null private var firstImagePos = -1 private var totalItemCount: Int = 0 + private var isLoading = false private val hasOtherItem get() = previews.size < totalItemCount val hasPreviews: Boolean @@ -273,61 +280,78 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { var transitionStatusElementCallback: TransitionElementStatusCallback? = null - fun reset(totalItemCount: Int, imageLoader: CachingImageLoader) { - this.imageLoader = imageLoader + fun reset(totalItemCount: Int) { firstImagePos = -1 previews.clear() this.totalItemCount = maxOf(0, totalItemCount) + isLoading = this.totalItemCount > 0 notifyDataSetChanged() } + fun markLoaded() { + if (!isLoading) return + isLoading = false + if (hasOtherItem) { + notifyItemChanged(previews.size) + } else { + notifyItemRemoved(previews.size) + } + } + fun addPreviews(newPreviews: Collection<Preview>) { if (newPreviews.isEmpty()) return val insertPos = previews.size val hadOtherItem = hasOtherItem + val wasEmpty = previews.isEmpty() previews.addAll(newPreviews) if (firstImagePos < 0) { val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } if (pos >= 0) firstImagePos = insertPos + pos } - notifyItemRangeInserted(insertPos, newPreviews.size) - when { - hadOtherItem && previews.size >= totalItemCount -> { - notifyItemRemoved(previews.size) - } - !hadOtherItem && previews.size < totalItemCount -> { - notifyItemInserted(previews.size) + if (wasEmpty) { + // we don't want any item animation in that case + notifyDataSetChanged() + } else { + notifyItemRangeInserted(insertPos, newPreviews.size) + when { + hadOtherItem && !hasOtherItem -> { + notifyItemRemoved(previews.size) + } + !hadOtherItem && hasOtherItem -> { + notifyItemInserted(previews.size) + } } } } override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { val view = LayoutInflater.from(context).inflate(itemType, parent, false) - return if (itemType == R.layout.image_preview_other_item) { - OtherItemViewHolder(view) - } else { - PreviewViewHolder( - view, - imagePreviewDescription, - videoPreviewDescription, - filePreviewDescription, - ) + return when (itemType) { + R.layout.image_preview_other_item -> OtherItemViewHolder(view) + R.layout.image_preview_loading_item -> LoadingItemViewHolder(view) + else -> + PreviewViewHolder( + view, + imagePreviewDescription, + videoPreviewDescription, + filePreviewDescription, + ) } } - override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0 + override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0 - override fun getItemViewType(position: Int): Int { - return if (position == previews.size) { - R.layout.image_preview_other_item - } else { - R.layout.image_preview_image_item + override fun getItemViewType(position: Int): Int = + when { + position == previews.size && isLoading -> R.layout.image_preview_loading_item + position == previews.size -> R.layout.image_preview_other_item + else -> R.layout.image_preview_image_item } - } override fun onBindViewHolder(vh: ViewHolder, position: Int) { when (vh) { is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size) + is LoadingItemViewHolder -> vh.bind() is PreviewViewHolder -> vh.bind( previews[position], @@ -466,6 +490,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun unbind() = Unit } + private class LoadingItemViewHolder(view: View) : ViewHolder(view) { + fun bind() = Unit + override fun unbind() = Unit + } + private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { @@ -487,7 +516,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private val imageLoader: CachingImageLoader, previews: List<Preview>, otherItemCount: Int, - private val onReset: (Int) -> Unit, private val onUpdate: (List<Preview>) -> Unit, private val onCompletion: () -> Unit, ) { @@ -527,9 +555,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } .collect { - if (blockStart == 0) { - onReset(totalItemCount) - } val updates = ArrayList<Preview>(blockEnd - blockStart) while (blockStart < blockEnd) { if (previewWidths[blockStart] > 0) { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt new file mode 100644 index 00000000..08331209 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +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 org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class UnifiedContentPreviewUiTest { + private val actionFactory = + mock<ChooserContentPreviewUi.ActionFactory> { + whenever(createCustomActions()).thenReturn(emptyList()) + } + private val imageLoader = mock<ImageLoader>() + private val headlineGenerator = + mock<HeadlineGenerator> { + whenever(getImagesHeadline(anyInt())).thenReturn("Image Headline") + whenever(getVideosHeadline(anyInt())).thenReturn("Video Headline") + whenever(getFilesHeadline(anyInt())).thenReturn("Files Headline") + } + + private val context + get() = getInstrumentation().getContext() + + @Test + fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("image/*", files = null) + + verify(headlineGenerator, times(1)).getImagesHeadline(2) + } + + @Test + fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("video/*", files = null) + + verify(headlineGenerator, times(1)).getVideosHeadline(2) + } + + @Test + fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("application/pdf", files = null) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { + testLoadingHeadline("*/*", files = null) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayImagesWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("image/jpeg").build(), + ) + testLoadingHeadline("image/*", files) + + verify(headlineGenerator, times(1)).getImagesHeadline(2) + } + + @Test + fun test_displayVideosWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingHeadline("video/*", files) + + verify(headlineGenerator, times(1)).getVideosHeadline(2) + } + + @Test + fun test_displayImagesAndVideosWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingHeadline("*/*", files) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + @Test + fun test_displayDocumentsWithUriMetadataSet_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + ) + testLoadingHeadline("application/pdf", files) + + verify(headlineGenerator, times(1)).getFilesHeadline(2) + } + + 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) + } +} diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt index e65cba5f..a0211308 100644 --- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt +++ b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt @@ -47,7 +47,6 @@ class BatchPreviewLoaderTest { private val dispatcher = UnconfinedTestDispatcher() private val testScope = CoroutineScope(dispatcher) private val onCompletion = mock<() -> Unit>() - private val onReset = mock<(Int) -> Unit>() private val onUpdate = mock<(List<Preview>) -> Unit>() @Before @@ -68,19 +67,11 @@ class BatchPreviewLoaderTest { val uriTwo = createUri(2) imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne)) val testSubject = - BatchPreviewLoader( - imageLoader, - previews(uriOne, uriTwo), - 0, - onReset, - onUpdate, - onCompletion - ) + BatchPreviewLoader(imageLoader, previews(uriOne, uriTwo), 0, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(2) val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriTwo).inOrder() } @@ -97,7 +88,6 @@ class BatchPreviewLoaderTest { imageLoader, previews(uriOne, uriTwo, uriThree), 0, - onReset, onUpdate, onCompletion ) @@ -105,7 +95,6 @@ class BatchPreviewLoaderTest { dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(3) val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } assertThat(list).containsExactly(uriOne, uriThree).inOrder() } @@ -126,12 +115,11 @@ class BatchPreviewLoaderTest { } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) + BatchPreviewLoader(imageLoader, previews(*uris), 0, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) val list = captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } @@ -156,12 +144,11 @@ class BatchPreviewLoaderTest { val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) + BatchPreviewLoader(imageLoader, previews(*uris), 0, onUpdate, onCompletion) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) val list = captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } |