From 53d920eccd9efeddae4a36c41ab31717cbc330ec Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 17 Jun 2024 17:32:10 -0700 Subject: Reclalculate drawer offset even when the targets list has not changed Shareousel has a variable-sized preview that affects the overall drawer offset. Therefore, we cannot early return from the offset calcualtion based solely on the condition that the targets list has not changed. Empirically, this introduces a minor overhead of the offset recalculation when the drawor is snapped to/from the top. Fix: 347316548 Test: manual testing with injected debug logging Flag: com.android.intentresolver.fix_missing_drawer_offset_calculation Change-Id: I69ec7a5e05fad0d6607e3614208b30910a3a17ba --- java/src/com/android/intentresolver/ChooserActivity.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index a5516fde..0fa5e758 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2258,7 +2258,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (isLayoutUpdated || insetsChanged - || mLastNumberOfChildren != recyclerView.getChildCount()) { + || mLastNumberOfChildren != recyclerView.getChildCount() + || mFeatureFlags.fixMissingDrawerOffsetCalculation()) { mCurrAvailableWidth = availableWidth; if (isLayoutUpdated) { // It is very important we call setAdapter from here. Otherwise in some cases @@ -2277,7 +2278,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return; } - if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged + && !mFeatureFlags.fixMissingDrawerOffsetCalculation()) { return; } -- cgit v1.2.3-59-g8ed1b From ee8e0bd6e2c21b6b1b28d3a81b73803c3b791342 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Fri, 21 Jun 2024 17:45:41 +0000 Subject: Remove chooser_album_text flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It has launched. Bug: 323380224 Test: Test album text functionality. Flag:  android.service.chooser.chooser_album_text Change-Id: I5c03dce5b7d362f008f4ab834fb6631cecdee386 --- .../intentresolver/ui/viewmodel/ChooserRequestReader.kt | 13 ++++--------- .../domain/update/SelectionChangeCallbackImplTest.kt | 1 - .../intentresolver/ui/viewmodel/ChooserRequestTest.kt | 2 -- 3 files changed, 4 insertions(+), 12 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index a9b6de7e..cb47c3de 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -95,8 +95,7 @@ fun readChooserRequest( val initialIntents = optional(array(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { it.maybeAddSendActionFlags() - } - ?: emptyList() + } ?: emptyList() val chosenComponentSender = optional(value(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) @@ -132,13 +131,9 @@ fun readChooserRequest( } val contentTypeHint = - if (flags.chooserAlbumText()) { - when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { - Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM - else -> ContentTypeHint.NONE - } - } else { - ContentTypeHint.NONE + when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE } val metadataText = diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index 91bbd151..9d36aa8f 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -69,7 +69,6 @@ class SelectionChangeCallbackImplTest { private val flags = FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) } diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt index 56c019fd..2b4a23e4 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -60,7 +60,6 @@ class ChooserRequestTest { private val fakeChooserServiceFlags = FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, false) setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) } @@ -244,7 +243,6 @@ class ChooserRequestTest { @Test fun testAlbumType() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_ALBUM_TEXT, true) val model = createActivityModel(Intent(ACTION_SEND)) model.intent.putExtra( Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, -- cgit v1.2.3-59-g8ed1b From bf864bdea2e89198f28c6f7e44ed0bde576bcb7d Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 26 Jun 2024 17:28:03 +0000 Subject: Remove enable_sharesheet_metadata_extra Test: atest ChooserRequestTest Test: atest SelectionChangeCallbackImplTest Bug: 318942069 Flag: android.service.chooser.enable_sharesheet_metadata_extra Change-Id: If7efc671a836723041905cb13467b71ac2cc39dd --- .../intentresolver/ChooserRequestParameters.java | 6 +---- .../domain/update/SelectionChangeCallback.kt | 8 ++----- .../ui/viewmodel/ChooserRequestReader.kt | 7 +----- .../update/SelectionChangeCallbackImplTest.kt | 28 +--------------------- .../ui/viewmodel/ChooserRequestTest.kt | 25 ++----------------- 5 files changed, 7 insertions(+), 67 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 06f56e3b..66bb66ef 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -159,11 +159,7 @@ public class ChooserRequestParameters { mChooserActions = getChooserActions(clientIntent); mModifyShareAction = getModifyShareAction(clientIntent); - if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) { - mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); - } else { - mMetadataText = null; - } + mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); } public Intent getTargetIntent() { diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt index 1d34dc75..479bff77 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -136,12 +136,8 @@ private fun readCallbackResponse( optional(value(key)) } val metadataText = - if (flags.enableSharesheetMetadataExtra()) { - bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> - optional(value(key)) - } - } else { - ValueUpdate.Absent + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value(key)) } ShareouselUpdate( diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index cb47c3de..f0f72489 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -136,12 +136,7 @@ fun readChooserRequest( else -> ContentTypeHint.NONE } - val metadataText = - if (flags.enableSharesheetMetadataExtra()) { - optional(value(EXTRA_METADATA_TEXT)) - } else { - null - } + val metadataText = optional(value(EXTRA_METADATA_TEXT)) ChooserRequest( targetIntent = targetIntent, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index 9d36aa8f..fd21ea3f 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -67,10 +67,7 @@ class SelectionChangeCallbackImplTest { private val contentResolver = mock() private val context = InstrumentationRegistry.getInstrumentation().context private val flags = - FakeChooserServiceFlags().apply { - setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) - } + FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } @Test fun testPayloadChangeCallbackContact() = runTest { @@ -376,31 +373,9 @@ class SelectionChangeCallbackImplTest { assertThat(result.metadataText).isEqualTo(Absent) } - @Test - fun testPayloadChangeCallbackUpdatesMetadataTextWithDisabledFlag_noUpdates() = runTest { - val metadataText = "[Metadata]" - whenever(contentResolver.call(any(), any(), any(), any())) - .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) - - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) - - val targetIntent = Intent(ACTION_SEND) - val result = testSubject.onSelectionChanged(targetIntent) - assertWithMessage("Callback result should not be null").that(result).isNotNull() - requireNotNull(result) - assertThat(result.customActions).isEqualTo(Absent) - assertThat(result.modifyShareAction).isEqualTo(Absent) - assertThat(result.alternateIntents).isEqualTo(Absent) - assertThat(result.callerTargets).isEqualTo(Absent) - assertThat(result.refinementIntentSender).isEqualTo(Absent) - assertThat(result.resultIntentSender).isEqualTo(Absent) - assertThat(result.metadataText).isEqualTo(Absent) - } - @Test fun testPayloadChangeCallbackUpdatesMetadataTextWithEnabledFlag_valueUpdated() = runTest { val metadataText = "[Metadata]" - flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) whenever(contentResolver.call(any(), any(), any(), any())) .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) @@ -421,7 +396,6 @@ class SelectionChangeCallbackImplTest { @Test fun testPayloadChangeCallbackProvidesInvalidData_invalidDataIgnored() = runTest { - flags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) whenever(contentResolver.call(any(), any(), any(), any())) .thenReturn( Bundle().apply { diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt index 2b4a23e4..2d5a44ed 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -58,10 +58,7 @@ private fun createActivityModel( class ChooserRequestTest { private val fakeChooserServiceFlags = - FakeChooserServiceFlags().apply { - setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) - } + FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } @Test fun missingIntent() { @@ -259,26 +256,8 @@ class ChooserRequestTest { } @Test - fun metadataText_whenFlagFalse_isNull() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, false) - val metadataText: CharSequence = "Test metadata text" - val model = - createActivityModel(targetIntent = Intent()).apply { - intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) - } - - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.metadataText).isNull() - } - - @Test - fun metadataText_whenFlagTrue_isPassedText() { + fun metadataText_isPassedText() { // Arrange - fakeChooserServiceFlags.setFlag(Flags.FLAG_ENABLE_SHARESHEET_METADATA_EXTRA, true) val metadataText: CharSequence = "Test metadata text" val model = createActivityModel(targetIntent = Intent()).apply { -- cgit v1.2.3-59-g8ed1b From 864d466559a5e576bbf02ba96b2850a8a30291e8 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Thu, 20 Jun 2024 16:30:13 -0700 Subject: Delete PreviewViewModel Merge PreviewViewModel functionality into ChooserViewModel. Make ImageLoader injectable; remove obsolete code. Bug: 348665058 Test: atest IntentResolver-tests-activity Test: atest IntentResolver-tests-unit Flag: EXEMPT refactor Change-Id: Icb18362763c19e97ac39cfa8112c9d0357f24e1e --- .../android/intentresolver/ChooserActivity.java | 18 +--- .../contentpreview/BasePreviewViewModel.kt | 35 -------- .../contentpreview/ImageLoaderModule.kt | 13 +-- .../contentpreview/PreviewDataProvider.kt | 13 +-- .../contentpreview/PreviewViewModel.kt | 98 ---------------------- .../ui/viewmodel/ChooserViewModel.kt | 17 ++++ .../ChooserActivityOverrideData.java | 3 - .../intentresolver/ChooserActivityTest.java | 3 - .../intentresolver/ChooserWrapperActivity.java | 9 -- .../intentresolver/TestContentPreviewViewModel.kt | 64 -------------- .../contentpreview/PreviewDataProviderTest.kt | 15 ++-- 11 files changed, 37 insertions(+), 251 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt delete mode 100644 java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt delete mode 100644 tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 0fa5e758..4d70f550 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -96,10 +96,8 @@ import com.android.intentresolver.ChooserRefinementManager.RefinementType; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.BasePreviewViewModel; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; -import com.android.intentresolver.contentpreview.PreviewViewModel; import com.android.intentresolver.data.model.ChooserRequest; import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; @@ -633,21 +631,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements finish(); } }); - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - previewViewModel.init( - mRequest.getTargetIntent(), - mRequest.getAdditionalContentUri(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); ChooserContentPreviewUi.ActionFactory actionFactory = decorateActionFactoryWithRefinement( createChooserActionFactory(mRequest.getTargetIntent())); mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), - previewViewModel.getPreviewDataProvider(), + mViewModel.getPreviewDataProvider(), mRequest.getTargetIntent(), - previewViewModel.getImageLoader(), + mViewModel.getImageLoader(), actionFactory, createModifyShareActionFactory(), mEnterTransitionAnimationDelegate, @@ -2108,11 +2099,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mPinnedSharedPrefs); } - @VisibleForTesting - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return PreviewViewModel.Companion.getFactory(); - } - private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement( ChooserContentPreviewUi.ActionFactory originalFactory) { if (!mFeatureFlags.refineSystemActions()) { diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt deleted file mode 100644 index dc36e584..00000000 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.content.Intent -import android.net.Uri -import androidx.annotation.MainThread -import androidx.lifecycle.ViewModel - -/** A contract for the preview view model. Added for testing. */ -abstract class BasePreviewViewModel : ViewModel() { - @get:MainThread abstract val previewDataProvider: PreviewDataProvider - @get:MainThread abstract val imageLoader: ImageLoader - - @MainThread - abstract fun init( - targetIntent: Intent, - additionalContentUri: Uri?, - isPayloadTogglingEnabled: Boolean, - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index 7035f765..17d05099 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -23,19 +23,14 @@ import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.hilt.android.components.ViewModelComponent @Module -@InstallIn(ActivityRetainedComponent::class) +@InstallIn(ViewModelComponent::class) interface ImageLoaderModule { - @Binds - @ActivityRetainedScoped - fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + @Binds fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader - @Binds - @ActivityRetainedScoped - fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader companion object { @Provides diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 96bb8258..9b2dbebf 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -32,6 +32,7 @@ import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREV import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT +import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.measurements.runTracing import com.android.intentresolver.util.ownedByCurrentUser import java.util.concurrent.atomic.AtomicInteger @@ -76,9 +77,7 @@ constructor( private val targetIntent: Intent, private val additionalContentUri: Uri?, private val contentResolver: ContentInterface, - // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted - // out - private val isPayloadTogglingEnabled: Boolean, + private val featureFlags: ChooserServiceFlags, private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { @@ -129,7 +128,7 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT - } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) { + } else if (featureFlags.chooserPayloadToggling() && shouldShowPayloadSelection()) { // TODO: replace with the proper flags injection CONTENT_PREVIEW_PAYLOAD_SELECTION } else { @@ -275,13 +274,16 @@ constructor( val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) } val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) + val supportsImageType: Boolean by lazy { contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null } val supportsThumbnail: Boolean get() = query.supportsThumbnail + val title: String get() = query.title + val iconUri: Uri? get() = query.iconUri @@ -326,8 +328,7 @@ constructor( } QueryResult(supportsThumbnail, title, iconUri) - } - ?: QueryResult() + } ?: QueryResult() } private class QueryResult( diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt deleted file mode 100644 index 6a729945..00000000 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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.app.Application -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import androidx.annotation.MainThread -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.R -import com.android.intentresolver.inject.Background -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.plus - -/** A view model for the preview logic */ -class PreviewViewModel( - private val contentResolver: ContentResolver, - // TODO: inject ImageLoader instead - private val thumbnailSize: Int, - @Background private val dispatcher: CoroutineDispatcher = Dispatchers.IO, -) : BasePreviewViewModel() { - private var targetIntent: Intent? = null - private var additionalContentUri: Uri? = null - private var isPayloadTogglingEnabled = false - - override val previewDataProvider by lazy { - val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } - PreviewDataProvider( - viewModelScope + dispatcher, - targetIntent, - additionalContentUri, - contentResolver, - isPayloadTogglingEnabled, - ) - } - - override val imageLoader by lazy { - ImagePreviewImageLoader( - viewModelScope + dispatcher, - thumbnailSize, - contentResolver, - cacheSize = 16 - ) - } - - // TODO: make the view model injectable and inject these dependencies instead - @MainThread - override fun init( - targetIntent: Intent, - additionalContentUri: Uri?, - isPayloadTogglingEnabled: Boolean, - ) { - if (this.targetIntent != null) return - this.targetIntent = targetIntent - this.additionalContentUri = additionalContentUri - this.isPayloadTogglingEnabled = isPayloadTogglingEnabled - } - - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: Class, - extras: CreationExtras - ): T { - val application: Application = checkNotNull(extras[APPLICATION_KEY]) - return PreviewViewModel( - application.contentResolver, - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ) - ) - as T - } - } - } -} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index c9cae3db..619e118a 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -15,10 +15,13 @@ */ package com.android.intentresolver.ui.viewmodel +import android.content.ContentInterface import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.PreviewDataProvider import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel @@ -38,6 +41,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.plus private const val TAG = "ChooserViewModel" @@ -58,6 +62,8 @@ constructor( */ val initialRequest: ValidationResult, private val chooserRequestRepository: Lazy, + private val contentResolver: ContentInterface, + val imageLoader: ImageLoader, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -86,6 +92,17 @@ constructor( val request: StateFlow get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + val previewDataProvider by lazy { + val chooserRequest = (initialRequest as Valid).value + PreviewDataProvider( + viewModelScope + bgDispatcher, + chooserRequest.targetIntent, + chooserRequest.additionalContentUri, + contentResolver, + flags, + ) + } + init { if (initialRequest is Invalid) { Log.w(TAG, "initialRequest is Invalid, initialization failed") diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java index 507ce3d7..311201cf 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -26,7 +26,6 @@ import android.database.Cursor; import android.os.UserHandle; import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ImageLoader; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -58,7 +57,6 @@ public class ChooserActivityOverrideData { public Boolean isVoiceInteraction; public Cursor resolverCursor; public boolean resolverForceException; - public ImageLoader imageLoader; public Resources resources; public boolean hasCrossProfileIntents; public boolean isQuietModeEnabled; @@ -68,7 +66,6 @@ public class ChooserActivityOverrideData { public void reset() { onSafelyStartInternalCallback = null; isVoiceInteraction = null; - imageLoader = null; resolverCursor = null; resolverForceException = false; resolverListController = mock(ChooserListController.class); diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index a8b8b2e9..620ac555 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -305,9 +305,6 @@ public class ChooserActivityTest { // values to the dependency graph at activity launch time. This allows replacing // arbitrary bindings per-test case if needed. mPackageManager = mContext.getPackageManager(); - - // TODO: inject image loader in the prod code and remove this override - ChooserActivityOverrideData.getInstance().imageLoader = mFakeImageLoader; } public ChooserActivityTest(boolean appPredictionAvailable) { diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 4b71aa29..8a8fe355 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -30,8 +30,6 @@ import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; -import androidx.lifecycle.ViewModelProvider; - import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; @@ -151,13 +149,6 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW return super.getResources(); } - @Override - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return TestContentPreviewViewModel.Companion.wrap( - super.createPreviewViewModelFactory(), - sOverrides.imageLoader); - } - @Override public Cursor queryResolver(ContentResolver resolver, Uri uri) { if (sOverrides.resolverCursor != null) { diff --git a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt deleted file mode 100644 index 8f246424..00000000 --- a/tests/shared/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 - -import android.content.Intent -import android.net.Uri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.contentpreview.BasePreviewViewModel -import com.android.intentresolver.contentpreview.ImageLoader - -/** A test content preview model that supports image loader override. */ -class TestContentPreviewViewModel( - private val viewModel: BasePreviewViewModel, - override val imageLoader: ImageLoader, -) : BasePreviewViewModel() { - - override val previewDataProvider - get() = viewModel.previewDataProvider - - override fun init( - targetIntent: Intent, - additionalContentUri: Uri?, - isPayloadTogglingEnabled: Boolean, - ) { - viewModel.init(targetIntent, additionalContentUri, isPayloadTogglingEnabled) - } - - companion object { - fun wrap( - factory: ViewModelProvider.Factory, - imageLoader: ImageLoader?, - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: Class, - extras: CreationExtras - ): T { - val wrapped = factory.create(modelClass, extras) as BasePreviewViewModel - return TestContentPreviewViewModel( - wrapped, - imageLoader ?: wrapped.imageLoader, - ) - as T - } - } - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index a2fb9693..370ee044 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -21,9 +21,9 @@ import android.content.Intent import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri -import android.platform.test.flag.junit.CheckFlagsRule -import android.platform.test.flag.junit.DeviceFlagsValueProvider import android.provider.DocumentsContract +import android.service.chooser.FakeFeatureFlagsImpl +import android.service.chooser.Flags import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope @@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -46,7 +45,8 @@ class PreviewDataProviderTest { private val contentResolver = mock() private val mimeTypeClassifier = DefaultMimeTypeClassifier private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) - @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() + private val featureFlags = + FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } private fun createDataProvider( targetIntent: Intent, @@ -54,14 +54,13 @@ class PreviewDataProviderTest { additionalContentUri: Uri? = null, resolver: ContentInterface = contentResolver, typeClassifier: MimeTypeClassifier = mimeTypeClassifier, - isPayloadTogglingEnabled: Boolean = false ) = PreviewDataProvider( scope, targetIntent, additionalContentUri, resolver, - isPayloadTogglingEnabled, + featureFlags, typeClassifier, ) @@ -377,11 +376,11 @@ class PreviewDataProviderTest { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("image/png") + featureFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val testSubject = createDataProvider( targetIntent, additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), - isPayloadTogglingEnabled = true, ) assertThat(testSubject.previewType) @@ -415,11 +414,11 @@ class PreviewDataProviderTest { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("image/png") + featureFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val testSubject = createDataProvider( targetIntent, additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"), - isPayloadTogglingEnabled = true, ) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) -- cgit v1.2.3-59-g8ed1b From a70571727f7e0a4f88c43f087787b6dbdfacbf3a Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Wed, 26 Jun 2024 11:36:47 -0700 Subject: Add default implementation of the loadImage method to ImageLoader Bug: 348665058 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Flag: EXEMPT refactor Change-Id: Ie72638cf841f7402aec0df93907a81cb5825d076 --- .../contentpreview/CachingImagePreviewImageLoader.kt | 6 ------ .../com/android/intentresolver/contentpreview/ImageLoader.kt | 11 ++++++++++- .../intentresolver/contentpreview/ImagePreviewImageLoader.kt | 11 ----------- 3 files changed, 10 insertions(+), 18 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index 2e2aa938..c9deec1b 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -22,7 +22,6 @@ import android.util.Log import androidx.core.util.lruCache import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher @@ -31,7 +30,6 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -74,10 +72,6 @@ constructor( } ) - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { - callerScope.launch { callback.accept(loadCachedImage(uri)) } - } - override fun prePopulate(uris: List) { uris.take(cache.maxSize()).map { cache[it] } } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 81913a8e..b4d03ac9 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -20,6 +20,8 @@ import android.graphics.Bitmap import android.net.Uri import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** A content preview image loader. */ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { @@ -30,7 +32,14 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) + fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + callerScope.launch { + val bitmap = invoke(uri) + if (isActive) { + callback.accept(bitmap) + } + } + } /** Prepopulate the image loader cache. */ fun prePopulate(uris: List) diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index fab7203e..7cf9a8c9 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -25,7 +25,6 @@ import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache import com.android.intentresolver.inject.Background -import java.util.function.Consumer import javax.inject.Inject import javax.inject.Qualifier import kotlinx.coroutines.CancellationException @@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore @@ -102,15 +100,6 @@ constructor( override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { - callerScope.launch { - val image = loadImageAsync(uri, caching = true) - if (isActive) { - callback.accept(image) - } - } - } - override fun prePopulate(uris: List) { uris.asSequence().take(cache.maxSize()).forEach { uri -> scope.launch { loadImageAsync(uri, caching = true) } -- cgit v1.2.3-59-g8ed1b From 8cd6138cca567e14e2cdfa132a8686c4eb324d52 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 7 Jun 2024 14:33:28 -0700 Subject: Delete unused class ChooserRequestParameters Bug: N/A Test: presubmits Flag: EXEMPT refactor Change-Id: I84573929396492bfe10a2f835783c9edd58f8c5c --- .../intentresolver/ChooserRequestParameters.java | 500 --------------------- 1 file changed, 500 deletions(-) delete mode 100644 java/src/com/android/intentresolver/ChooserRequestParameters.java (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java deleted file mode 100644 index 66bb66ef..00000000 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ /dev/null @@ -1,500 +0,0 @@ -/* - * Copyright (C) 2008 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; - - -import android.content.ComponentName; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.IntentSender; -import android.net.Uri; -import android.os.Bundle; -import android.os.Parcelable; -import android.os.PatternMatcher; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.intentresolver.util.UriFilters; - -import com.google.common.collect.ImmutableList; - -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collector; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched - * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars. - * - * TODO: field nullability in this class reflects legacy use, and typically would indicate that the - * client's intent didn't provide the respective data. In some cases we may be able to provide - * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the - * client code could instead handle empty collections equally well. - * - * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to - * it internally) differ from the legacy model because they're computed directly from the initial - * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved - * through methods on the base class. The base always seems to return them exactly as they were - * provided, so this should be safe -- and clients can reasonably switch to retrieving through these - * parameters instead. For now, the other convention is still used in some places. Ideally we'd like - * to normalize on a single source of truth, but we'll have to clean up the delegation up to the - * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?). - */ -public class ChooserRequestParameters { - private static final String TAG = "ChooserActivity"; - - private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = - Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; - private static final int MAX_CHOOSER_ACTIONS = 5; - - private final Intent mTarget; - private final String mReferrerPackageName; - private final Pair mTitleSpec; - private final Intent mReferrerFillInIntent; - private final ImmutableList mFilteredComponentNames; - private final ImmutableList mCallerChooserTargets; - private final @NonNull ImmutableList mChooserActions; - private final ChooserAction mModifyShareAction; - private final boolean mRetainInOnStop; - - @Nullable - private final ImmutableList mAdditionalTargets; - - @Nullable - private final Bundle mReplacementExtras; - - @Nullable - private final ImmutableList mInitialIntents; - - @Nullable - private final IntentSender mChosenComponentSender; - - @Nullable - private final IntentSender mRefinementIntentSender; - - @Nullable - private final String mSharedText; - - @Nullable - private final IntentFilter mTargetIntentFilter; - - @Nullable - private final CharSequence mMetadataText; - - public ChooserRequestParameters( - final Intent clientIntent, - String referrerPackageName, - final Uri referrer) { - final Intent requestedTarget = parseTargetIntentExtra( - clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); - mTarget = intentWithModifiedLaunchFlags(requestedTarget); - - mReferrerPackageName = referrerPackageName; - - mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( - clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); - - mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); - - mTitleSpec = makeTitleSpec( - clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE), - isSendAction(mTarget.getAction())); - - mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent( - clientIntent, Intent.EXTRA_INITIAL_INTENTS); - - mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); - - mChosenComponentSender = - Optional.ofNullable( - clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, - IntentSender.class)) - .orElse(clientIntent.getParcelableExtra( - Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, - IntentSender.class)); - - mRefinementIntentSender = clientIntent.getParcelableExtra( - Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); - - ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( - Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); - mFilteredComponentNames = filteredComponents != null - ? ImmutableList.copyOf(filteredComponents) - : ImmutableList.of(); - - mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); - - mRetainInOnStop = clientIntent.getBooleanExtra( - ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false); - - mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); - - mTargetIntentFilter = getTargetIntentFilter(mTarget); - - mChooserActions = getChooserActions(clientIntent); - mModifyShareAction = getModifyShareAction(clientIntent); - - mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); - } - - public Intent getTargetIntent() { - return mTarget; - } - - @Nullable - public String getTargetAction() { - return getTargetIntent().getAction(); - } - - public boolean isSendActionTarget() { - return isSendAction(getTargetAction()); - } - - @Nullable - public String getTargetType() { - return getTargetIntent().getType(); - } - - public String getReferrerPackageName() { - return mReferrerPackageName; - } - - @Nullable - public CharSequence getTitle() { - return mTitleSpec.first; - } - - public int getDefaultTitleResource() { - return mTitleSpec.second; - } - - public Intent getReferrerFillInIntent() { - return mReferrerFillInIntent; - } - - public ImmutableList getFilteredComponentNames() { - return mFilteredComponentNames; - } - - public ImmutableList getCallerChooserTargets() { - return mCallerChooserTargets; - } - - @NonNull - public ImmutableList getChooserActions() { - return mChooserActions; - } - - @Nullable - public ChooserAction getModifyShareAction() { - return mModifyShareAction; - } - - /** - * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. - */ - public boolean shouldRetainInOnStop() { - return mRetainInOnStop; - } - - /** - * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. - */ - @Nullable - public Intent[] getAdditionalTargets() { - return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]); - } - - @Nullable - public Bundle getReplacementExtras() { - return mReplacementExtras; - } - - /** - * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link #mInitialIntents} directly is simpler and safer. - */ - @Nullable - public Intent[] getInitialIntents() { - return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]); - } - - @Nullable - public IntentSender getChosenComponentSender() { - return mChosenComponentSender; - } - - @Nullable - public IntentSender getRefinementIntentSender() { - return mRefinementIntentSender; - } - - @Nullable - public String getSharedText() { - return mSharedText; - } - - @Nullable - public IntentFilter getTargetIntentFilter() { - return mTargetIntentFilter; - } - - @Nullable - public CharSequence getMetadataText() { - return mMetadataText; - } - - private static boolean isSendAction(@Nullable String action) { - return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); - } - - private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) { - if (targetParcelable instanceof Uri) { - try { - targetParcelable = Intent.parseUri(targetParcelable.toString(), - Intent.URI_INTENT_SCHEME); - } catch (URISyntaxException ex) { - throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex); - } - } - - if (!(targetParcelable instanceof Intent)) { - throw new IllegalArgumentException( - "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable); - } - - return ((Intent) targetParcelable); - } - - private static Intent intentWithModifiedLaunchFlags(Intent intent) { - if (isSendAction(intent.getAction())) { - intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION); - } - return intent; - } - - /** - * Build a pair of values specifying the title to use from the client request. The first - * ({@link CharSequence}) value is the client-specified title, if there was one and their - * requested target wasn't a send action; otherwise it is null. The second value is - * the resource ID of a default title string; this is nonzero only if the first value is null. - * - * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or - * create a real type (not {@link Pair}) to express the semantics described in this comment. - */ - private static Pair makeTitleSpec( - @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) { - if (hasSendActionTarget && (requestedTitle != null)) { - // Do not allow the title to be changed when sharing content - Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" - + " preview title by using EXTRA_TITLE property of the wrapped" - + " EXTRA_INTENT."); - requestedTitle = null; - } - - int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0; - - return Pair.create(requestedTitle, defaultTitleRes); - } - - private static ImmutableList parseCallerTargetsFromClientIntent( - Intent clientIntent) { - return - streamParcelableArrayExtra( - clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true) - .collect(toImmutableList()); - } - - @NonNull - private static ImmutableList getChooserActions(Intent intent) { - return streamParcelableArrayExtra( - intent, - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - ChooserAction.class, - true, - true) - .filter(UriFilters::hasValidIcon) - .limit(MAX_CHOOSER_ACTIONS) - .collect(toImmutableList()); - } - - @Nullable - private static ChooserAction getModifyShareAction(Intent intent) { - try { - return intent.getParcelableExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - ChooserAction.class); - } catch (Throwable t) { - Log.w( - TAG, - "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", - t); - return null; - } - } - - private static Collector> toImmutableList() { - return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); - } - - @Nullable - private static ImmutableList intentsWithModifiedLaunchFlagsFromExtraIfPresent( - Intent clientIntent, String extra) { - Stream intents = - streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false); - if (intents == null) { - return null; - } - return intents - .map(ChooserRequestParameters::intentWithModifiedLaunchFlags) - .collect(toImmutableList()); - } - - /** - * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent} - * as the optional parcelable array extra with key {@code extra}. The stream elements, if any, - * are all of the type specified by {@code clazz}. - * - * @param intent The intent that may contain the optional extras. - * @param extra The extras key to identify the parcelable array. - * @param clazz A class that is assignable from any elements in the result stream. - * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have - * the required type. If false, throw an {@link IllegalArgumentException} if the extra is - * non-null but can't be assigned to variables of type {@code T}. - * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't - * present in the intent (or if it had the wrong type, but warnOnTypeError is true). - * If false, return null in these cases, and only return an empty stream if the intent - * explicitly provided an empty array for the specified extra. - */ - @Nullable - private static Stream streamParcelableArrayExtra( - final Intent intent, - String extra, - @NonNull Class clazz, - boolean warnOnTypeError, - boolean streamEmptyIfNull) { - T[] result = null; - - try { - result = getParcelableArrayExtraIfPresent(intent, extra, clazz); - } catch (IllegalArgumentException e) { - if (warnOnTypeError) { - Log.w(TAG, "Ignoring client-requested " + extra, e); - } else { - throw e; - } - } - - if (result != null) { - return Arrays.stream(result); - } else if (streamEmptyIfNull) { - return Stream.empty(); - } else { - return null; - } - } - - /** - * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]} - * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't - * present in the {@code intent}, return null. - */ - @Nullable - private static T[] getParcelableArrayExtraIfPresent( - final Intent intent, String extra, @NonNull Class clazz) throws - IllegalArgumentException { - if (!intent.hasExtra(extra)) { - return null; - } - - T[] castResult = intent.getParcelableArrayExtra(extra, clazz); - if (castResult == null) { - Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra); - if (actualExtrasArray != null) { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s[]: %s", - extra, - clazz.getSimpleName(), - Arrays.toString(actualExtrasArray))); - } else if (intent.getParcelableExtra(extra) != null) { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s[] (or any array type): %s", - extra, - clazz.getSimpleName(), - intent.getParcelableExtra(extra))); - } else { - throw new IllegalArgumentException( - String.format( - "%s is not of type %s (or any Parcelable type): %s", - extra, - clazz.getSimpleName(), - intent.getExtras().get(extra))); - } - } - - return castResult; - } - - private static IntentFilter getTargetIntentFilter(final Intent intent) { - try { - String dataString = intent.getDataString(); - if (intent.getType() == null) { - if (!TextUtils.isEmpty(dataString)) { - return new IntentFilter(intent.getAction(), dataString); - } - Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); - return null; - } - IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); - List contentUris = new ArrayList<>(); - if (Intent.ACTION_SEND.equals(intent.getAction())) { - Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri != null) { - contentUris.add(uri); - } - } else { - List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); - if (uris != null) { - contentUris.addAll(uris); - } - } - for (Uri uri : contentUris) { - intentFilter.addDataScheme(uri.getScheme()); - intentFilter.addDataAuthority(uri.getAuthority(), null); - intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); - } - return intentFilter; - } catch (Exception e) { - Log.e(TAG, "Failed to get target intent filter", e); - return null; - } - } -} -- cgit v1.2.3-59-g8ed1b From fcb5a63feb6dd82a712c0b83b325f7b478976cc1 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 10 Jul 2024 14:25:59 -0700 Subject: Remove fix_target_list_footer Launched. Bug: 324011248 Test: presubmits Flag: EXEMPT removing com.android.intentresolver.fix_target_list_footer Change-Id: Id18ee5168b9adbb6b8c383312dbaef0603fc31a2 --- aconfig/FeatureFlags.aconfig | 10 ---------- .../com/android/intentresolver/grid/ChooserGridAdapter.java | 8 +++----- 2 files changed, 3 insertions(+), 15 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 9c5e5eaa..0cadaab1 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -5,16 +5,6 @@ container: "system" # namespace: intentresolver # bug: "Feature_Bug_#" or "" -flag { - name: "fix_target_list_footer" - namespace: "intentresolver" - description: "Update app target grid footer on window insets change" - bug: "324011248" - metadata { - purpose: PURPOSE_BUGFIX - } -} - flag { name: "target_data_caching" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 7cf9d2e9..1dd83566 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -150,11 +150,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter Date: Wed, 10 Jul 2024 15:14:34 -0700 Subject: Remove bespoke_label_view Launched. Bug: 302188527 Test: presubmits Flag: EXEMPT removing com.android.intentresolver.bespoke_label_view Change-Id: Ia3f3b80b6e854f4ecd569936e144815e5ab592d7 --- aconfig/FeatureFlags.aconfig | 7 ----- .../android/intentresolver/ChooserActivity.java | 3 +-- .../android/intentresolver/ChooserListAdapter.java | 30 +++++----------------- .../intentresolver/ChooserWrapperActivity.java | 3 +-- .../intentresolver/ChooserListAdapterDataTest.kt | 4 --- .../intentresolver/ChooserListAdapterTest.kt | 15 +++-------- 6 files changed, 13 insertions(+), 49 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 0cadaab1..a95a9ea8 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -19,13 +19,6 @@ flag { bug: "302113519" } -flag { - name: "bespoke_label_view" - namespace: "intentresolver" - description: "Use a custom view to draw target labels" - bug: "302188527" -} - flag { name: "enable_private_profile" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 4d70f550..cdf1a8ad 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2043,8 +2043,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - }, - mFeatureFlags); + }); } private void onWorkProfileStatusUpdated() { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index ff0c40d7..07a615a5 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -111,7 +111,6 @@ public class ChooserListAdapter extends ResolverListAdapter { // Reserve spots for incoming direct share targets by adding placeholders private final TargetInfo mPlaceHolderTargetInfo; private final TargetDataLoader mTargetDataLoader; - private final boolean mUseBadgeTextViewForLabels; private final List mServiceTargets = new ArrayList<>(); private final List mCallerTargets = new ArrayList<>(); @@ -171,8 +170,7 @@ public class ChooserListAdapter extends ResolverListAdapter { int maxRankedTargets, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader, - @Nullable PackageChangeCallback packageChangeCallback, - FeatureFlags featureFlags) { + @Nullable PackageChangeCallback packageChangeCallback) { this( context, payloadIntents, @@ -191,8 +189,8 @@ public class ChooserListAdapter extends ResolverListAdapter { targetDataLoader, packageChangeCallback, AsyncTask.SERIAL_EXECUTOR, - context.getMainExecutor(), - featureFlags); + context.getMainExecutor() + ); } @VisibleForTesting @@ -214,8 +212,7 @@ public class ChooserListAdapter extends ResolverListAdapter { TargetDataLoader targetDataLoader, @Nullable PackageChangeCallback packageChangeCallback, Executor bgExecutor, - Executor mainExecutor, - FeatureFlags featureFlags) { + Executor mainExecutor) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -239,7 +236,6 @@ public class ChooserListAdapter extends ResolverListAdapter { mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; mPackageChangeCallback = packageChangeCallback; - mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView(); createPlaceHolders(); mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( @@ -345,12 +341,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate( - mUseBadgeTextViewForLabels - ? R.layout.chooser_grid_item - : R.layout.resolve_grid_item, - parent, - false); + return mInflater.inflate(R.layout.chooser_grid_item, parent, false); } @Override @@ -448,9 +439,7 @@ public class ChooserListAdapter extends ResolverListAdapter { holder.reset(); holder.itemView.setBackground(holder.defaultItemViewBackground); - if (mUseBadgeTextViewForLabels) { - ((BadgeTextView) holder.text).setBadgeDrawable(null); - } + ((BadgeTextView) holder.text).setBadgeDrawable(null); holder.text.setBackground(null); holder.text.setPaddingRelative(0, 0, 0, 0); } @@ -464,12 +453,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } private void bindGroupIndicator(ViewHolder holder, Drawable indicator) { - if (mUseBadgeTextViewForLabels) { - ((BadgeTextView) holder.text).setBadgeDrawable(indicator); - } else { - holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); - holder.text.setBackground(indicator); - } + ((BadgeTextView) holder.text).setBadgeDrawable(indicator); } private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) { diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 8a8fe355..930c84f0 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -75,8 +75,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW maxTargetsPerRow, userHandle, mTargetDataLoader, - null, - mFeatureFlags); + null); } @Override diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt index df0c5e5e..a0f733ab 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterDataTest.kt @@ -66,8 +66,6 @@ class ChooserListAdapterDataTest { private val immediateExecutor = TestExecutor(immediate = true) private val referrerFillInIntent = Intent().putExtra(Intent.EXTRA_REFERRER, "org.referrer.package") - private val featureFlags = - FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_BESPOKE_LABEL_VIEW, false) } @Test fun test_twoTargetsWithNonOverlappingInitialIntent_threeTargetsInResolverAdapter() { @@ -119,7 +117,6 @@ class ChooserListAdapterDataTest { null, backgroundExecutor, immediateExecutor, - featureFlags, ) val doPostProcessing = true @@ -183,7 +180,6 @@ class ChooserListAdapterDataTest { null, backgroundExecutor, immediateExecutor, - featureFlags, ) val doPostProcessing = true diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index bad3b18c..cdc84ba8 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -61,7 +61,6 @@ class ChooserListAdapterTest { private val mEventLog = mock() private val mTargetDataLoader = mock() private val mPackageChangeCallback = mock() - private val featureFlags = FeatureFlagsImpl() private val testSubject by lazy { ChooserListAdapter( @@ -81,7 +80,6 @@ class ChooserListAdapterTest { null, mTargetDataLoader, mPackageChangeCallback, - featureFlags, ) } @@ -222,15 +220,10 @@ class ChooserListAdapterTest { private fun createView(): View { val view = FrameLayout(context) - if (featureFlags.bespokeLabelView()) { - BadgeTextView(context) - } else { - TextView(context) - } - .apply { - id = R.id.text1 - view.addView(this) - } + BadgeTextView(context).apply { + id = R.id.text1 + view.addView(this) + } TextView(context).apply { id = R.id.text2 view.addView(this) -- cgit v1.2.3-59-g8ed1b From 875c53f91e3bdc0814f25208b9b291809b3b6bcc Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 10 Jul 2024 15:45:16 -0700 Subject: Remove fix_partial_image_edit_transition Launched. Bug: 339583191 Test: presubmits Flag: EXEMPT removing com.android.intentresolver.fix_partial_image_edit_transition Change-Id: I8e826f05690dcfdf4bed1cfcdf036218fe3b708e --- aconfig/FeatureFlags.aconfig | 10 ---------- .../src/com/android/intentresolver/ChooserActionFactory.java | 12 ++++-------- java/src/com/android/intentresolver/ChooserActivity.java | 3 +-- .../com/android/intentresolver/ChooserActionFactoryTest.kt | 6 ------ 4 files changed, 5 insertions(+), 26 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index a95a9ea8..87ae2f1d 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -63,16 +63,6 @@ flag { } } -flag { - name: "fix_partial_image_edit_transition" - namespace: "intentresolver" - description: "Do not run the shared element transition animation for a partially visible image" - bug: "339583191" - metadata { - purpose: PURPOSE_BUGFIX - } -} - flag { name: "fix_private_space_locked_on_restart" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index cc7091e4..21ca3b73 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -133,8 +133,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, Consumer finishCallback, - ClipboardManager clipboardManager, - FeatureFlags featureFlags) { + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( @@ -150,8 +149,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio imageEditor), firstVisibleImageQuery, activityStarter, - log, - featureFlags.fixPartialImageEditTransition()), + log), chooserActions, onUpdateSharedTextIsExcluded, log, @@ -340,8 +338,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable TargetInfo editSharingTarget, Callable firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog log, - boolean requireFullVisibility) { + EventLog log) { if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. @@ -352,8 +349,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstImageView = firstVisibleImageQuery.call(); } catch (Exception e) { /* ignore */ } // Action bar is user-independent; always start as primary. - if (firstImageView == null - || (requireFullVisibility && !isFullyVisible(firstImageView))) { + if (firstImageView == null || !isFullyVisible(firstImageView)) { activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); } else { activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index cdf1a8ad..7e0f148d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2193,8 +2193,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, mShareResultSender, this::finishWithStatus, - mClipboardManager, - mFeatureFlags); + mClipboardManager); } private Supplier createModifyShareActionFactory() { diff --git a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt index c8e17de4..8dfbdbdd 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -69,8 +69,6 @@ class ChooserActionFactoryTest { latestReturn = resultCode } } - private val featureFlags = - FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_FIX_PARTIAL_IMAGE_EDIT_TRANSITION, true) } @Before fun setup() { @@ -121,7 +119,6 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), - /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -143,7 +140,6 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), - /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -166,7 +162,6 @@ class ChooserActionFactoryTest { /* shareResultSender = */ resultSender, /* finishCallback = */ {}, /* clipboardManager = */ mock(), - /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNotNull() @@ -199,7 +194,6 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ resultConsumer, /* clipboardManager = */ mock(), - /* featureFlags = */ featureFlags, ) } } -- cgit v1.2.3-59-g8ed1b From c59c3bdb6262228d9135fb545ab42340a9ec8f7f Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 12 Jul 2024 16:20:54 -0700 Subject: Request preview size, WIDTH and HEIGHT columns for additional content Fix: 352818692 Test: atest IntentResolver-tests-unit Flag: EXEMPT bugfix Change-Id: I0fb0c7916a428eca064bc08def049ae4bc59818a --- .../payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt | 5 +++-- .../payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt index 148310e6..2b14cdea 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -20,6 +20,8 @@ import android.content.ContentInterface import android.content.Intent import android.database.Cursor import android.net.Uri +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH import android.service.chooser.AdditionalContentContract.Columns.URI import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow @@ -48,8 +50,7 @@ constructor( runCatching { contentResolver.query( cursorUri, - // TODO: uncomment to start using that data - arrayOf(URI /*, WIDTH, HEIGHT*/), + arrayOf(URI, WIDTH, HEIGHT), bundleOf(Intent.EXTRA_INTENT to chooserIntent), signal, ) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt index 5d81ec2a..f0813623 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolverTest.kt @@ -30,9 +30,12 @@ import com.google.common.truth.Truth.assertWithMessage import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.capture import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class PayloadToggleCursorResolverTest { private val cursorUri = Uri.parse("content://org.pkg.app.extra") @@ -101,6 +104,9 @@ class PayloadToggleCursorResolverTest { assertThat(row!!.uri).isEqualTo(uri) assertThat(row.previewSize).isEqualTo(Size(100, 50)) } + val columnsCaptor = argumentCaptor>() + verify(fakeContentProvider).query(eq(cursorUri), columnsCaptor.capture(), any(), any()) + assertThat(columnsCaptor.firstValue.toList()).containsExactly(URI, WIDTH, HEIGHT) } @Test -- cgit v1.2.3-59-g8ed1b From af0301b4c0ecee359d729d8d072973752e2a2bcf Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 11 Jul 2024 23:14:25 -0700 Subject: Limit max item aspect ratio Limit max item aspect ratio to make sure it is always visible on the screen (incliding paddings) Fix: 351094699 Test: manual test Flag: EXEMPT bugfix Change-Id: I8748bb7ee1b9cde03da1f6d15075d04e806197db --- .../ui/composable/ShareouselComposable.kt | 91 +++++++++++++++------- 1 file changed, 64 insertions(+), 27 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index c40ed266..3c3381a2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -46,13 +47,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -106,42 +110,76 @@ private fun PreviewCarousel( initialFirstVisibleItemIndex = centerIdx, prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } ) - // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case - LazyRow( - state = carouselState, - horizontalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + var maxAspectRatio by remember { mutableStateOf(0f) } + + val horizontalPadding = 16.dp + Box( modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) - .systemGestureExclusion() - ) { - itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> - - // Index if this is the element in the center of the viewing area, otherwise null - val previewIndex by remember { - derivedStateOf { - carouselState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } - ?.let { - val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2 - val halfPreviewWidth = it.size / 2 - val previewCenter = it.offset + halfPreviewWidth - val previewDistanceToViewportCenter = - abs(previewCenter - viewportCenter) - if (previewDistanceToViewportCenter <= halfPreviewWidth) index else null + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val aspectRatio = + if (placeable.height <= 0) { + 0f + } else { + val maxItemWidth = + maxOf(0, placeable.width - 2 * horizontalPadding.roundToPx()) + (maxItemWidth.toFloat() / placeable.height).coerceIn( + 0f, + MAX_ASPECT_RATIO + ) } + if (maxAspectRatio != aspectRatio) { + maxAspectRatio = aspectRatio + } + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + }, + ) { + if (maxAspectRatio <= 0) { + // Do not compose the list until we know the viewport size + return@Box + } + // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding), + modifier = Modifier.fillMaxSize().systemGestureExclusion(), + ) { + itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + // Index if this is the element in the center of the viewing area, otherwise null + val previewIndex by remember { + derivedStateOf { + carouselState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == index } + ?.let { + val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2 + val halfPreviewWidth = it.size / 2 + val previewCenter = it.offset + halfPreviewWidth + val previewDistanceToViewportCenter = + abs(previewCenter - viewportCenter) + if (previewDistanceToViewportCenter <= halfPreviewWidth) { + index + } else { + null + } + } + } } - } - ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope())) + ShareouselCard( + viewModel.preview(model, previewIndex, rememberCoroutineScope()), + maxAspectRatio, + ) + } } } } @Composable -private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio: Float) { val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary @@ -162,8 +200,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) ) { state -> - // TODO: max ratio is actually equal to the viewport ratio - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio) if (state is ValueUpdate.Value) { state.getOrDefault(null).let { bitmap -> ShareouselCard( -- cgit v1.2.3-59-g8ed1b From e48daa217dc397cce855a5357ee11a87b0c7bce4 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 25 Jun 2024 12:48:32 -0700 Subject: Update ImageLoader interface to receive preview sizes along with the URI The UI calculates and provides view sizes but they are not used by the existing ImageLoader implementations thus no functional change is expected. Bug: 348665058 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: inject debug logging and verify preview values being requested for app preview types Flag: EXEMPT refactor Change-Id: I0e282f773c424b6fe81587a71e1b8630452ac63c --- .../CachingImagePreviewImageLoader.kt | 7 +- .../FilesPlusTextContentPreviewUi.java | 5 +- .../intentresolver/contentpreview/ImageLoader.kt | 14 ++-- .../contentpreview/ImagePreviewImageLoader.kt | 7 +- .../contentpreview/TextContentPreviewUi.java | 9 ++- .../contentpreview/UnifiedContentPreviewUi.java | 17 +++-- .../ui/composable/ShareouselComposable.kt | 13 ++-- .../ui/viewmodel/ShareouselViewModel.kt | 21 ++++-- .../widget/ScrollableImagePreviewView.kt | 42 ++++++++++-- .../com/android/intentresolver/FakeImageLoader.kt | 12 +++- .../CachingImagePreviewImageLoaderTest.kt | 36 ++++++----- .../contentpreview/ImagePreviewImageLoaderTest.kt | 48 +++++++------- .../ui/viewmodel/ShareouselViewModelTest.kt | 75 ++++++++++++---------- .../widget/BatchPreviewLoaderTest.kt | 28 ++++++-- 14 files changed, 219 insertions(+), 115 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index c9deec1b..f60f550e 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -19,6 +19,7 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri import android.util.Log +import android.util.Size import androidx.core.util.lruCache import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned @@ -72,11 +73,11 @@ constructor( } ) - override fun prePopulate(uris: List) { - uris.take(cache.maxSize()).map { cache[it] } + override fun prePopulate(uriSizePairs: List>) { + uriSizePairs.take(cache.maxSize()).map { cache[it.first] } } - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? { return if (caching) { loadCachedImage(uri) } else { diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index b50f5bc8..30161cfb 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -23,6 +23,7 @@ import android.content.res.Resources; import android.net.Uri; import android.text.util.Linkify; import android.util.PluralsMessageFormatter; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -68,6 +69,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private Uri mFirstFilePreviewUri; private boolean mAllImages; private boolean mAllVideos; + private int mPreviewSize; // TODO(b/285309527): make this a flag private static final boolean SHOW_TOGGLE_CHECKMARK = false; @@ -109,6 +111,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size); return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -164,12 +167,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private void updateUiWithMetadata(ViewGroup contentPreviewView, View headlineView) { prepareTextPreview(contentPreviewView, headlineView, mActionFactory); updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); - ImageView imagePreview = mContentPreviewView.requireViewById(R.id.image_view); if (mIsSingleImage && mFirstFilePreviewUri != null) { mImageLoader.loadImage( mScope, mFirstFilePreviewUri, + new Size(mPreviewSize, mPreviewSize), bitmap -> { if (bitmap == null) { imagePreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index b4d03ac9..ac34f552 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,23 +18,25 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import android.util.Size import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** A content preview image loader. */ -interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { +interface ImageLoader : suspend (Uri, Size) -> Bitmap?, suspend (Uri, Size, Boolean) -> Bitmap? { /** * Load preview image asynchronously; caching is allowed. * * @param uri content URI + * @param size target bitmap size * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + fun loadImage(callerScope: CoroutineScope, uri: Uri, size: Size, callback: Consumer) { callerScope.launch { - val bitmap = invoke(uri) + val bitmap = invoke(uri, size) if (isActive) { callback.accept(bitmap) } @@ -42,13 +44,13 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm } /** Prepopulate the image loader cache. */ - fun prePopulate(uris: List) + fun prePopulate(uriSizePairs: List>) /** Returns a bitmap for the given URI if it's already cached, otherwise null */ fun getCachedBitmap(uri: Uri): Bitmap? = null /** Load preview image; caching is allowed. */ - override suspend fun invoke(uri: Uri) = invoke(uri, true) + override suspend fun invoke(uri: Uri, size: Size) = invoke(uri, size, true) /** * Load preview image. @@ -56,5 +58,5 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param uri content URI * @param caching indicates if the loaded image could be cached. */ - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? } diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 7cf9a8c9..379bdb37 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -98,10 +98,11 @@ constructor( @GuardedBy("lock") private val cache = LruCache(cacheSize) @GuardedBy("lock") private val runningRequests = HashMap() - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = + loadImageAsync(uri, caching) - override fun prePopulate(uris: List) { - uris.asSequence().take(cache.maxSize()).forEach { uri -> + override fun prePopulate(uriSizePairs: List>) { + uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) -> scope.launch { loadImageAsync(uri, caching = true) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index ae7ddcd9..b12eb8cf 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -22,6 +22,7 @@ import android.content.res.Resources; import android.net.Uri; import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -50,6 +51,7 @@ class TextContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; private final ContentTypeHint mContentTypeHint; + private int mPreviewSize; TextContentPreviewUi( CoroutineScope scope, @@ -83,6 +85,7 @@ class TextContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.width_text_image_preview_size); return displayInternal(layoutInflater, parent, headlineViewParent); } @@ -119,7 +122,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewTitleView.setText(mPreviewTitle); } - ImageView previewThumbnailView = contentPreviewLayout.findViewById( + final ImageView previewThumbnailView = contentPreviewLayout.requireViewById( com.android.internal.R.id.content_preview_thumbnail); if (!isOwnedByCurrentUser(mPreviewThumbnail)) { previewThumbnailView.setVisibility(View.GONE); @@ -127,9 +130,9 @@ class TextContentPreviewUi extends ContentPreviewUi { mImageLoader.loadImage( mScope, mPreviewThumbnail, + new Size(mPreviewSize, mPreviewSize), (bitmap) -> updateViewWithImage( - contentPreviewLayout.findViewById( - com.android.internal.R.id.content_preview_thumbnail), + previewThumbnailView, bitmap)); } diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 88311016..7de988c4 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import android.content.res.Resources; import android.util.Log; +import android.util.Size; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -31,6 +32,8 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; +import kotlin.Pair; + import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.flow.Flow; @@ -55,6 +58,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { @Nullable private ViewGroup mContentPreviewView; private View mHeadlineView; + private int mPreviewSize; UnifiedContentPreviewUi( CoroutineScope scope, @@ -93,14 +97,18 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { + mPreviewSize = resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen); return displayInternal(layoutInflater, parent, headlineViewParent); } private void setFiles(List files) { - mImageLoader.prePopulate(files.stream() - .map(FileInfo::getPreviewUri) - .filter(Objects::nonNull) - .toList()); + Size previewSize = new Size(mPreviewSize, mPreviewSize); + mImageLoader.prePopulate( + files.stream() + .map(FileInfo::getPreviewUri) + .filter(Objects::nonNull) + .map((uri -> new Pair<>(uri, previewSize))) + .toList()); mFiles = files; if (mContentPreviewView != null) { updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files); @@ -121,6 +129,7 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ScrollableImagePreviewView imagePreview = mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setPreviewHeight(mPreviewSize); imagePreview.setImageLoader(mImageLoader); imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 3c3381a2..9ac36a87 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -111,6 +111,7 @@ private fun PreviewCarousel( prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } ) var maxAspectRatio by remember { mutableStateOf(0f) } + var viewportHeight by remember { mutableStateOf(0) } val horizontalPadding = 16.dp Box( @@ -130,9 +131,8 @@ private fun PreviewCarousel( MAX_ASPECT_RATIO ) } - if (maxAspectRatio != aspectRatio) { - maxAspectRatio = aspectRatio - } + maxAspectRatio = aspectRatio + viewportHeight = placeable.height layout(placeable.width, placeable.height) { placeable.place(0, 0) } }, ) { @@ -170,7 +170,12 @@ private fun PreviewCarousel( } ShareouselCard( - viewModel.preview(model, previewIndex, rememberCoroutineScope()), + viewModel.preview( + model, + viewportHeight, + previewIndex, + rememberCoroutineScope() + ), maxAspectRatio, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index d0b89860..f1e65f73 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -15,6 +15,7 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel +import android.util.Size import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader @@ -57,7 +58,9 @@ data class ShareouselViewModel( val actions: Flow>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ val preview: - (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel, + ( + key: PreviewModel, previewHeight: Int, index: Int?, scope: CoroutineScope + ) -> ShareouselPreviewViewModel, ) @Module @@ -114,7 +117,7 @@ interface ShareouselViewModelModule { } } }, - preview = { key, index, previewScope -> + preview = { key, previewHeight, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = @@ -130,9 +133,19 @@ interface ShareouselViewModelModule { ShareouselPreviewViewModel( bitmapLoadState = flow { + val previewWidth = + if (key.aspectRatio > 0) { + previewHeight.toFloat() / key.aspectRatio + } else { + previewHeight + } + .toInt() emit( - key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } - ?: ValueUpdate.Absent + key.previewUri?.let { + ValueUpdate.Value( + imageLoader(it, Size(previewWidth, previewHeight)) + ) + } ?: ValueUpdate.Absent ) } .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 7fe16091..c706e3ee 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -22,6 +22,7 @@ import android.graphics.Rect import android.net.Uri import android.util.AttributeSet import android.util.PluralsMessageFormatter +import android.util.Size import android.util.TypedValue import android.view.LayoutInflater import android.view.View @@ -60,11 +61,13 @@ private const val MIN_ASPECT_RATIO_STRING = "2:5" private const val MAX_ASPECT_RATIO = 2.5f private const val MAX_ASPECT_RATIO_STRING = "5:2" -private typealias CachingImageLoader = suspend (Uri, Boolean) -> Bitmap? +private typealias CachingImageLoader = suspend (Uri, Size, Boolean) -> Bitmap? class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor( context: Context, attrs: AttributeSet?, @@ -121,12 +124,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { * A hint about the maximum width this view can grow to, this helps to optimize preview loading. */ var maxWidthHint: Int = -1 + private var requestedHeight: Int = 0 private var isMeasured = false private var maxAspectRatio = MAX_ASPECT_RATIO private var maxAspectRatioString = MAX_ASPECT_RATIO_STRING private var outerSpacing: Int = 0 + var previewHeight: Int + get() = previewAdapter.previewHeight + set(value) { + previewAdapter.previewHeight = value + } + override fun onMeasure(widthSpec: Int, heightSpec: Int) { super.onMeasure(widthSpec, heightSpec) if (!isMeasured) { @@ -198,6 +208,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { BatchPreviewLoader( previewAdapter.imageLoader ?: error("Image loader is not set"), previews, + Size(previewHeight, previewHeight), totalItemCount, onUpdate = previewAdapter::addPreviews, onCompletion = { @@ -303,11 +314,19 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private var isLoading = false private val hasOtherItem get() = previews.size < totalItemCount + val hasPreviews: Boolean get() = previews.isNotEmpty() var transitionStatusElementCallback: TransitionElementStatusCallback? = null + private var previewSize: Size = Size(0, 0) + var previewHeight: Int + get() = previewSize.height + set(value) { + previewSize = Size(value, value) + } + fun reset(totalItemCount: Int) { firstImagePos = -1 previews.clear() @@ -387,6 +406,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), + previewSize, fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, previewReadyCallback = @@ -438,6 +458,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: CachingImageLoader, + previewSize: Size, fadeInDurationMs: Long, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? @@ -477,7 +498,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } resetScope().launch { - loadImage(preview, imageLoader) + loadImage(preview, previewSize, imageLoader) if (preview.type == PreviewType.Image && previewReadyCallback != null) { image.waitForPreDraw() previewReadyCallback(TRANSITION_NAME) @@ -487,12 +508,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } - private suspend fun loadImage(preview: Preview, imageLoader: CachingImageLoader) { + private suspend fun loadImage( + preview: Preview, + previewSize: Size, + imageLoader: CachingImageLoader, + ) { val bitmap = runCatching { // it's expected for all loading/caching optimizations to be implemented by // the loader - imageLoader(preview.uri, true) + imageLoader(preview.uri, previewSize, true) } .getOrNull() image.setImageBitmap(bitmap) @@ -507,6 +532,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { setAnimationListener( object : AnimationListener { override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit override fun onAnimationEnd(animation: Animation?) { @@ -551,6 +577,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { private class LoadingItemViewHolder(view: View) : ViewHolder(view) { fun bind() = Unit + override fun unbind() = Unit } @@ -638,6 +665,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { class BatchPreviewLoader( private val imageLoader: CachingImageLoader, private val previews: Flow, + private val previewSize: Size, val totalItemCount: Int, private val onUpdate: (List) -> Unit, private val onCompletion: () -> Unit, @@ -701,10 +729,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { // 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 -> + imageLoader(preview.uri, previewSize, isFirstBlock)?.let { + bitmap -> previewSizeUpdater(preview, bitmap.width, bitmap.height) - } - ?: 0 + } ?: 0 } .getOrDefault(0) diff --git a/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt index c57ea78b..76eb5e0d 100644 --- a/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt +++ b/tests/shared/src/com/android/intentresolver/FakeImageLoader.kt @@ -18,6 +18,7 @@ package com.android.intentresolver import android.graphics.Bitmap import android.net.Uri +import android.util.Size import com.android.intentresolver.contentpreview.ImageLoader import java.util.function.Consumer import kotlinx.coroutines.CoroutineScope @@ -25,13 +26,18 @@ import kotlinx.coroutines.CoroutineScope class FakeImageLoader(initialBitmaps: Map = emptyMap()) : ImageLoader { private val bitmaps = HashMap().apply { putAll(initialBitmaps) } - override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer) { + override fun loadImage( + callerScope: CoroutineScope, + uri: Uri, + size: Size, + callback: Consumer, + ) { callback.accept(bitmaps[uri]) } - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = bitmaps[uri] - override fun prePopulate(uris: List) = Unit + override fun prePopulate(uriSizePairs: List>) = Unit fun setBitmap(uri: Uri, bitmap: Bitmap) { bitmaps[uri] = bitmap diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt index 331f9f64..d5a569aa 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import android.util.Size import com.google.common.truth.Truth.assertThat import kotlin.math.ceil import kotlin.math.roundToInt @@ -43,6 +44,7 @@ class CachingImagePreviewImageLoaderTest { testJobTime * ceil((testCacheSize).toFloat() / testMaxConcurrency.toFloat()).roundToInt() private val testUris = List(5) { Uri.fromParts("TestScheme$it", "TestSsp$it", "TestFragment$it") } + private val previewSize = Size(500, 500) private val testTimeToLoadAllUris = testJobTime * ceil((testUris.size).toFloat() / testMaxConcurrency.toFloat()).roundToInt() private val testBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8) @@ -72,7 +74,7 @@ class CachingImagePreviewImageLoaderTest { var result: Bitmap? = null // Act - imageLoader.loadImage(testScope, testUris[0]) { result = it } + imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } advanceTimeBy(testJobTime) runCurrent() @@ -85,14 +87,14 @@ class CachingImagePreviewImageLoaderTest { fun loadImage_cached_usesCachedValue() = testScope.runTest { // Arrange - imageLoader.loadImage(testScope, testUris[0]) {} + imageLoader.loadImage(testScope, testUris[0], previewSize) {} advanceTimeBy(testJobTime) runCurrent() fakeThumbnailLoader.invokeCalls.clear() var result: Bitmap? = null // Act - imageLoader.loadImage(testScope, testUris[0]) { result = it } + imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } advanceTimeBy(testJobTime) runCurrent() @@ -112,7 +114,7 @@ class CachingImagePreviewImageLoaderTest { var result: Bitmap? = testBitmap // Act - imageLoader.loadImage(testScope, testUris[0]) { result = it } + imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } advanceTimeBy(testJobTime) runCurrent() @@ -130,7 +132,7 @@ class CachingImagePreviewImageLoaderTest { // Act testUris.take(testMaxConcurrency + 1).forEach { uri -> - imageLoader.loadImage(testScope, uri) { results.add(it) } + imageLoader.loadImage(testScope, uri, previewSize) { results.add(it) } } // Assert @@ -153,10 +155,10 @@ class CachingImagePreviewImageLoaderTest { assertThat(testUris.size).isGreaterThan(testCacheSize) // Act - imageLoader.loadImage(testScope, testUris[0]) { results[0] = it } + imageLoader.loadImage(testScope, testUris[0], previewSize) { results[0] = it } runCurrent() testUris.indices.drop(1).take(testCacheSize).forEach { i -> - imageLoader.loadImage(testScope, testUris[i]) { results[i] = it } + imageLoader.loadImage(testScope, testUris[i], previewSize) { results[i] = it } } advanceTimeBy(testTimeToFillCache) runCurrent() @@ -179,7 +181,7 @@ class CachingImagePreviewImageLoaderTest { assertThat(fullCacheUris).hasSize(testCacheSize) // Act - imageLoader.prePopulate(fullCacheUris) + imageLoader.prePopulate(fullCacheUris.map { it to previewSize }) advanceTimeBy(testTimeToFillCache) runCurrent() @@ -188,7 +190,7 @@ class CachingImagePreviewImageLoaderTest { // Act fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(fullCacheUris) + imageLoader.prePopulate(fullCacheUris.map { it to previewSize }) advanceTimeBy(testTimeToFillCache) runCurrent() @@ -203,7 +205,7 @@ class CachingImagePreviewImageLoaderTest { assertThat(testUris.size).isGreaterThan(testCacheSize) // Act - imageLoader.prePopulate(testUris) + imageLoader.prePopulate(testUris.map { it to previewSize }) advanceTimeBy(testTimeToLoadAllUris) runCurrent() @@ -213,7 +215,7 @@ class CachingImagePreviewImageLoaderTest { // Act fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(testUris) + imageLoader.prePopulate(testUris.map { it to previewSize }) advanceTimeBy(testTimeToLoadAllUris) runCurrent() @@ -229,7 +231,7 @@ class CachingImagePreviewImageLoaderTest { assertThat(unfilledCacheUris.size).isLessThan(testCacheSize) // Act - imageLoader.prePopulate(unfilledCacheUris) + imageLoader.prePopulate(unfilledCacheUris.map { it to previewSize }) advanceTimeBy(testJobTime) runCurrent() @@ -238,7 +240,7 @@ class CachingImagePreviewImageLoaderTest { // Act fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(unfilledCacheUris) + imageLoader.prePopulate(unfilledCacheUris.map { it to previewSize }) advanceTimeBy(testJobTime) runCurrent() @@ -252,8 +254,8 @@ class CachingImagePreviewImageLoaderTest { // Arrange // Act - imageLoader.invoke(testUris[0], caching = false) - imageLoader.invoke(testUris[0], caching = false) + imageLoader.invoke(testUris[0], previewSize, caching = false) + imageLoader.invoke(testUris[0], previewSize, caching = false) advanceTimeBy(testJobTime) runCurrent() @@ -267,8 +269,8 @@ class CachingImagePreviewImageLoaderTest { // Arrange // Act - imageLoader.invoke(testUris[0], caching = true) - imageLoader.invoke(testUris[0], caching = true) + imageLoader.invoke(testUris[0], previewSize, caching = true) + imageLoader.invoke(testUris[0], previewSize, caching = true) advanceTimeBy(testJobTime) runCurrent() diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt index 3a45e2f6..d78e6665 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt @@ -77,24 +77,25 @@ class ImagePreviewImageLoaderTest { contentResolver, cacheSize = 1, ) + private val previewSize = Size(500, 500) @Test fun prePopulate_cachesImagesUpToTheCacheSize() = scope.runTest { - testSubject.prePopulate(listOf(uriOne, uriTwo)) + testSubject.prePopulate(listOf(uriOne to previewSize, uriTwo to previewSize)) verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - testSubject(uriOne) + testSubject(uriOne, previewSize) verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) } @Test fun invoke_returnCachedImageWhenCalledTwice() = scope.runTest { - testSubject(uriOne) - testSubject(uriOne) + testSubject(uriOne, previewSize) + testSubject(uriOne, previewSize) verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) } @@ -102,8 +103,8 @@ class ImagePreviewImageLoaderTest { @Test fun invoke_whenInstructed_doesNotCache() = scope.runTest { - testSubject(uriOne, false) - testSubject(uriOne, false) + testSubject(uriOne, previewSize, false) + testSubject(uriOne, previewSize, false) verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) } @@ -120,8 +121,8 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, ) coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } scheduler.advanceUntilIdle() } @@ -131,10 +132,10 @@ class ImagePreviewImageLoaderTest { @Test fun invoke_oldRecordsEvictedFromTheCache() = scope.runTest { - testSubject(uriOne) - testSubject(uriTwo) - testSubject(uriTwo) - testSubject(uriOne) + testSubject(uriOne, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriOne, previewSize) verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) @@ -144,8 +145,8 @@ class ImagePreviewImageLoaderTest { fun invoke_doNotCacheNulls() = scope.runTest { whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne) - testSubject(uriOne) + testSubject(uriOne, previewSize) + testSubject(uriOne, previewSize) verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) } @@ -162,7 +163,7 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, ) imageLoaderScope.cancel() - testSubject(uriOne) + testSubject(uriOne, previewSize) } @Test(expected = CancellationException::class) @@ -178,7 +179,8 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, ) coroutineScope { - val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } + val deferred = + async(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } imageLoaderScope.cancel() scheduler.advanceUntilIdle() deferred.await() @@ -198,11 +200,11 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, ) coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, true) } + launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, true) } scheduler.advanceUntilIdle() } - testSubject(uriOne, true) + testSubject(uriOne, previewSize, true) verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) } @@ -243,7 +245,7 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, testSemaphore, ) - testSubject(uriOne, false) + testSubject(uriOne, previewSize, false) verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) assertThat(acquireCount.get()).isEqualTo(1) @@ -281,7 +283,7 @@ class ImagePreviewImageLoaderTest { cacheSize = 1, testSemaphore, ) - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } + launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) @@ -324,7 +326,9 @@ class ImagePreviewImageLoaderTest { ) coroutineScope { repeat(requestCount) { - launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } + launch { + testSubject(Uri.parse("content://org.pkg.app/image-$it.png"), previewSize) + } } yield() // wait for all requests to be dispatched diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt index bb67e084..1047d145 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -76,23 +76,25 @@ class ShareouselViewModelTest { scope = viewModelScope, ) } + private val previewHeight = 500 @Test fun headline_images() = runTest { assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 1") previewSelectionsRepository.selections.value = listOf( - PreviewModel( - uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/png", - order = 0, - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/jpeg", - order = 1, + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/png", + order = 0, + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/jpeg", + order = 1, + ) ) - ).associateBy { it.uri } + .associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("IMAGES: 2") } @@ -101,17 +103,18 @@ class ShareouselViewModelTest { fun headline_videos() = runTest { previewSelectionsRepository.selections.value = listOf( - PreviewModel( - uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "video/mpeg", - order = 0, - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "video/mpeg", - order = 1, + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "video/mpeg", + order = 0, + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + order = 1, + ) ) - ).associateBy { it.uri } + .associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("VIDEOS: 2") } @@ -120,17 +123,18 @@ class ShareouselViewModelTest { fun headline_mixed() = runTest { previewSelectionsRepository.selections.value = listOf( - PreviewModel( - uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = "image/jpeg", - order = 0, - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "video/mpeg", - order = 1, + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/jpeg", + order = 0, + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + order = 1, + ) ) - ).associateBy { it.uri } + .associateBy { it.uri } runCurrent() assertThat(shareouselViewModel.headline.first()).isEqualTo("FILES: 2") } @@ -194,6 +198,7 @@ class ShareouselViewModelTest { mimeType = "video/mpeg", order = 0, ), + previewHeight, /* index = */ 1, viewModelScope, ) @@ -245,6 +250,7 @@ class ShareouselViewModelTest { mimeType = "video/mpeg", order = 1, ), + previewHeight, /* index = */ 1, viewModelScope, ) @@ -308,10 +314,11 @@ class ShareouselViewModelTest { this.targetIntentModifier = targetIntentModifier previewSelectionsRepository.selections.value = PreviewModel( - uri = Uri.fromParts("scheme", "ssp", "fragment"), - mimeType = null, - order = 0, - ).let { mapOf(it.uri to it) } + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0, + ) + .let { mapOf(it.uri to it) } payloadToggleImageLoader = FakeImageLoader( initialBitmaps = diff --git a/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt index 4f4223c0..b1e8593d 100644 --- a/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.widget import android.graphics.Bitmap import android.net.Uri +import android.util.Size import com.android.intentresolver.captureMany import com.android.intentresolver.mock import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader @@ -49,6 +50,7 @@ class BatchPreviewLoaderTest { private val testScope = CoroutineScope(dispatcher) private val onCompletion = mock<() -> Unit>() private val onUpdate = mock<(List) -> Unit>() + private val previewSize = Size(500, 500) @Before fun setup() { @@ -71,6 +73,7 @@ class BatchPreviewLoaderTest { BatchPreviewLoader( imageLoader, previews(uriOne, uriTwo), + previewSize, totalItemCount = 2, onUpdate, onCompletion @@ -94,6 +97,7 @@ class BatchPreviewLoaderTest { BatchPreviewLoader( imageLoader, previews(uriOne, uriTwo, uriThree), + previewSize, totalItemCount = 3, onUpdate, onCompletion @@ -122,7 +126,14 @@ class BatchPreviewLoaderTest { } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion) + BatchPreviewLoader( + imageLoader, + previews(*uris), + previewSize, + uris.size, + onUpdate, + onCompletion + ) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() @@ -151,7 +162,14 @@ class BatchPreviewLoaderTest { val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } imageLoader.setUriLoadingOrder(*loadingOrder) val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), uris.size, onUpdate, onCompletion) + BatchPreviewLoader( + imageLoader, + previews(*uris), + previewSize, + uris.size, + onUpdate, + onCompletion + ) testSubject.loadAspectRatios(200) { _, _, _ -> 100 } dispatcher.scheduler.advanceUntilIdle() @@ -166,7 +184,9 @@ class BatchPreviewLoaderTest { private fun createUri(idx: Int): Uri = Uri.parse("content://org.pkg.app/image-$idx.png") 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(uris.size)) { acc, uri -> @@ -175,7 +195,7 @@ class BatchPreviewLoaderTest { .asFlow() } -private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? { +private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Size, Boolean) -> Bitmap? { private val loadingOrder = ArrayDeque>() private val pendingRequests = LinkedHashMap>() private val flow = MutableSharedFlow(replay = 1) @@ -203,7 +223,7 @@ private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> loadingOrder.addAll(uris) } - override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? { + override suspend fun invoke(uri: Uri, size: Size, cache: Boolean): Bitmap? { val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() } flow.tryEmit(Unit) return deferred.await() -- cgit v1.2.3-59-g8ed1b From a7b90069598ad145a0a2a33a83af23122433a045 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 22 Jul 2024 18:59:10 -0700 Subject: Fix drawer offset calculation when rotating from a non-initial profile Fix: 344057117 Test: manual testing Flag: com.android.intentresolver.fix_drawer_offset_on_config_change Change-Id: I0fb633b02299c0a4845bfa09bbbb234915ccb487 --- aconfig/FeatureFlags.aconfig | 10 ++++++++++ .../com/android/intentresolver/ChooserActivity.java | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 87ae2f1d..df1f0030 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -36,6 +36,16 @@ flag { } } +flag { + name: "fix_drawer_offset_on_config_change" + namespace: "intentresolver" + description: "Fix drawer offset calculation after rotating when in a non-initial tab" + bug: "344057117" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "fix_empty_state_padding" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7e0f148d..84f29e21 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -204,7 +204,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private static final String TAB_TAG_PERSONAL = "personal"; private static final String TAB_TAG_WORK = "work"; - private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + private static final String LAST_SHOWN_PROFILE = "last_shown_tab_key"; public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; private int mLayoutId; @@ -349,6 +349,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserHelper.setOnPendingSelection(this::onPendingSelection); } } + private int mInitialProfile = -1; @Override protected final void onStart() { @@ -410,7 +411,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected final void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mViewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem()); + outState.putInt( + LAST_SHOWN_PROFILE, mChooserMultiProfilePagerAdapter.getActiveProfile()); } } @@ -679,6 +681,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); + mInitialProfile = findSelectedProfile(); Tracer.INSTANCE.markLaunched(); } @@ -828,7 +831,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { if (mViewPager != null) { - mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + int profile = savedInstanceState.getInt(LAST_SHOWN_PROFILE); + int profileNumber = mChooserMultiProfilePagerAdapter.getPageNumberForProfile(profile); + if (profileNumber != -1) { + mViewPager.setCurrentItem(profileNumber); + mInitialProfile = profile; + } } mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); } @@ -2257,7 +2265,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); - int initialProfile = findSelectedProfile(); + int initialProfile = Flags.fixDrawerOffsetOnConfigChange() + ? mInitialProfile + : findSelectedProfile(); if (currentProfile != initialProfile) { return; } -- cgit v1.2.3-59-g8ed1b From 78e45e942cce4144fe772955631794389a41c156 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Tue, 23 Jul 2024 13:56:03 -0700 Subject: Do not crash if max aspect ratio falls below min aspect ratio The effective aspect ratio for the item will be in the range of [minOf(MIN_ASPECT_RATIO, maxAspectRatio), maxOf(MIN_ASPECT_RATIO, maxAspectRatio)] i.e. we always try to fit items into the view port even if the effective AR will be lower than the minimum AR. Bug: 351094699 Test: manual testing Flag: EXEMPT bugfix Change-Id: I6ae117ff816f47185caebc68c7fd3c670eacff33 --- .../contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 9ac36a87..e8d75c8f 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -205,7 +205,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) ) { state -> - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio) + val aspectRatio = minOf(maxAspectRatio, maxOf(MIN_ASPECT_RATIO, viewModel.aspectRatio)) if (state is ValueUpdate.Value) { state.getOrDefault(null).let { bitmap -> ShareouselCard( -- cgit v1.2.3-59-g8ed1b From 826936d3e69a4fccb56a5b1f7038d266f7ffb7bf Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Tue, 2 Jul 2024 09:35:27 -0700 Subject: Unified preview image loader. Replace ImagePreviewImageLoader and CachingPreviewImageLoader with the new class, PreviewImageLoader. Fix: 348665058 Fix: 343819590 Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Test: inject debugging logs for image requests, loadings, and cancellations and verify common scenarios. Flag: com.android.intentresolver.preview_image_loader Change-Id: Ib54ea0d538cd2cbaef6041f09eeb2b9eb6ada4cf --- aconfig/FeatureFlags.aconfig | 7 + .../CachingImagePreviewImageLoader.kt | 2 +- .../contentpreview/ImageLoaderModule.kt | 15 +- .../contentpreview/PreviewImageLoader.kt | 197 ++++++++ .../contentpreview/ThumbnailLoader.kt | 26 +- .../ui/viewmodel/ShareouselViewModel.kt | 186 ++++---- .../intentresolver/ChooserActivityTest.java | 5 + .../contentpreview/FakeThumbnailLoader.kt | 13 +- .../contentpreview/PreviewImageLoaderTest.kt | 497 +++++++++++++++++++++ 9 files changed, 850 insertions(+), 98 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt create mode 100644 tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 87ae2f1d..2c3f66fb 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -72,3 +72,10 @@ flag { purpose: PURPOSE_BUGFIX } } + +flag { + name: "preview_image_loader" + namespace: "intentresolver" + description: "Use the unified preview image loader for all preview variations; support variable preview sizes." + bug: "348665058" +} diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index f60f550e..847fcc82 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -87,7 +87,7 @@ constructor( private suspend fun loadUncachedImage(uri: Uri): Bitmap? = withContext(bgDispatcher) { - runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } } .onFailure { ensureActive() Log.d(TAG, "Failed to load preview for $uri", it) diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index 17d05099..27e817db 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources +import com.android.intentresolver.Flags import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import dagger.Binds @@ -24,15 +25,25 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Provider @Module @InstallIn(ViewModelComponent::class) interface ImageLoaderModule { - @Binds fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader - @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader companion object { + @Provides + fun imageLoader( + imagePreviewImageLoader: Provider, + previewImageLoader: Provider + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + imagePreviewImageLoader.get() + } + @Provides @ThumbnailSize fun thumbnailSize(@ApplicationOwned resources: Resources): Int = diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt new file mode 100644 index 00000000..b10f7ef9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2024 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.graphics.Bitmap +import android.net.Uri +import android.util.Log +import android.util.Size +import androidx.collection.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import javax.annotation.concurrent.GuardedBy +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +private const val TAG = "PayloadSelImageLoader" + +/** + * Implements preview image loading for the payload selection UI. Cancels preview loading for items + * that has been evicted from the cache at the expense of a possible request duplication (deemed + * unlikely). + */ +class PreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @PreviewCacheSize private val cacheSize: Int, + @ThumbnailSize private val defaultPreviewSize: Int, + private val thumbnailLoader: ThumbnailLoader, + @Background private val bgDispatcher: CoroutineDispatcher, + @PreviewMaxConcurrency maxSimultaneousRequests: Int = 4, +) : ImageLoader { + + private val contentResolverSemaphore = Semaphore(maxSimultaneousRequests) + + private val lock = Any() + @GuardedBy("lock") private val runningRequests = hashMapOf() + @GuardedBy("lock") + private val cache = + lruCache( + maxSize = cacheSize, + onEntryRemoved = { _, _, oldRec, newRec -> + if (oldRec !== newRec) { + onRecordEvictedFromCache(oldRec) + } + } + ) + + override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = + loadImageInternal(uri, size, caching) + + override fun prePopulate(uriSizePairs: List>) { + uriSizePairs.asSequence().take(cacheSize).forEach { uri -> + scope.launch { loadImageInternal(uri.first, uri.second, caching = true) } + } + } + + private suspend fun loadImageInternal(uri: Uri, size: Size, caching: Boolean): Bitmap? { + return withRequestRecord(uri, caching) { record -> + val newSize = sanitize(size) + val newMetric = newSize.metric + record + .also { + // set the requested size to the max of the new and the previous value; input + // will emit if the resulted value is greater than the old one + it.input.update { oldSize -> + if (oldSize == null || oldSize.metric < newSize.metric) newSize else oldSize + } + } + .output + // filter out bitmaps of a lower resolution than that we're requesting + .filter { it is BitmapLoadingState.Loaded && newMetric <= it.size.metric } + .firstOrNull() + ?.let { (it as BitmapLoadingState.Loaded).bitmap } + } + } + + private suspend fun withRequestRecord( + uri: Uri, + caching: Boolean, + block: suspend (RequestRecord) -> Bitmap? + ): Bitmap? { + val record = trackRecordRunning(uri, caching) + return try { + block(record) + } finally { + untrackRecordRunning(uri, record) + } + } + + private fun trackRecordRunning(uri: Uri, caching: Boolean): RequestRecord = + synchronized(lock) { + runningRequests + .getOrPut(uri) { cache[uri] ?: createRecord(uri) } + .also { record -> + record.clientCount++ + if (caching) { + cache.put(uri, record) + } + } + } + + private fun untrackRecordRunning(uri: Uri, record: RequestRecord) { + synchronized(lock) { + record.clientCount-- + if (record.clientCount <= 0) { + runningRequests.remove(uri) + val result = record.output.value + if (cache[uri] == null) { + record.loadingJob.cancel() + } else if (result is BitmapLoadingState.Loaded && result.bitmap == null) { + cache.remove(uri) + } + } + } + } + + private fun onRecordEvictedFromCache(record: RequestRecord) { + synchronized(lock) { + if (record.clientCount <= 0) { + record.loadingJob.cancel() + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createRecord(uri: Uri): RequestRecord { + // use a StateFlow with sentinel values to avoid using SharedFlow that is deemed dangerous + val input = MutableStateFlow(null) + val output = MutableStateFlow(BitmapLoadingState.Loading) + val job = + scope.launch(bgDispatcher) { + // the image loading pipeline: input -- a desired image size, output -- a bitmap + input + .filterNotNull() + .mapLatest { size -> BitmapLoadingState.Loaded(size, loadBitmap(uri, size)) } + .collect { output.tryEmit(it) } + } + return RequestRecord(input, output, job, clientCount = 0) + } + + private suspend fun loadBitmap(uri: Uri, size: Size): Bitmap? = + contentResolverSemaphore.withPermit { + runCatching { thumbnailLoader.loadThumbnail(uri, size) } + .onFailure { Log.d(TAG, "failed to load $uri preview", it) } + .getOrNull() + } + + private class RequestRecord( + /** The image loading pipeline input: desired preview size */ + val input: MutableStateFlow, + /** The image loading pipeline output */ + val output: MutableStateFlow, + /** The image loading pipeline job */ + val loadingJob: Job, + @GuardedBy("lock") var clientCount: Int, + ) + + private sealed interface BitmapLoadingState { + data object Loading : BitmapLoadingState + + data class Loaded(val size: Size, val bitmap: Bitmap?) : BitmapLoadingState + } + + private fun sanitize(size: Size?): Size = + size?.takeIf { it.width > 0 && it.height > 0 } + ?: Size(defaultPreviewSize, defaultPreviewSize) +} + +private val Size.metric + get() = maxOf(width, height) diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt index 9f1d50da..e8afa480 100644 --- a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -20,10 +20,25 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import android.util.Size +import com.android.intentresolver.util.withCancellationSignal import javax.inject.Inject /** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ -interface ThumbnailLoader : suspend (Uri) -> Bitmap? +interface ThumbnailLoader { + /** + * Loads a thumbnail for the given [uri]. + * + * The size of the thumbnail is determined by the implementation. + */ + suspend fun loadThumbnail(uri: Uri): Bitmap? + + /** + * Loads a thumbnail for the given [uri] and [size]. + * + * The [size] is the size of the thumbnail in pixels. + */ + suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? +} /** Default implementation of [ThumbnailLoader]. */ class ThumbnailLoaderImpl @@ -35,6 +50,11 @@ constructor( private val size = Size(thumbnailSize, thumbnailSize) - override suspend fun invoke(uri: Uri): Bitmap = - contentResolver.loadThumbnail(uri, size, /* signal = */ null) + override suspend fun loadThumbnail(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal= */ null) + + override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap = + withCancellationSignal { signal -> + contentResolver.loadThumbnail(uri, size, signal) + } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index f1e65f73..6f8be1ff 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -16,10 +16,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.util.Size +import com.android.intentresolver.Flags import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.PreviewImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor @@ -30,11 +32,11 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentTyp import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.inject.ViewModelOwned -import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -65,98 +67,106 @@ data class ShareouselViewModel( @Module @InstallIn(ViewModelComponent::class) -interface ShareouselViewModelModule { +object ShareouselViewModelModule { - @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + @Provides + @PayloadToggle + fun imageLoader( + cachingImageLoader: Provider, + previewImageLoader: Provider + ): ImageLoader = + if (Flags.previewImageLoader()) { + previewImageLoader.get() + } else { + cachingImageLoader.get() + } - companion object { - @Provides - fun create( - interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, - actionsInteractor: CustomActionsInteractor, - headlineGenerator: HeadlineGenerator, - selectionInteractor: SelectionInteractor, - chooserRequestInteractor: ChooserRequestInteractor, - mimeTypeClassifier: MimeTypeClassifier, - // TODO: remove if possible - @ViewModelOwned scope: CoroutineScope, - ): ShareouselViewModel { - val keySet = - interactor.previews.stateIn( - scope, - SharingStarted.Eagerly, - initialValue = null, - ) - return ShareouselViewModel( - headline = - selectionInteractor.aggregateContentType.zip( - selectionInteractor.amountSelected - ) { contentType, numItems -> - when (contentType) { - ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) - ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) - ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) { + contentType, + numItems -> + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) } - }, - metadataText = chooserRequestInteractor.metadataText, - previews = keySet, - actions = - actionsInteractor.customActions.map { actions -> - actions.mapIndexedNotNull { i, model -> - val icon = model.icon - val label = model.label - if (icon == null && label.isBlank()) { - null - } else { - ActionChipViewModel( - label = label.toString(), - icon = model.icon, - onClicked = { model.performAction(i) }, + } + }, + preview = { key, previewHeight, index, previewScope -> + keySet.value?.maybeLoad(index) + val previewInteractor = interactor.preview(key) + val contentType = + when { + mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image + mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video + else -> ContentType.Other + } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent + ShareouselPreviewViewModel( + bitmapLoadState = + flow { + val previewWidth = + if (key.aspectRatio > 0) { + previewHeight.toFloat() / key.aspectRatio + } else { + previewHeight + } + .toInt() + emit( + key.previewUri?.let { + ValueUpdate.Value( + imageLoader(it, Size(previewWidth, previewHeight)) + ) + } ?: ValueUpdate.Absent ) } - } - }, - preview = { key, previewHeight, index, previewScope -> - keySet.value?.maybeLoad(index) - val previewInteractor = interactor.preview(key) - val contentType = - when { - mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image - mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video - else -> ContentType.Other - } - val initialBitmapValue = - key.previewUri?.let { - imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } - } ?: ValueUpdate.Absent - ShareouselPreviewViewModel( - bitmapLoadState = - flow { - val previewWidth = - if (key.aspectRatio > 0) { - previewHeight.toFloat() / key.aspectRatio - } else { - previewHeight - } - .toInt() - emit( - key.previewUri?.let { - ValueUpdate.Value( - imageLoader(it, Size(previewWidth, previewHeight)) - ) - } ?: ValueUpdate.Absent - ) - } - .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), - contentType = contentType, - isSelected = previewInteractor.isSelected, - setSelected = previewInteractor::setSelected, - aspectRatio = key.aspectRatio, - ) - }, - ) - } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), + contentType = contentType, + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) + }, + ) } } diff --git a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java index 620ac555..e103e57b 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java +++ b/tests/activity/src/com/android/intentresolver/ChooserActivityTest.java @@ -124,6 +124,7 @@ import com.android.intentresolver.contentpreview.ImageLoaderModule; import com.android.intentresolver.contentpreview.PreviewCacheSize; import com.android.intentresolver.contentpreview.PreviewMaxConcurrency; import com.android.intentresolver.contentpreview.ThumbnailLoader; +import com.android.intentresolver.contentpreview.ThumbnailSize; import com.android.intentresolver.data.repository.FakeUserRepository; import com.android.intentresolver.data.repository.UserRepository; import com.android.intentresolver.data.repository.UserRepositoryModule; @@ -284,6 +285,10 @@ public class ChooserActivityTest { @PreviewMaxConcurrency int mPreviewMaxConcurrency = 4; + @BindValue + @ThumbnailSize + int mPreviewThumbnailSize = 500; + @BindValue ThumbnailLoader mThumbnailLoader = new FakeThumbnailLoader(); diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt index d3fdf17d..33969eb7 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/FakeThumbnailLoader.kt @@ -18,18 +18,23 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri +import android.util.Size /** Fake implementation of [ThumbnailLoader] for use in testing. */ -class FakeThumbnailLoader : ThumbnailLoader { +class FakeThumbnailLoader(private val defaultSize: Size = Size(100, 100)) : ThumbnailLoader { - val fakeInvoke = mutableMapOf Bitmap?>() + val fakeInvoke = mutableMapOf Bitmap?>() val invokeCalls = mutableListOf() var unfinishedInvokeCount = 0 - override suspend fun invoke(uri: Uri): Bitmap? { + override suspend fun loadThumbnail(uri: Uri): Bitmap? = getBitmap(uri, defaultSize) + + override suspend fun loadThumbnail(uri: Uri, size: Size): Bitmap? = getBitmap(uri, size) + + private suspend fun getBitmap(uri: Uri, size: Size): Bitmap? { invokeCalls.add(uri) unfinishedInvokeCount++ - val result = fakeInvoke[uri]?.invoke() + val result = fakeInvoke[uri]?.invoke(size) unfinishedInvokeCount-- return result } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt new file mode 100644 index 00000000..8293264c --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt @@ -0,0 +1,497 @@ +/* + * Copyright 2024 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.graphics.Bitmap +import android.net.Uri +import android.util.Size +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PreviewImageLoaderTest { + private val scope = TestScope() + + @Test + fun test_cachingImageRequest_imageCached() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(200, 100)) + val b2 = testSubject.invoke(uri, Size(200, 100), caching = false) + assertThat(b1).isEqualTo(b2) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_nonCachingImageRequest_imageNotCached() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject.invoke(uri, Size(200, 100), caching = false) + testSubject.invoke(uri, Size(200, 100), caching = false) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_twoSimultaneousImageRequests_requestsDeduplicated() = + scope.runTest { + val uri = createUri(0) + val loadingStartedDeferred = CompletableDeferred() + val bitmapDeferred = CompletableDeferred() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStartedDeferred.complete(Unit) + bitmapDeferred.await() + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1Deferred = async { testSubject.invoke(uri, Size(200, 100), caching = false) } + loadingStartedDeferred.await() + val b2Deferred = + async(start = CoroutineStart.UNDISPATCHED) { + testSubject.invoke(uri, Size(200, 100), caching = true) + } + bitmapDeferred.complete(createBitmap(200, 200)) + + val b1 = b1Deferred.await() + val b2 = b2Deferred.await() + assertThat(b1).isEqualTo(b2) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_cachingRequestCancelledAndEvoked_imageLoadingCancelled() = + scope.runTest { + val uriOne = createUri(1) + val uriTwo = createUri(2) + val loadingStartedDeferred = CompletableDeferred() + val cancelledRequests = mutableSetOf() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uriOne] = { + loadingStartedDeferred.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelledRequests.add(uriOne) + throw e + } + } + fakeInvoke[uriTwo] = { createBitmap(200, 200) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize = 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val jobOne = launch { testSubject.invoke(uriOne, Size(200, 100)) } + loadingStartedDeferred.await() + jobOne.cancel() + scope.runCurrent() + + assertThat(cancelledRequests).isEmpty() + + // second URI should evict the first item from the cache + testSubject.invoke(uriTwo, Size(200, 100)) + + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + assertThat(cancelledRequests).containsExactly(uriOne) + } + + @Test + fun test_nonCachingRequestClientCancels_imageLoadingCancelled() = + scope.runTest { + val uri = createUri(1) + val loadingStartedDeferred = CompletableDeferred() + val cancelledRequests = mutableSetOf() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStartedDeferred.complete(Unit) + try { + awaitCancellation() + } catch (e: CancellationException) { + cancelledRequests.add(uri) + throw e + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize = 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val job = launch { testSubject.invoke(uri, Size(200, 100), caching = false) } + loadingStartedDeferred.await() + job.cancel() + scope.runCurrent() + + assertThat(cancelledRequests).containsExactly(uri) + } + + @Test + fun test_requestHigherResImage_newImageLoaded() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(100, 100)) + val b2 = testSubject.invoke(uri, Size(200, 200)) + assertThat(b1).isNotNull() + assertThat(b1!!.width).isEqualTo(100) + assertThat(b2).isNotNull() + assertThat(b2!!.width).isEqualTo(200) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_imageLoadingThrowsException_returnsNull() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { throw SecurityException("test") } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val bitmap = testSubject.invoke(uri, Size(100, 100)) + assertThat(bitmap).isNull() + } + + @Test + fun test_requestHigherResImage_cancelsLowerResLoading() = + scope.runTest { + val uri = createUri(0) + val cancelledRequestCount = atomic(0) + val imageLoadingStarted = CompletableDeferred() + val bitmapDeferred = CompletableDeferred() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + imageLoadingStarted.complete(Unit) + try { + bitmapDeferred.await() + } catch (e: CancellationException) { + cancelledRequestCount.getAndIncrement() + throw e + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val lowResSize = 100 + val highResSize = 200 + launch(start = CoroutineStart.UNDISPATCHED) { + testSubject.invoke(uri, Size(lowResSize, lowResSize)) + } + imageLoadingStarted.await() + val result = async { testSubject.invoke(uri, Size(highResSize, highResSize)) } + runCurrent() + assertThat(cancelledRequestCount.value).isEqualTo(1) + + bitmapDeferred.complete(createBitmap(highResSize, highResSize)) + val bitmap = result.await() + assertThat(bitmap).isNotNull() + assertThat(bitmap!!.width).isEqualTo(highResSize) + assertThat(thumbnailLoader.invokeCalls).hasSize(2) + } + + @Test + fun test_requestLowerResImage_cachedHigherResImageReturned() = + scope.runTest { + val uri = createUri(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val lowResSize = 100 + val highResSize = 200 + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject.invoke(uri, Size(highResSize, highResSize)) + val b2 = testSubject.invoke(uri, Size(lowResSize, lowResSize)) + assertThat(b1).isEqualTo(b2) + assertThat(b2!!.width).isEqualTo(highResSize) + assertThat(thumbnailLoader.invokeCalls).hasSize(1) + } + + @Test + fun test_incorrectSizeRequested_defaultSizeIsUsed() = + scope.runTest { + val uri = createUri(0) + val defaultPreviewSize = 100 + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { size -> createBitmap(size.width, size.height) } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + cacheSize = 1, + defaultPreviewSize, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + val b1 = testSubject(uri, Size(0, 0)) + assertThat(b1!!.width).isEqualTo(defaultPreviewSize) + + val largerImageSize = 200 + val b2 = testSubject(uri, Size(largerImageSize, largerImageSize)) + assertThat(b2!!.width).isEqualTo(largerImageSize) + } + + @Test + fun test_prePopulateImages_cachesImagesUpToTheCacheSize() = + scope.runTest { + val previewSize = Size(100, 100) + val uris = List(2) { createUri(it) } + val loadingCount = atomic(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + for (uri in uris) { + fakeInvoke[uri] = { size -> + loadingCount.getAndIncrement() + createBitmap(size.width, size.height) + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject.prePopulate(uris.map { it to previewSize }) + runCurrent() + + assertThat(loadingCount.value).isEqualTo(1) + assertThat(thumbnailLoader.invokeCalls).containsExactly(uris[0]) + + testSubject(uris[0], previewSize) + runCurrent() + + assertThat(loadingCount.value).isEqualTo(1) + } + + @Test + fun test_oldRecordEvictedFromTheCache() = + scope.runTest { + val previewSize = Size(100, 100) + val uriOne = createUri(1) + val uriTwo = createUri(2) + val requestsPerUri = HashMap() + val thumbnailLoader = + FakeThumbnailLoader().apply { + for (uri in arrayOf(uriOne, uriTwo)) { + fakeInvoke[uri] = { size -> + requestsPerUri.getOrPut(uri) { AtomicInteger() }.incrementAndGet() + createBitmap(size.width, size.height) + } + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject(uriOne, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriTwo, previewSize) + testSubject(uriOne, previewSize) + + assertThat(requestsPerUri[uriOne]?.get()).isEqualTo(2) + assertThat(requestsPerUri[uriTwo]?.get()).isEqualTo(1) + } + + @Test + fun test_doNotCacheNulls() = + scope.runTest { + val previewSize = Size(100, 100) + val uri = createUri(1) + val loadingCount = atomic(0) + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingCount.getAndIncrement() + null + } + } + val testSubject = + PreviewImageLoader( + backgroundScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + testSubject(uri, previewSize) + testSubject(uri, previewSize) + + assertThat(loadingCount.value).isEqualTo(2) + } + + @Test(expected = CancellationException::class) + fun invoke_onClosedImageLoaderScope_throwsCancellationException() = + scope.runTest { + val uri = createUri(1) + val thumbnailLoader = FakeThumbnailLoader().apply { fakeInvoke[uri] = { null } } + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + PreviewImageLoader( + imageLoaderScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + imageLoaderScope.cancel() + testSubject(uri, Size(200, 200)) + } + + @Test(expected = CancellationException::class) + fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = + scope.runTest { + val uri = createUri(1) + val loadingStarted = CompletableDeferred() + val bitmapDeferred = CompletableDeferred() + val thumbnailLoader = + FakeThumbnailLoader().apply { + fakeInvoke[uri] = { + loadingStarted.complete(Unit) + bitmapDeferred.await() + } + } + val imageLoaderScope = CoroutineScope(coroutineContext) + val testSubject = + PreviewImageLoader( + imageLoaderScope, + 1, + 100, + thumbnailLoader, + StandardTestDispatcher(scope.testScheduler) + ) + + launch { + loadingStarted.await() + imageLoaderScope.cancel() + } + testSubject(uri, Size(200, 200)) + } +} + +private fun createUri(id: Int) = Uri.parse("content://org.pkg.app/image-$id.png") + +private fun createBitmap(width: Int, height: Int) = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) -- cgit v1.2.3-59-g8ed1b From 068c395fd118c20632a5a77a0e28639863dc070b Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 24 Jul 2024 21:36:22 -0400 Subject: Center initial item in Sharousel As long as there are enough items in the row to center the initial item, it will be centered, otherwise it will be as close to center as the scroll bounds allow. Test: manual testing BUG: 341925364 Flag: android.service.chooser.chooser_payload_toggling Change-Id: Ifb96071ba70c3ba42f53d584ddfc31d4fc62cedf --- .../ui/composable/ShareouselComposable.kt | 100 ++++++++++++++------- 1 file changed, 67 insertions(+), 33 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 9ac36a87..e40d98bd 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -45,6 +45,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -71,6 +72,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.Prev import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import kotlin.math.abs +import kotlin.math.min import kotlinx.coroutines.launch @Composable @@ -104,44 +106,48 @@ private fun PreviewCarousel( previews: PreviewsModel, viewModel: ShareouselViewModel, ) { - val centerIdx = previews.startIdx - val carouselState = - rememberLazyListState( - initialFirstVisibleItemIndex = centerIdx, - prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } - ) var maxAspectRatio by remember { mutableStateOf(0f) } var viewportHeight by remember { mutableStateOf(0) } - - val horizontalPadding = 16.dp + var viewportCenter by remember { mutableStateOf(0) } + var horizontalPadding by remember { mutableStateOf(0.dp) } Box( modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - val aspectRatio = + val (minItemWidth, maxAR) = if (placeable.height <= 0) { - 0f + 0f to 0f } else { - val maxItemWidth = - maxOf(0, placeable.width - 2 * horizontalPadding.roundToPx()) - (maxItemWidth.toFloat() / placeable.height).coerceIn( - 0f, - MAX_ASPECT_RATIO - ) + val minItemWidth = (MIN_ASPECT_RATIO * placeable.height) + val maxItemWidth = maxOf(0, placeable.width - 32.dp.roundToPx()) + val maxAR = + (maxItemWidth.toFloat() / placeable.height).coerceIn( + 0f, + MAX_ASPECT_RATIO + ) + minItemWidth to maxAR } - maxAspectRatio = aspectRatio + viewportCenter = placeable.width / 2 + maxAspectRatio = maxAR viewportHeight = placeable.height + horizontalPadding = ((placeable.width - minItemWidth) / 2).toDp() layout(placeable.width, placeable.height) { placeable.place(0, 0) } }, ) { - if (maxAspectRatio <= 0) { + if (maxAspectRatio <= 0 && previews.previewModels.isNotEmpty()) { // Do not compose the list until we know the viewport size return@Box } - // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if - // HorizontalPager works for our use-case + + var firstSelectedIndex by remember { mutableStateOf(null as Int?) } + + val carouselState = + rememberLazyListState( + prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }, + ) + LazyRow( state = carouselState, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -149,26 +155,38 @@ private fun PreviewCarousel( modifier = Modifier.fillMaxSize().systemGestureExclusion(), ) { itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + val visibleItem by remember { + derivedStateOf { + carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + } + } + // Index if this is the element in the center of the viewing area, otherwise null val previewIndex by remember { derivedStateOf { - carouselState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } - ?.let { - val viewportCenter = carouselState.layoutInfo.viewportEndOffset / 2 - val halfPreviewWidth = it.size / 2 - val previewCenter = it.offset + halfPreviewWidth - val previewDistanceToViewportCenter = - abs(previewCenter - viewportCenter) - if (previewDistanceToViewportCenter <= halfPreviewWidth) { - index - } else { - null - } + visibleItem?.let { + val halfPreviewWidth = it.size / 2 + val previewCenter = it.offset + halfPreviewWidth + val previewDistanceToViewportCenter = + abs(previewCenter - viewportCenter) + if (previewDistanceToViewportCenter <= halfPreviewWidth) { + index + } else { + null } + } } } + val previewModel = + viewModel.preview(model, viewportHeight, previewIndex, rememberCoroutineScope()) + val selected by + previewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + + if (selected) { + firstSelectedIndex = min(index, firstSelectedIndex ?: Int.MAX_VALUE) + } + ShareouselCard( viewModel.preview( model, @@ -180,6 +198,22 @@ private fun PreviewCarousel( ) } } + + firstSelectedIndex?.let { index -> + LaunchedEffect(Unit) { + val visibleItem = + carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + val center = + with(carouselState.layoutInfo) { + ((viewportEndOffset - viewportStartOffset) / 2) + viewportStartOffset + } + + carouselState.scrollToItem( + index = index, + scrollOffset = visibleItem?.size?.div(2)?.minus(center) ?: 0, + ) + } + } } } -- cgit v1.2.3-59-g8ed1b From 7da3a9396f38d92dd01f765d99d1c9d3f65a46d0 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Tue, 30 Jul 2024 10:57:39 -0700 Subject: Do not crash when fail to read from the additional content cursor Bug: 354546194 Test: atest IntentResolver-tests-unit Flag: EXEMPT bugfix Change-Id: I5123fe6c1cabbbae7479d2cea8bd9e4b8ff0b4a8 --- .../domain/interactor/CursorPreviewsInteractor.kt | 73 ++++++++++++------ .../interactor/CursorPreviewsInteractorTest.kt | 89 +++++++++++++++++++--- 2 files changed, 128 insertions(+), 34 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt index a475263c..7d658209 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -20,6 +20,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import android.util.Log import com.android.intentresolver.contentpreview.UriMetadataReader import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection @@ -51,6 +52,8 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest +private const val TAG = "CursorPreviewsIntr" + /** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ class CursorPreviewsInteractor @Inject @@ -273,8 +276,7 @@ constructor( pagedCursor .getPageRows(pageNum) // TODO: what do we do if the load fails? ?.filter { it.uri !in state.merged } - ?.toPage(this, unclaimedRecords) - ?: this + ?.toPage(this, unclaimedRecords) ?: this private suspend fun Sequence.toPage( destination: M, @@ -288,26 +290,32 @@ constructor( private fun createPreviewModel( row: CursorRow, unclaimedRecords: MutableUnclaimedMap, - ): PreviewModel = uriMetadataReader.getMetadata(row.uri).let { metadata -> - val size = - row.previewSize - ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } - PreviewModel( - uri = row.uri, - previewUri = metadata.previewUri, - mimeType = metadata.mimeType, - aspectRatio = size.aspectRatioOrDefault(1f), - order = row.position, - ) - }.also { updated -> - if (unclaimedRecords.remove(row.uri) != null) { - // unclaimedRecords contains initially shared (and thus selected) items with unknown - // cursor position. Update selection records when any of those items is encountered - // in the cursor to maintain proper selection order should other items also be - // selected. - selectionInteractor.updateSelection(updated) + ): PreviewModel = + uriMetadataReader + .getMetadata(row.uri) + .let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), + order = row.position, + ) + } + .also { updated -> + if (unclaimedRecords.remove(row.uri) != null) { + // unclaimedRecords contains initially shared (and thus selected) items with + // unknown + // cursor position. Update selection records when any of those items is + // encountered + // in the cursor to maintain proper selection order should other items also be + // selected. + selectionInteractor.updateSelection(updated) + } } - } private fun M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } @@ -343,7 +351,28 @@ private fun M.putAllUnclaimedWhere( .toMap(this) private fun PagedCursor.getPageRows(pageNum: Int): Sequence? = - get(pageNum)?.filterNotNull() + runCatching { get(pageNum) } + .onFailure { Log.e(TAG, "Failed to read additional content cursor page #$pageNum", it) } + .getOrNull() + ?.asSafeSequence() + ?.filterNotNull() + +private fun Sequence.asSafeSequence(): Sequence { + return if (this is SafeSequence) this else SafeSequence(this) +} + +private class SafeSequence(private val sequence: Sequence) : Sequence { + override fun iterator(): Iterator = + sequence.iterator().let { if (it is SafeIterator) it else SafeIterator(it) } +} + +private class SafeIterator(private val iterator: Iterator) : Iterator by iterator { + override fun hasNext(): Boolean { + return runCatching { iterator.hasNext() } + .onFailure { Log.e(TAG, "Failed to read cursor", it) } + .getOrDefault(false) + } +} @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt index 48e43190..c4ba8105 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractorTest.kt @@ -18,10 +18,13 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import android.provider.MediaStore.MediaColumns.HEIGHT import android.provider.MediaStore.MediaColumns.WIDTH +import android.service.chooser.AdditionalContentContract.Columns.URI +import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION import android.util.Size import androidx.core.os.bundleOf import com.android.intentresolver.contentpreview.FileInfo @@ -39,6 +42,7 @@ import com.android.intentresolver.util.cursor.CursorView import com.android.intentresolver.util.cursor.viewBy import com.android.intentresolver.util.runTest import com.android.systemui.kosmos.Kosmos +import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -93,9 +97,9 @@ class CursorPreviewsInteractorTest { private val cursorSizes: Map, ) { val cursor: CursorView = - MatrixCursor(arrayOf("uri", WIDTH, HEIGHT)) + MatrixCursor(arrayOf(URI, WIDTH, HEIGHT)) .apply { - extras = bundleOf("position" to cursorStartPosition) + extras = bundleOf(POSITION to cursorStartPosition) for (i in cursorRange) { val size = cursorSizes[i] addRow( @@ -279,22 +283,83 @@ class CursorPreviewsInteractorTest { ) { deps -> previewSelectionsRepository.selections.value = PreviewModel( - uri = uri(1), - mimeType = "image/png", - order = 0, - ).let { mapOf(it.uri to it) } + uri = uri(1), + mimeType = "image/png", + order = 0, + ) + .let { mapOf(it.uri to it) } backgroundScope.launch { cursorPreviewsInteractor.launch(deps.cursor, deps.initialPreviews) } runCurrent() - assertThat(previewSelectionsRepository.selections.value.values).containsExactly( - PreviewModel( - uri = uri(1), - mimeType = "image/bitmap", - order = 1, + assertThat(previewSelectionsRepository.selections.value.values) + .containsExactly( + PreviewModel( + uri = uri(1), + mimeType = "image/bitmap", + order = 1, + ) + ) + } + + @Test + fun testReadFailedPages() = + runTestWithDeps( + initialSelection = listOf(4), + cursor = emptyList(), + cursorStartPosition = 0, + pageSize = 2, + maxLoadedPages = 5, + ) { deps -> + val cursor = + MatrixCursor(arrayOf(URI)).apply { + extras = bundleOf(POSITION to 4) + for (i in 0 until 10) { + addRow(arrayOf(uri(i))) + } + } + val failingPositions = setOf(1, 5, 8) + val failingCursor = + object : Cursor by cursor { + override fun move(offset: Int): Boolean = moveToPosition(position + offset) + + override fun moveToPosition(position: Int): Boolean { + if (failingPositions.contains(position)) { + throw RuntimeException( + "A test exception when moving the cursor to position $position" + ) + } + return cursor.moveToPosition(position) + } + + override fun moveToFirst(): Boolean = moveToPosition(0) + + override fun moveToLast(): Boolean = moveToPosition(count - 1) + + override fun moveToNext(): Boolean = move(1) + + override fun moveToPrevious(): Boolean = move(-1) + } + .viewBy { + getString(0)?.let { uriStr -> + CursorRow(Uri.parse(uriStr), readSize(), position) + } + } + backgroundScope.launch { + cursorPreviewsInteractor.launch(failingCursor, deps.initialPreviews) + } + runCurrent() + + assertThat(cursorPreviewsRepository.previewsModel.value).isNotNull() + assertThat(cursorPreviewsRepository.previewsModel.value!!.previewModels) + .comparingElementsUsing( + Correspondence.transforming({ it.uri }, "has a Uri of") + ) + .containsExactlyElementsIn( + (0..7).filterNot { failingPositions.contains(it) }.map { uri(it) } ) - ) + .inOrder() } } -- cgit v1.2.3-59-g8ed1b From 12d34169d65a07a99fb29e2332f7f01d256de202 Mon Sep 17 00:00:00 2001 From: Alan Chen Date: Tue, 30 Jul 2024 14:15:19 -0700 Subject: Add a null check in decorateActionFactoryWithRefinement Since originalFactory.getEditButtonRunnable is nullable, a check needs to be added to avoid an NPE. Bug: 353397828 Flag: EXEMPT bugfix Test: manual Change-Id: I9f028e30e7f6232fcd9c2cd3df423715c81e806b --- java/src/com/android/intentresolver/ChooserActivity.java | 1 + 1 file changed, 1 insertion(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 84f29e21..bc8788ad 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2116,6 +2116,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override @Nullable public Runnable getEditButtonRunnable() { + if (originalFactory.getEditButtonRunnable() == null) return null; return () -> { if (!mRefinementManager.maybeHandleSelection( RefinementType.EDIT_ACTION, -- cgit v1.2.3-59-g8ed1b From 51dcb3e094bad99d2f3e8d3e692a7dd0ebcf7d30 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Wed, 31 Jul 2024 18:15:26 +0000 Subject: Don't send target selection callbacks crossprofile Test: existing + new unit case in ShareResultSenderImplTest Bug: 319671608 Flag: EXEMPT bugfix Change-Id: I9e6f3a531ee6b35cd62a67f33836fa02ce7e70cf --- .../android/intentresolver/ChooserActivity.java | 8 ++- .../android/intentresolver/ui/ShareResultSender.kt | 54 ++++++++++++----- .../intentresolver/ui/ShareResultSenderImplTest.kt | 68 +++++++++++++++++++++- 3 files changed, 109 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 84f29e21..32667ad0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1087,7 +1087,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (cti.startAsCaller(this, options, user.getIdentifier())) { // Prevent sending a second chooser result when starting the edit action intent. if (!cti.getTargetIntent().hasExtra(EDIT_SOURCE)) { - maybeSendShareResult(cti); + maybeSendShareResult(cti, user); } maybeLogCrossProfileTargetLaunch(cti, user); } @@ -1639,11 +1639,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return result; } - private void maybeSendShareResult(TargetInfo cti) { + private void maybeSendShareResult(TargetInfo cti, UserHandle launchedAsUser) { if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); + boolean crossProfile = !UserHandle.of(UserHandle.myUserId()).equals(launchedAsUser); + mShareResultSender.onComponentSelected( + target, cti.isChooserTargetInfo(), crossProfile); } } } diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt index 7be2076e..dce477ec 100644 --- a/java/src/com/android/intentresolver/ui/ShareResultSender.kt +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -47,7 +47,7 @@ private const val TAG = "ShareResultSender" /** Reports the result of a share to another process across binder, via an [IntentSender] */ interface ShareResultSender { /** Reports user selection of an activity to launch from the provided choices. */ - fun onComponentSelected(component: ComponentName, directShare: Boolean) + fun onComponentSelected(component: ComponentName, directShare: Boolean, crossProfile: Boolean) /** Reports user invocation of a built-in system action. See [ShareAction]. */ fun onActionSelected(action: ShareAction) @@ -88,11 +88,15 @@ class ShareResultSenderImpl( IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } ) - override fun onComponentSelected(component: ComponentName, directShare: Boolean) { - Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + override fun onComponentSelected( + component: ComponentName, + directShare: Boolean, + crossProfile: Boolean + ) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare cross=$crossProfile") scope.launch { - val intent = createChosenComponentIntent(component, directShare) - intentDispatcher.dispatchIntent(resultSender, intent) + val intent = createChosenComponentIntent(component, directShare, crossProfile) + intent?.let { intentDispatcher.dispatchIntent(resultSender, it) } } } @@ -112,20 +116,38 @@ class ShareResultSenderImpl( private suspend fun createChosenComponentIntent( component: ComponentName, direct: Boolean, - ): Intent { - // Add extra with component name for backwards compatibility. - val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) - - // Add ChooserResult value for Android V+ + crossProfile: Boolean, + ): Intent? { if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { - intent.putExtra( - Intent.EXTRA_CHOOSER_RESULT, - ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) - ) + if (crossProfile) { + Log.i(TAG, "Redacting package from cross-profile ${Intent.EXTRA_CHOOSER_RESULT}") + return Intent() + .putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_UNKNOWN, null, direct) + ) + } else { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + return intent + } } else { - Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + if (crossProfile) { + // We can only send cross-profile results in the new ChooserResult format. + Log.i(TAG, "Omitting selection callback for cross-profile target") + return null + } else { + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + return intent + } } - return intent } @ResultType diff --git a/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt index c254a856..7b43360a 100644 --- a/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/ShareResultSenderImplTest.kt @@ -68,7 +68,7 @@ class ShareResultSenderImplTest { intentDispatcher = intentDispatcher ) - resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, false) runCurrent() val intentReceived = deferred.await() @@ -83,6 +83,43 @@ class ShareResultSenderImplTest { assertThat(chooserResult?.isShortcut).isTrue() } + @OptIn(ExperimentalCoroutinesApi::class) + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_crossProfile_chooserResultEnabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + // Invoke as in the previous test, but this time say that the selection was cross-profile. + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, true) + runCurrent() + + val intentReceived = deferred.await() + val chooserResult = + intentReceived.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult::class.java + ) + assertThat(chooserResult).isNotNull() + assertThat(chooserResult?.type).isEqualTo(ChooserResult.CHOOSER_RESULT_UNKNOWN) + assertThat(chooserResult?.selectedComponent).isNull() + assertThat(chooserResult?.isShortcut).isTrue() + assertThat(intentReceived.hasExtra(Intent.EXTRA_CHOSEN_COMPONENT)).isFalse() + } + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) @Test fun onComponentSelected_chooserResultDisabled() = runTest { @@ -102,7 +139,7 @@ class ShareResultSenderImplTest { intentDispatcher = intentDispatcher ) - resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true) + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, false) runCurrent() val intentReceived = deferred.await() @@ -121,6 +158,33 @@ class ShareResultSenderImplTest { .isFalse() } + @DisableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) + @Test + fun onComponentSelected_crossProfile_chooserResultDisabled() = runTest { + val pi = PendingIntent.getBroadcast(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE) + val deferred = CompletableDeferred() + val intentDispatcher = IntentSenderDispatcher { _, intent -> deferred.complete(intent) } + + flags.setFlag(Flags.FLAG_ENABLE_CHOOSER_RESULT, true) + + val resultSender = + ShareResultSenderImpl( + flags = flags, + scope = this, + backgroundDispatcher = UnconfinedTestDispatcher(testScheduler), + callerUid = Process.myUid(), + resultSender = pi.intentSender, + intentDispatcher = intentDispatcher + ) + + // Invoke as in the previous test, but this time say that the selection was cross-profile. + resultSender.onComponentSelected(ComponentName("example.com", "Foo"), true, true) + runCurrent() + + // In the pre-ChooserResult API, no callback intent is sent for cross-profile selections. + assertWithMessage("deferred result isComplete").that(deferred.isCompleted).isFalse() + } + @EnableCompatChanges(ChooserResult.SEND_CHOOSER_RESULT) @Test fun onActionSelected_chooserResultEnabled() = runTest { -- cgit v1.2.3-59-g8ed1b From 38ee8332d767014a9c69a737511bbc5aa705d5ec Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 31 Jul 2024 15:03:41 -0700 Subject: Update excluded components from payload selection callback Bug: 352496527 Test: atest IntentResolver-tests-unit Test: manual testing using ShareTest app Flag: com.android.intentresolver.shareousel_update_exclude_components_extra Change-Id: I610edf2c11a5beb114185c8724347b0eb0487ef1 --- aconfig/FeatureFlags.aconfig | 7 +++ .../android/intentresolver/ChooserActivity.java | 8 ++- .../interactor/UpdateChooserRequestInteractor.kt | 7 +++ .../payloadtoggle/domain/model/ShareouselUpdate.kt | 2 + .../domain/update/SelectionChangeCallback.kt | 17 ++++-- .../domain/update/SelectionChangeCallbackKosmos.kt | 2 - tests/unit/Android.bp | 1 + .../UpdateChooserRequestInteractorTest.kt | 24 ++++++++ .../update/SelectionChangeCallbackImplTest.kt | 65 +++++++++++++++++----- 9 files changed, 112 insertions(+), 21 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index c449bb43..b58b4daa 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -89,3 +89,10 @@ flag { description: "Use the unified preview image loader for all preview variations; support variable preview sizes." bug: "348665058" } + +flag { + name: "shareousel_update_exclude_components_extra" + namespace: "intentresolver" + description: "Allow Shareousel selection change callback to update Intent#EXTRA_EXCLUDE_COMPONENTS" + bug: "352496527" +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 84f29e21..7b43a21d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -23,6 +23,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; +import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; @@ -749,10 +750,15 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Intent newTargetIntent = newChooserRequest.getTargetIntent(); List oldAltIntents = oldChooserRequest.getAdditionalTargets(); List newAltIntents = newChooserRequest.getAdditionalTargets(); + List oldExcluded = oldChooserRequest.getFilteredComponentNames(); + List newExcluded = newChooserRequest.getFilteredComponentNames(); // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - // an artifact of the current implementation; revisit. - return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); + return !oldTargetIntent.equals(newTargetIntent) + || !oldAltIntents.equals(newAltIntents) + || (shareouselUpdateExcludeComponentsExtra() + && !oldExcluded.equals(newExcluded)); } private void recreatePagerAdapter() { diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt index dd16f0c1..4fe5e8d5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.content.Intent +import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel @@ -49,6 +50,12 @@ constructor( update.refinementIntentSender.getOrDefault(current.refinementIntentSender), metadataText = update.metadataText.getOrDefault(current.metadataText), chooserActions = update.customActions.getOrDefault(current.chooserActions), + filteredComponentNames = + if (shareouselUpdateExcludeComponentsExtra()) { + update.excludeComponents.getOrDefault(current.filteredComponentNames) + } else { + current.filteredComponentNames + } ) } update.customActions.onValue { actions -> diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt index 821e88a5..77f196e6 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.model +import android.content.ComponentName import android.content.Intent import android.content.IntentSender import android.service.chooser.ChooserAction @@ -31,4 +32,5 @@ data class ShareouselUpdate( val refinementIntentSender: ValueUpdate = ValueUpdate.Absent, val resultIntentSender: ValueUpdate = ValueUpdate.Absent, val metadataText: ValueUpdate = ValueUpdate.Absent, + val excludeComponents: ValueUpdate> = ValueUpdate.Absent, ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt index 479bff77..184cc027 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.update +import android.content.ComponentName import android.content.ContentInterface import android.content.Intent import android.content.Intent.EXTRA_ALTERNATE_INTENTS @@ -24,6 +25,7 @@ import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_METADATA_TEXT import android.content.IntentSender @@ -32,11 +34,11 @@ import android.os.Bundle import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget +import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.inject.AdditionalContent import com.android.intentresolver.inject.ChooserIntent -import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.ui.viewmodel.readAlternateIntents import com.android.intentresolver.ui.viewmodel.readChooserActions import com.android.intentresolver.validation.Invalid @@ -70,7 +72,6 @@ constructor( @AdditionalContent private val uri: Uri, @ChooserIntent private val chooserIntent: Intent, private val contentResolver: ContentInterface, - private val flags: ChooserServiceFlags, ) : SelectionChangeCallback { private val mutex = Mutex() @@ -90,7 +91,7 @@ constructor( ) } ?.let { bundle -> - return when (val result = readCallbackResponse(bundle, flags)) { + return when (val result = readCallbackResponse(bundle)) { is Valid -> { result.warnings.forEach { it.log(TAG) } result.value @@ -105,7 +106,6 @@ constructor( private fun readCallbackResponse( bundle: Bundle, - flags: ChooserServiceFlags ): ValidationResult { return validateFrom(bundle::get) { // An error is treated as an empty collection or null as the presence of a value indicates @@ -139,6 +139,14 @@ private fun readCallbackResponse( bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> optional(value(key)) } + val excludedComponents: ValueUpdate> = + if (shareouselUpdateExcludeComponentsExtra()) { + bundle.readValueUpdate(EXTRA_EXCLUDE_COMPONENTS) { key -> + optional(array(key)) ?: emptyList() + } + } else { + ValueUpdate.Absent + } ShareouselUpdate( customActions, @@ -148,6 +156,7 @@ private fun readCallbackResponse( refinementIntentSender, resultIntentSender, metadataText, + excludedComponents, ) } } diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt index 548b1f37..b26b562e 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackKosmos.kt @@ -19,7 +19,6 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.update import com.android.intentresolver.contentInterface import com.android.intentresolver.inject.additionalContentUri import com.android.intentresolver.inject.chooserIntent -import com.android.intentresolver.inject.chooserServiceFlags import com.android.systemui.kosmos.Kosmos val Kosmos.selectionChangeCallbackImpl by @@ -28,7 +27,6 @@ val Kosmos.selectionChangeCallbackImpl by additionalContentUri, chooserIntent, contentInterface, - chooserServiceFlags, ) } var Kosmos.selectionChangeCallback: SelectionChangeCallback by diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index 1ae8d883..aa1be2e0 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -33,6 +33,7 @@ android_test { "android.test.mock", "framework", "framework-res", + "flag-junit", ], resource_dirs: ["res"], diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt index 570c346c..32d040fe 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractorTest.kt @@ -18,7 +18,11 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor +import android.content.ComponentName import android.content.Intent +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import com.android.intentresolver.Flags.FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.pendingSelectionCallbackRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate @@ -29,9 +33,12 @@ import com.android.intentresolver.util.runKosmosTest import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import org.junit.Rule import org.junit.Test class UpdateChooserRequestInteractorTest { + @get:Rule val setFlagsRule = SetFlagsRule() + @Test fun updateTargetIntentWithSelection() = runKosmosTest { val selectionCallbackResult = ShareouselUpdate(metadataText = ValueUpdate.Value("update")) @@ -45,4 +52,21 @@ class UpdateChooserRequestInteractorTest { assertThat(pendingSelectionCallbackRepository.pendingTargetIntent.value).isNull() assertThat(chooserRequestRepository.chooserRequest.value.metadataText).isEqualTo("update") } + + @Test + @EnableFlags(FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA) + fun testSelectionResultWithExcludedComponents_chooserRequestIsUpdated() = runKosmosTest { + val excludedComponent = ComponentName("org.pkg.app", "Class") + val selectionCallbackResult = + ShareouselUpdate(excludeComponents = ValueUpdate.Value(listOf(excludedComponent))) + selectionChangeCallback = SelectionChangeCallback { selectionCallbackResult } + + backgroundScope.launch { processTargetIntentUpdatesInteractor.activate() } + + updateTargetIntentInteractor.updateTargetIntent(Intent()) + runCurrent() + + assertThat(chooserRequestRepository.chooserRequest.value.filteredComponentNames) + .containsExactly(excludedComponent) + } } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt index fd21ea3f..c1a1833a 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallbackImplTest.kt @@ -29,32 +29,34 @@ import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_METADATA_TEXT import android.content.Intent.EXTRA_STREAM import android.graphics.drawable.Icon import android.net.Uri import android.os.Bundle +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget -import android.service.chooser.Flags import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.Flags.FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate.Absent -import com.android.intentresolver.inject.FakeChooserServiceFlags import com.google.common.truth.Correspondence import com.google.common.truth.Correspondence.BinaryPredicate import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import java.lang.IllegalArgumentException import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.capture import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -62,16 +64,16 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class SelectionChangeCallbackImplTest { + @get:Rule val setFlagsRule = SetFlagsRule() + private val uri = Uri.parse("content://org.pkg/content-provider") private val chooserIntent = Intent(ACTION_CHOOSER) private val contentResolver = mock() private val context = InstrumentationRegistry.getInstrumentation().context - private val flags = - FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } @Test fun testPayloadChangeCallbackContact() = runTest { - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val u1 = createUri(1) val u2 = createUri(2) @@ -166,7 +168,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, arrayOf(a1, a2)) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND_MULTIPLE) val result = testSubject.onSelectionChanged(targetIntent) @@ -183,6 +185,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -204,7 +207,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShare) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -223,6 +226,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -239,7 +243,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_ALTERNATE_INTENTS, alternateIntents) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -264,6 +268,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -289,7 +294,7 @@ class SelectionChangeCallbackImplTest { Bundle().apply { putParcelableArray(EXTRA_CHOOSER_TARGETS, arrayOf(t1, t2)) } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -317,6 +322,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -331,7 +337,7 @@ class SelectionChangeCallbackImplTest { } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -344,6 +350,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender.getOrThrow()).isNotNull() assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -358,7 +365,7 @@ class SelectionChangeCallbackImplTest { } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -371,6 +378,7 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender.getOrThrow()).isNotNull() assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents).isEqualTo(Absent) } @Test @@ -379,7 +387,7 @@ class SelectionChangeCallbackImplTest { whenever(contentResolver.call(any(), any(), any(), any())) .thenReturn(Bundle().apply { putCharSequence(EXTRA_METADATA_TEXT, metadataText) }) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) @@ -392,6 +400,35 @@ class SelectionChangeCallbackImplTest { assertThat(result.refinementIntentSender).isEqualTo(Absent) assertThat(result.resultIntentSender).isEqualTo(Absent) assertThat(result.metadataText.getOrThrow()).isEqualTo(metadataText) + assertThat(result.excludeComponents).isEqualTo(Absent) + } + + @Test + @EnableFlags(FLAG_SHAREOUSEL_UPDATE_EXCLUDE_COMPONENTS_EXTRA) + fun testPayloadChangeCallbackUpdatesExcludedComponents_valueUpdated() = runTest { + val excludedComponent = ComponentName("org.pkg.app", "org.pkg.app.TheClass") + whenever(contentResolver.call(any(), any(), any(), any())) + .thenReturn( + Bundle().apply { + putParcelableArray(EXTRA_EXCLUDE_COMPONENTS, arrayOf(excludedComponent)) + } + ) + + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) + + val targetIntent = Intent(ACTION_SEND) + val result = testSubject.onSelectionChanged(targetIntent) + + assertWithMessage("Callback result should not be null").that(result).isNotNull() + requireNotNull(result) + assertThat(result.customActions).isEqualTo(Absent) + assertThat(result.modifyShareAction).isEqualTo(Absent) + assertThat(result.alternateIntents).isEqualTo(Absent) + assertThat(result.callerTargets).isEqualTo(Absent) + assertThat(result.refinementIntentSender).isEqualTo(Absent) + assertThat(result.resultIntentSender).isEqualTo(Absent) + assertThat(result.metadataText).isEqualTo(Absent) + assertThat(result.excludeComponents.getOrThrow()).containsExactly(excludedComponent) } @Test @@ -409,7 +446,7 @@ class SelectionChangeCallbackImplTest { } ) - val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver, flags) + val testSubject = SelectionChangeCallbackImpl(uri, chooserIntent, contentResolver) val targetIntent = Intent(ACTION_SEND) val result = testSubject.onSelectionChanged(targetIntent) -- cgit v1.2.3-59-g8ed1b From 1a429e9038a47d30470df7e278fd1beabe2bc387 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 2 Aug 2024 12:10:27 -0700 Subject: Add disabled state to chooser targets The new functinality is not used yet as this is a preparation step. Allows chooser targets to be disabled: unclickable with grey-scale icons. Bug: 349468879 Test: presubmits Flag: EXEMPT refactoring Change-Id: I202a496954c08da27b7cc7c0f9df8d04d9916350 --- .../android/intentresolver/ChooserListAdapter.java | 17 +++++++- .../intentresolver/ResolverListAdapter.java | 30 +------------- .../profiles/ChooserMultiProfilePagerAdapter.java | 9 +++++ .../util/graphics/SuspendedMatrixColorFilter.kt | 46 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 07a615a5..548cd6ac 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -18,6 +18,7 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix; import android.app.ActivityManager; import android.app.prediction.AppTarget; @@ -153,6 +154,7 @@ public class ChooserListAdapter extends ResolverListAdapter { }; private boolean mAnimateItems = true; + private boolean mTargetsEnabled = true; public ChooserListAdapter( Context context, @@ -306,6 +308,16 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Set the enabled state for all targets. + */ + public void setTargetsEnabled(boolean isEnabled) { + if (mTargetsEnabled != isEnabled) { + mTargetsEnabled = isEnabled; + notifyDataSetChanged(); + } + } + public void setAnimateItems(boolean animateItems) { mAnimateItems = animateItems; } @@ -353,7 +365,7 @@ public class ChooserListAdapter extends ResolverListAdapter { @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { - view.setEnabled(!isDestroyed()); + view.setEnabled(!isDestroyed() && mTargetsEnabled); final ViewHolder holder = (ViewHolder) view.getTag(); resetViewHolder(holder); @@ -413,6 +425,9 @@ public class ChooserListAdapter extends ResolverListAdapter { } holder.bindIcon(info); + if (info.hasDisplayIcon() && !mTargetsEnabled) { + holder.icon.setColorFilter(getSuspendedColorMatrix()); + } if (mAnimateItems && info.hasDisplayIcon()) { mAnimationTracker.animateIcon(holder.icon, info); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 5fd37d43..7ca1c724 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,14 +16,14 @@ package com.android.intentresolver; +import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix; + import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.LabeledIntent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.graphics.ColorMatrix; -import android.graphics.ColorMatrixColorFilter; import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.RemoteException; @@ -63,9 +63,6 @@ import java.util.concurrent.atomic.AtomicBoolean; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; - @Nullable // TODO: other model for lazy computation? Or just precompute? - private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; - protected final Context mContext; protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; @@ -797,29 +794,6 @@ public class ResolverListAdapter extends BaseAdapter { return mDestroyed.get(); } - private static ColorMatrixColorFilter getSuspendedColorMatrix() { - if (sSuspendedMatrixColorFilter == null) { - - int grayValue = 127; - float scale = 0.5f; // half bright - - ColorMatrix tempBrightnessMatrix = new ColorMatrix(); - float[] mat = tempBrightnessMatrix.getArray(); - mat[0] = scale; - mat[6] = scale; - mat[12] = scale; - mat[4] = grayValue; - mat[9] = grayValue; - mat[14] = grayValue; - - ColorMatrix matrix = new ColorMatrix(); - matrix.setSaturation(0.0f); - matrix.preConcat(tempBrightnessMatrix); - sSuspendedMatrixColorFilter = new ColorMatrixColorFilter(matrix); - } - return sSuspendedMatrixColorFilter; - } - protected final Drawable loadIconPlaceholder() { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } diff --git a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java index 8aee0da1..9176cd35 100644 --- a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -112,6 +112,15 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< } } + /** + * Set enabled status for all targets in all profiles. + */ + public void setTargetsEnabled(boolean isEnabled) { + for (int i = 0, size = getItemCount(); i < size; i++) { + getPageAdapterForIndex(i).getListAdapter().setTargetsEnabled(isEnabled); + } + } + private static ViewGroup makeProfileView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); ViewGroup rootView = diff --git a/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt new file mode 100644 index 00000000..3e2d8e2a --- /dev/null +++ b/java/src/com/android/intentresolver/util/graphics/SuspendedMatrixColorFilter.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 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("SuspendedMatrixColorFilter") + +package com.android.intentresolver.util.graphics + +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter + +val suspendedColorMatrix by lazy { + val grayValue = 127f + val scale = 0.5f // half bright + + val tempBrightnessMatrix = + ColorMatrix().apply { + array.let { m -> + m[0] = scale + m[6] = scale + m[12] = scale + m[4] = grayValue + m[9] = grayValue + m[14] = grayValue + } + } + + val matrix = + ColorMatrix().apply { + setSaturation(0.0f) + preConcat(tempBrightnessMatrix) + } + ColorMatrixColorFilter(matrix) +} -- cgit v1.2.3-59-g8ed1b From 014824053450ef297505d1058987f95a4c5d3231 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 8 Aug 2024 20:57:51 -0700 Subject: Unify profile records and profile tabs creation. Create one ProfileRecord per profile tab. Utilize legacy profile tab logic to build an ordered map of ProfileRecords and generate profile tabs from these records. This change enforces 1:1 relation between profile records and tabs and highlights the exsiting dependency between them. Bug: 343300158 Test: manual testing with varius profile combinations available on the device Flag: EXEMPT refactoring Change-Id: I47bbbbdcc7832d6b881a272b0e489622c31de231 --- .../android/intentresolver/ChooserActivity.java | 49 ++++++++++++---------- .../intentresolver/ChooserWrapperActivity.java | 2 +- 2 files changed, 27 insertions(+), 24 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 8c6c7b7f..b0344565 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -153,8 +153,10 @@ import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -305,7 +307,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private final Map mProfileRecords = new HashMap<>(); + private final Map mProfileRecords = new LinkedHashMap<>(); private boolean mExcludeSharedText = false; /** @@ -518,6 +520,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfilePagerResources, mRequest, mProfiles, + mProfileRecords.values(), mProfileAvailability, mRequest.getInitialIntents(), mMaxTargetsPerRow); @@ -787,6 +790,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mProfilePagerResources, mRequest, mProfiles, + mProfileRecords.values(), mProfileAvailability, mRequest.getInitialIntents(), mMaxTargetsPerRow); @@ -1351,26 +1355,23 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = mProfiles.getPersonalHandle(); - ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); - if (record.shortcutLoader == null) { - Tracer.INSTANCE.endLaunchToShortcutTrace(); - } - - UserHandle workUserHandle = mProfiles.getWorkHandle(); - if (workUserHandle != null) { - createProfileRecord(workUserHandle, targetIntentFilter, factory); - } - UserHandle privateUserHandle = mProfiles.getPrivateHandle(); - if (privateUserHandle != null && mProfileAvailability.isAvailable( - requireNonNull(mProfiles.getPrivateProfile()))) { - createProfileRecord(privateUserHandle, targetIntentFilter, factory); + Profile launchedAsProfile = mProfiles.getLaunchedAsProfile(); + for (Profile profile : mProfiles.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !mProfileAvailability.isAvailable(profile)) { + continue; + } + ProfileRecord record = createProfileRecord(profile, targetIntentFilter, factory); + if (profile.equals(launchedAsProfile) && record.shortcutLoader == null) { + Tracer.INSTANCE.endLaunchToShortcutTrace(); + } } } private ProfileRecord createProfileRecord( - UserHandle userHandle, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + Profile profile, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + UserHandle userHandle = profile.getPrimary().getHandle(); AppPredictor appPredictor = factory.create(userHandle); ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null @@ -1380,7 +1381,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements userHandle, targetIntentFilter, shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - ProfileRecord record = new ProfileRecord(appPredictor, shortcutLoader); + ProfileRecord record = new ProfileRecord(profile, appPredictor, shortcutLoader); mProfileRecords.put(userHandle.getIdentifier(), record); return record; } @@ -1415,6 +1416,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ProfilePagerResources profilePagerResources, ChooserRequest request, ProfileHelper profileHelper, + Collection profileRecords, ProfileAvailability profileAvailability, List initialIntents, int maxTargetsPerRow) { @@ -1426,11 +1428,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List payloadIntents = request.getPayloadIntents(); List> tabs = new ArrayList<>(); - for (Profile profile : profileHelper.getProfiles()) { - if (profile.getType() == Profile.Type.PRIVATE - && !profileAvailability.isAvailable(profile)) { - continue; - } + for (ProfileRecord record : profileRecords) { + Profile profile = record.profile; ChooserGridAdapter adapter = createChooserGridAdapter( context, payloadIntents, @@ -2044,7 +2043,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements initialIntents, rList, filterLastUsed, - createListController(userHandle), + resolverListController, userHandle, targetIntent, referrerFillInIntent, @@ -2680,6 +2679,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private static class ProfileRecord { + public final Profile profile; + /** The {@link AppPredictor} for this profile, if any. */ @Nullable public final AppPredictor appPredictor; @@ -2691,8 +2692,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements public long loadingStartTime; private ProfileRecord( + Profile profile, @Nullable AppPredictor appPredictor, @Nullable ShortcutLoader shortcutLoader) { + this.profile = profile; this.appPredictor = appPredictor; this.shortcutLoader = shortcutLoader; } diff --git a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java index 930c84f0..6ff7af3f 100644 --- a/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -65,7 +65,7 @@ public class ChooserWrapperActivity extends ChooserActivity implements IChooserW initialIntents, rList, filterLastUsed, - createListController(userHandle), + resolverListController, userHandle, targetIntent, referrerFillInIntent, -- cgit v1.2.3-59-g8ed1b From 45aee5e60a4f300bed30db58d3aa66c4a1917866 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 8 Aug 2024 21:27:31 -0700 Subject: Per-profile caller-provided direct target collections Instead of (partially) relying on the active profile value when deciding whether the caller-provided direct targets should be added to the list, define a list of caller-provided targets for each profile and use those lists unconditionally. Bug: 343300158 Test: manual tests: launche the ShareTest app from different profiles and confirm that the caller-provided targes are added only to the right profile (the profile the Chooser was launched from). Flag: EXEMPT refactoring Change-Id: I9c4ba35d48a090b21f96ddf4723f7b28654acb48 --- .../android/intentresolver/ChooserActivity.java | 46 ++++++++++++++-------- 1 file changed, 30 insertions(+), 16 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index b0344565..845d1e1d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1362,7 +1362,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements && !mProfileAvailability.isAvailable(profile)) { continue; } - ProfileRecord record = createProfileRecord(profile, targetIntentFilter, factory); + ProfileRecord record = createProfileRecord( + profile, + targetIntentFilter, + launchedAsProfile.equals(profile) + ? mRequest.getCallerChooserTargets() + : Collections.emptyList(), + factory); if (profile.equals(launchedAsProfile) && record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } @@ -1370,7 +1376,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ProfileRecord createProfileRecord( - Profile profile, IntentFilter targetIntentFilter, AppPredictorFactory factory) { + Profile profile, + IntentFilter targetIntentFilter, + List callerTargets, + AppPredictorFactory factory) { UserHandle userHandle = profile.getPrimary().getHandle(); AppPredictor appPredictor = factory.create(userHandle); ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() @@ -1381,7 +1390,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements userHandle, targetIntentFilter, shortcutsResult -> onShortcutsLoaded(userHandle, shortcutsResult)); - ProfileRecord record = new ProfileRecord(profile, appPredictor, shortcutLoader); + ProfileRecord record = new ProfileRecord( + profile, appPredictor, shortcutLoader, callerTargets); mProfileRecords.put(userHandle.getIdentifier(), record); return record; } @@ -1655,17 +1665,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } - private void addCallerChooserTargets() { - if (!mRequest.getCallerChooserTargets().isEmpty()) { - // Send the caller's chooser targets only to the default profile. - if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( - /* origTarget */ null, - new ArrayList<>(mRequest.getCallerChooserTargets()), - TARGET_TYPE_DEFAULT, - /* directShareShortcutInfoCache */ Collections.emptyMap(), - /* directShareAppTargetCache */ Collections.emptyMap()); - } + private void addCallerChooserTargets(ChooserListAdapter adapter) { + ProfileRecord record = getProfileRecord(adapter.getUserHandle()); + List callerTargets = record == null + ? Collections.emptyList() + : record.callerTargets; + if (!callerTargets.isEmpty()) { + adapter.addServiceResults( + /* origTarget */ null, + new ArrayList<>(mRequest.getCallerChooserTargets()), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ Collections.emptyMap(), + /* directShareAppTargetCache */ Collections.emptyMap()); } } @@ -2408,7 +2419,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } - addCallerChooserTargets(); + addCallerChooserTargets(chooserListAdapter); getEventLog().logSharesheetAppLoadComplete(); maybeQueryAdditionalPostProcessingTargets( listProfileUserHandle, @@ -2689,15 +2700,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements */ @Nullable public final ShortcutLoader shortcutLoader; + public final List callerTargets; public long loadingStartTime; private ProfileRecord( Profile profile, @Nullable AppPredictor appPredictor, - @Nullable ShortcutLoader shortcutLoader) { + @Nullable ShortcutLoader shortcutLoader, + List callerTargets) { this.profile = profile; this.appPredictor = appPredictor; this.shortcutLoader = shortcutLoader; + this.callerTargets = callerTargets; } public void destroy() { -- cgit v1.2.3-59-g8ed1b From e70191e44f659a10e913ffb20335c959bf43a39c Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 7 Aug 2024 11:22:19 -0700 Subject: Use nested scope for ShortcutLoader Use a nested coroutine scope for each ShortcutLoader instance and close it when the instance is stopped being used. Fix: 358135601 Test: atest IntentResolver-tests-unit Test: manual testing checking the logs Flag: com.android.intentresolver.fix_shortcut_loader_job_leak Change-Id: I84bbca75612f153193b03e24bfc0cb8842a8d3e2 --- aconfig/FeatureFlags.aconfig | 10 ++++ .../android/intentresolver/ChooserActivity.java | 3 ++ .../intentresolver/shortcuts/ShortcutLoader.kt | 56 +++++++++++++++------- .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 30 ++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 87a584c9..07ef198f 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -76,6 +76,16 @@ flag { } } +flag { + name: "fix_shortcut_loader_job_leak" + namespace: "intentresolver" + description: "User a nested coroutine scope for shortcut loader instances" + bug: "358135601" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "preview_image_loader" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 8c6c7b7f..d3c21f3e 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2701,6 +2701,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (appPredictor != null) { appPredictor.destroy(); } + if (shortcutLoader != null) { + shortcutLoader.destroy(); + } } } } diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 08230d90..68412256 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,6 +35,7 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import com.android.intentresolver.Flags.fixShortcutLoaderJobLeak import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing @@ -43,7 +44,9 @@ import java.util.function.Consumer import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine @@ -65,7 +68,7 @@ open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, - private val scope: CoroutineScope, + parentScope: CoroutineScope, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, @@ -73,6 +76,8 @@ constructor( private val dispatcher: CoroutineDispatcher, private val callback: Consumer ) { + private val scope = + if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager private val appPredictorCallback = @@ -88,6 +93,9 @@ constructor( private val isDestroyed get() = !scope.isActive + private val id + get() = System.identityHashCode(this).toString(Character.MAX_RADIX) + @MainThread constructor( context: Context, @@ -132,7 +140,7 @@ constructor( } .invokeOnCompletion { runCatching { appPredictor?.unregisterPredictionUpdates(appPredictorCallback) } - Log.d(TAG, "destroyed, user: $userHandle") + Log.d(TAG, "[$id] destroyed, user: $userHandle") } reset() } @@ -140,7 +148,7 @@ constructor( /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */ @OpenForTesting open fun reset() { - Log.d(TAG, "reset shortcut loader for user $userHandle") + Log.d(TAG, "[$id] reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) scope.launch(dispatcher) { loadShortcuts() } @@ -155,14 +163,21 @@ constructor( appTargetSource.tryEmit(appTargets) } + @OpenForTesting + open fun destroy() { + if (fixShortcutLoaderJobLeak()) { + scope.cancel() + } + } + @WorkerThread private fun loadShortcuts() { // no need to query direct share for work profile when its locked or disabled if (!shouldQueryDirectShareTargets()) { - Log.d(TAG, "skip shortcuts loading for user $userHandle") + Log.d(TAG, "[$id] skip shortcuts loading for user $userHandle") return } - Log.d(TAG, "querying direct share targets for user $userHandle") + Log.d(TAG, "[$id] querying direct share targets for user $userHandle") queryDirectShareTargets(false) } @@ -170,7 +185,7 @@ constructor( private fun queryDirectShareTargets(skipAppPredictionService: Boolean) { if (!skipAppPredictionService && appPredictor != null) { try { - Log.d(TAG, "query AppPredictor for user $userHandle") + Log.d(TAG, "[$id] query AppPredictor for user $userHandle") Tracer.beginAppPredictorQueryTrace(userHandle) appPredictor.requestPredictionUpdate() return @@ -180,12 +195,12 @@ constructor( if (isDestroyed) { return } - Log.e(TAG, "Failed to query AppPredictor for user $userHandle", e) + Log.e(TAG, "[$id] failed to query AppPredictor for user $userHandle", e) } } // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) { - Log.d(TAG, "skip querying ShortcutManager for $userHandle") + Log.d(TAG, "[$id] skip querying ShortcutManager for $userHandle") sendShareShortcutInfoList( emptyList(), isFromAppPredictor = false, @@ -193,12 +208,12 @@ constructor( ) return } - Log.d(TAG, "query ShortcutManager for user $userHandle") + Log.d(TAG, "[$id] query ShortcutManager for user $userHandle") val shortcuts = runTracing("shortcut-mngr-${userHandle.identifier}") { queryShortcutManager(targetIntentFilter) } - Log.d(TAG, "receive shortcuts from ShortcutManager for user $userHandle") + Log.d(TAG, "[$id] receive shortcuts from ShortcutManager for user $userHandle") sendShareShortcutInfoList(shortcuts, false, null) } @@ -210,14 +225,13 @@ constructor( val pm = context.createContextAsUser(userHandle, 0 /* flags */).packageManager return sm?.getShareTargets(targetIntentFilter)?.filter { pm.isPackageEnabled(it.targetComponent.packageName) - } - ?: emptyList() + } ?: emptyList() } @WorkerThread private fun onAppPredictorCallback(appPredictorTargets: List) { endAppPredictorQueryTrace(userHandle) - Log.d(TAG, "receive app targets from AppPredictor") + Log.d(TAG, "[$id] receive app targets from AppPredictor") if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { // APS may be disabled, so try querying targets ourselves. queryDirectShareTargets(true) @@ -330,6 +344,11 @@ constructor( val directShareShortcutInfoCache: Map ) + private fun endAppPredictorQueryTrace(userHandle: UserHandle) { + val duration = Tracer.endAppPredictorQueryTrace(userHandle) + Log.d(TAG, "[$id] AppPredictor query duration for user $userHandle: $duration ms") + } + /** Shortcuts grouped by app. */ class ShortcutResultInfo( val appTarget: DisplayResolveInfo, @@ -378,9 +397,12 @@ constructor( .getOrDefault(false) } - private fun endAppPredictorQueryTrace(userHandle: UserHandle) { - val duration = Tracer.endAppPredictorQueryTrace(userHandle) - Log.d(TAG, "AppPredictor query duration for user $userHandle: $duration ms") - } + /** + * Creates a new coroutine scope and makes its job a child of the given, `this`, coroutine + * scope's job. This ensures that the new scope will be canceled when the parent scope is + * canceled (but not vice versa). + */ + private fun CoroutineScope.createChildScope() = + CoroutineScope(coroutineContext + Job(parent = coroutineContext[Job])) } } diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index fbdc062b..9c84cdcf 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -26,7 +26,10 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.ShortcutManager import android.os.UserHandle import android.os.UserManager +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule import androidx.test.filters.SmallTest +import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUT_LOADER_JOB_LEAK import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.createAppTarget import com.android.intentresolver.createShareShortcutInfo @@ -42,6 +45,7 @@ import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor @@ -56,6 +60,8 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) @SmallTest class ShortcutLoaderTest { + @get:Rule val flagRule = SetFlagsRule() + private val appInfo = ApplicationInfo().apply { enabled = true @@ -465,6 +471,30 @@ class ShortcutLoaderTest { testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) } + @Test + @EnableFlags(FLAG_FIX_SHORTCUT_LOADER_JOB_LEAK) + fun test_ShortcutLoaderDestroyed_appPredictorCallbackUnregisteredAndWatchdogCancelled() { + scope.runTest { + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + testSubject.destroy() + + verify(appPredictor, times(1)).registerPredictionUpdates(any(), any()) + verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) + } + } + private fun testDisabledWorkProfileDoNotCallSystem( isUserRunning: Boolean = true, isUserUnlocked: Boolean = true, -- cgit v1.2.3-59-g8ed1b From 6872117b988dbf42ab81f078c5c27f8182517d0a Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 12 Aug 2024 10:53:47 -0700 Subject: Fix direct share row flashing upon Shareousel selection change. Upon payload selection change, copy direct share targets into the new adapters but keep the items disabled until the new set of shortcuts is loaded. To have all targets updated at once, publish the caller-provided targets together with the remaining direct shares. Fix: 343300158 Test: manual testing: using the ShareTest app, launch the Shareousel with a caller-provided targets and make the payload selection. Flag: com.android.intentresolver.fix_shortcuts_flashing Change-Id: Iff5ea6c892708daa9cca968a56a03aaaf80196f6 --- aconfig/FeatureFlags.aconfig | 10 +++++++ .../android/intentresolver/ChooserActivity.java | 24 ++++++++++++++- .../android/intentresolver/ChooserListAdapter.java | 35 ++++++++++++++++++++-- 3 files changed, 66 insertions(+), 3 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 07ef198f..d6116c9a 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -86,6 +86,16 @@ flag { } } +flag { + name: "fix_shortcuts_flashing" + namespace: "intentresolver" + description: "Do not flash shortcuts on payload selection change" + bug: "343300158" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "preview_image_loader" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index cc8e3a11..51d8785f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -24,6 +24,7 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; +import static com.android.intentresolver.Flags.fixShortcutsFlashing; import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; @@ -785,6 +786,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } // Update the pager adapter but do not attach it to the view till the targets are reloaded, // see onChooserAppTargetsLoaded method. + ChooserMultiProfilePagerAdapter oldPagerAdapter = + mChooserMultiProfilePagerAdapter; mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( /* context = */ this, mProfilePagerResources, @@ -824,6 +827,19 @@ public class ChooserActivity extends Hilt_ChooserActivity implements postRebuildList( mChooserMultiProfilePagerAdapter.rebuildTabs( mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent())); + if (fixShortcutsFlashing() && oldPagerAdapter != null) { + for (int i = 0, count = mChooserMultiProfilePagerAdapter.getCount(); i < count; i++) { + ChooserListAdapter listAdapter = + mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i) + .getListAdapter(); + ChooserListAdapter oldListAdapter = + oldPagerAdapter.getListAdapterForUserHandle(listAdapter.getUserHandle()); + if (oldListAdapter != null) { + listAdapter.copyDirectTargetsFrom(oldListAdapter); + listAdapter.setDirectTargetsEnabled(false); + } + } + } setTabsViewEnabled(false); } @@ -2419,7 +2435,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } - addCallerChooserTargets(chooserListAdapter); + if (!fixShortcutsFlashing()) { + addCallerChooserTargets(chooserListAdapter); + } getEventLog().logSharesheetAppLoadComplete(); maybeQueryAdditionalPostProcessingTargets( listProfileUserHandle, @@ -2449,6 +2467,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ChooserListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle); if (adapter != null) { + if (fixShortcutsFlashing()) { + adapter.setDirectTargetsEnabled(true); + addCallerChooserTargets(adapter); + } for (ShortcutLoader.ShortcutResultInfo resultInfo : result.getShortcutsByApp()) { adapter.addServiceResults( resultInfo.getAppTarget(), diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 548cd6ac..26e2feeb 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -155,6 +155,7 @@ public class ChooserListAdapter extends ResolverListAdapter { private boolean mAnimateItems = true; private boolean mTargetsEnabled = true; + private boolean mDirectTargetsEnabled = true; public ChooserListAdapter( Context context, @@ -318,6 +319,18 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Set the enabled state for direct targets. + */ + public void setDirectTargetsEnabled(boolean isEnabled) { + if (mDirectTargetsEnabled != isEnabled) { + mDirectTargetsEnabled = isEnabled; + if (!mServiceTargets.isEmpty() && !isDirectTargetRowEmptyState()) { + notifyDataSetChanged(); + } + } + } + public void setAnimateItems(boolean animateItems) { mAnimateItems = animateItems; } @@ -365,7 +378,8 @@ public class ChooserListAdapter extends ResolverListAdapter { @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { - view.setEnabled(!isDestroyed() && mTargetsEnabled); + final boolean isEnabled = !isDestroyed() && mTargetsEnabled; + view.setEnabled(isEnabled); final ViewHolder holder = (ViewHolder) view.getTag(); resetViewHolder(holder); @@ -390,6 +404,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } if (info.isSelectableTargetInfo()) { + view.setEnabled(isEnabled && mDirectTargetsEnabled); // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); CharSequence appName = @@ -747,7 +762,7 @@ public class ChooserListAdapter extends ResolverListAdapter { Map directShareToShortcutInfos, Map directShareToAppTargets) { // Avoid inserting any potentially late results. - if ((mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo()) { + if (isDirectTargetRowEmptyState()) { return; } boolean isShortcutResult = targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER @@ -769,6 +784,22 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + /** + * Copy direct targets from another ChooserListAdapter instance + */ + public void copyDirectTargetsFrom(ChooserListAdapter adapter) { + if (adapter.isDirectTargetRowEmptyState()) { + return; + } + + mServiceTargets.clear(); + mServiceTargets.addAll(adapter.mServiceTargets); + } + + private boolean isDirectTargetRowEmptyState() { + return (mServiceTargets.size() == 1) && mServiceTargets.get(0).isEmptyTargetInfo(); + } + /** * Use the scoring system along with artificial boosts to create up to 4 distinct buckets: *
    -- cgit v1.2.3-59-g8ed1b From 2f6086166956949bc5ab732795f671b1800cb613 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Tue, 6 Aug 2024 13:46:56 -0700 Subject: Add timeout for AppPredictor response. Timeout AppPredictor callbacks and fallback to ShortcutManager. Bug: 295956687 Bug: 343300158 Test: atest IntentResolver-tests-unit Flag: com.android.intentresolver.fix_shortcuts_flashing Change-Id: Ia6e5643451a840e10213f242c6c79364b0193e78 --- .../intentresolver/shortcuts/ShortcutLoader.kt | 28 +++++ .../intentresolver/shortcuts/ShortcutLoaderTest.kt | 139 +++++++++++++++++++++ 2 files changed, 167 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 68412256..1fedee30 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -36,18 +36,22 @@ import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.android.intentresolver.Flags.fixShortcutLoaderJobLeak +import com.android.intentresolver.Flags.fixShortcutsFlashing import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing import java.util.concurrent.Executor import java.util.function.Consumer +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.asExecutor import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter @@ -80,6 +84,7 @@ constructor( if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager + private val appPredictorWatchdog = atomic(null) private val appPredictorCallback = ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() @@ -186,8 +191,29 @@ constructor( if (!skipAppPredictionService && appPredictor != null) { try { Log.d(TAG, "[$id] query AppPredictor for user $userHandle") + + val watchdogJob = + if (fixShortcutsFlashing()) { + scope + .launch(start = CoroutineStart.LAZY) { + delay(APP_PREDICTOR_RESPONSE_TIMEOUT_MS) + Log.w(TAG, "AppPredictor response timeout for user: $userHandle") + appPredictorCallback.onTargetsAvailable(emptyList()) + } + .also { job -> + appPredictorWatchdog.getAndSet(job)?.cancel() + job.invokeOnCompletion { + appPredictorWatchdog.compareAndSet(job, null) + } + } + } else { + null + } + Tracer.beginAppPredictorQueryTrace(userHandle) appPredictor.requestPredictionUpdate() + + watchdogJob?.start() return } catch (e: Throwable) { endAppPredictorQueryTrace(userHandle) @@ -230,6 +256,7 @@ constructor( @WorkerThread private fun onAppPredictorCallback(appPredictorTargets: List) { + appPredictorWatchdog.value?.cancel() endAppPredictorQueryTrace(userHandle) Log.d(TAG, "[$id] receive app targets from AppPredictor") if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { @@ -378,6 +405,7 @@ constructor( } companion object { + @VisibleForTesting const val APP_PREDICTOR_RESPONSE_TIMEOUT_MS = 2_000L private const val TAG = "ShortcutLoader" private fun PackageManager.isPackageEnabled(packageName: String): Boolean { diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index 9c84cdcf..d11cb460 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -26,9 +26,11 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.ShortcutManager import android.os.UserHandle import android.os.UserManager +import android.platform.test.annotations.DisableFlags import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule import androidx.test.filters.SmallTest +import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUTS_FLASHING import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUT_LOADER_JOB_LEAK import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.createAppTarget @@ -322,6 +324,143 @@ class ShortcutLoaderTest { } } + @Test + @DisableFlags(FLAG_FIX_SHORTCUTS_FLASHING) + fun test_appPredictorNotResponding_noCallbackFromShortcutLoader() { + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + on { getShareTargets(intentFilter) } doReturn shortcutManagerResult + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + + scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS * 2) + verify(callback, never()).accept(any()) + } + } + + @Test + @EnableFlags(FLAG_FIX_SHORTCUTS_FLASHING) + fun test_appPredictorNotResponding_timeoutAndFallbackToShortcutManager() { + scope.runTest { + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + val matchingAppTarget = createAppTarget(matchingShortcutInfo) + val shortcuts = + listOf( + matchingAppTarget, + // an AppTarget that does not belong to any resolved application; should be + // ignored + createAppTarget( + createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + ) + val appPredictorCallbackCaptor = argumentCaptor() + verify(appPredictor, atLeastOnce()) + .registerPredictionUpdates(any(), appPredictorCallbackCaptor.capture()) + appPredictorCallbackCaptor.firstValue.onTargetsAvailable(shortcuts) + + scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS * 2) + verify(callback, times(1)).accept(any()) + } + } + + @Test + @EnableFlags(FLAG_FIX_SHORTCUTS_FLASHING) + fun test_appPredictorResponding_appPredictorTimeoutJobIsCancelled() { + scope.runTest { + val shortcutManagerResult = + listOf( + ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), + // mismatching shortcut + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + ) + val shortcutManager = + mock { + on { getShareTargets(intentFilter) } doReturn shortcutManagerResult + } + whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) + val testSubject = + ShortcutLoader( + context, + backgroundScope, + appPredictor, + UserHandle.of(0), + true, + intentFilter, + dispatcher, + callback + ) + + testSubject.updateAppTargets(appTargets) + + verify(appPredictor, times(1)).requestPredictionUpdate() + + scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS / 2) + verify(callback, never()).accept(any()) + + val resultCaptor = argumentCaptor() + scheduler.advanceTimeBy(ShortcutLoader.APP_PREDICTOR_RESPONSE_TIMEOUT_MS) + verify(callback, times(1)).accept(resultCaptor.capture()) + val result = resultCaptor.firstValue + assertWithMessage("An ShortcutManager result is expected") + .that(result.isFromAppPredictor) + .isFalse() + assertWithMessage("Wrong input app targets in the result") + .that(appTargets) + .asList() + .containsExactlyElementsIn(result.appTargets) + .inOrder() + assertWithMessage("Wrong shortcut count").that(result.shortcutsByApp).hasLength(1) + assertWithMessage("Wrong app target") + .that(appTarget) + .isEqualTo(result.shortcutsByApp[0].appTarget) + for (shortcut in result.shortcutsByApp[0].shortcuts) { + assertWithMessage( + "AppTargets are not expected the cache of a ShortcutManager result" + ) + .that(result.directShareAppTargetCache) + .isEmpty() + assertWithMessage("Wrong ShortcutInfo in the cache") + .that(matchingShortcutInfo) + .isEqualTo(result.directShareShortcutInfoCache[shortcut]) + } + } + } + @Test fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() = scope.runTest { -- cgit v1.2.3-59-g8ed1b From 754d59681277b2afdd52b79746a593488f132e4b Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 1 Aug 2024 14:56:31 -0700 Subject: Allow toggling of final shareousel item When no items selected the UI will: * change the chooser headline; * make the custom action row invisible; * make all targets disabled and all target icons greyscale. Fix: 349468879 Test: atest IntentResolver-tests-unit Test: manual functionality testing Flag: com.android.intentresolver.unselect_final_item Change-Id: I53b9c908943b1f1003cb0131a6dec8abc26ec782 --- aconfig/FeatureFlags.aconfig | 7 +++ java/res/values/strings.xml | 3 ++ .../android/intentresolver/ChooserActivity.java | 9 +++- .../com/android/intentresolver/ChooserHelper.kt | 40 ++++++++++++--- .../android/intentresolver/ChooserListAdapter.java | 6 +-- .../intentresolver/ResolverListAdapter.java | 18 ++++++- .../contentpreview/HeadlineGenerator.kt | 2 + .../contentpreview/HeadlineGeneratorImpl.kt | 3 ++ .../data/repository/PreviewSelectionsRepository.kt | 4 +- .../domain/interactor/SelectionInteractor.kt | 9 +++- .../ui/composable/ShareouselComposable.kt | 58 ++++++++++++++-------- .../ui/viewmodel/ShareouselViewModel.kt | 16 ++++-- .../domain/interactor/SelectionInteractorTest.kt | 35 +++++++++++++ .../ui/viewmodel/ShareouselViewModelTest.kt | 2 + 14 files changed, 169 insertions(+), 43 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index d6116c9a..6f7e75d5 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -109,3 +109,10 @@ flag { description: "Allow Shareousel selection change callback to update Intent#EXTRA_EXCLUDE_COMPONENTS" bug: "352496527" } + +flag { + name: "unselect_final_item" + namespace: "intentresolver" + description: "Allow toggling of final Shareousel item" + bug: "349468879" +} diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index c026ee59..4f77d248 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -162,6 +162,9 @@ } + + Select items to share + {count, plural, diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 51d8785f..8871ce3f 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -25,6 +25,7 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; import static com.android.intentresolver.Flags.fixShortcutsFlashing; +import static com.android.intentresolver.Flags.unselectFinalItem; import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; @@ -351,6 +352,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); mChooserHelper.setOnPendingSelection(this::onPendingSelection); + if (unselectFinalItem()) { + mChooserHelper.setOnHasSelections(this::onHasSelections); + } } } private int mInitialProfile = -1; @@ -705,7 +709,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void onChooserRequestChanged(ChooserRequest chooserRequest) { - // intentional reference comparison if (mRequest == chooserRequest) { return; } @@ -724,6 +727,10 @@ public class ChooserActivity extends Hilt_ChooserActivity implements setTabsViewEnabled(false); } + private void onHasSelections(boolean hasSelections) { + mChooserMultiProfilePagerAdapter.setTargetsEnabled(hasSelections); + } + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { Log.d(TAG, "onAppTargetsLoaded(" + "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")"); diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index 312911a6..7d382bfc 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -27,9 +27,12 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.platform.GlobalSettings import com.android.intentresolver.ui.viewmodel.ChooserViewModel @@ -39,6 +42,8 @@ import com.android.intentresolver.validation.log import dagger.hilt.android.scopes.ActivityScoped import java.util.function.Consumer import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -46,6 +51,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch private const val TAG: String = "ChooserHelper" @@ -86,6 +92,7 @@ constructor( hostActivity: Activity, private val activityResultRepo: ActivityResultRepository, private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, + private val selectionsRepo: PreviewSelectionsRepository, private val globalSettings: GlobalSettings, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. @@ -98,6 +105,7 @@ constructor( var onChooserRequestChanged: Consumer = Consumer {} /** Invoked when there are a new change to payload selection */ var onPendingSelection: Runnable = Runnable {} + var onHasSelections: Consumer = Consumer {} init { activity.lifecycle.addObserver(this) @@ -144,22 +152,40 @@ constructor( } activity.lifecycleScope.launch { - val hasPendingCallbackFlow = + val hasPendingIntentFlow = pendingSelectionCallbackRepo.pendingTargetIntent .map { it != null } .distinctUntilChanged() - .onEach { hasPendingCallback -> - if (hasPendingCallback) { + .onEach { hasPendingIntent -> + if (hasPendingIntent) { onPendingSelection.run() } } activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.request - .combine(hasPendingCallbackFlow) { request, hasPendingCallback -> - request to hasPendingCallback + val hasSelectionFlow = + if ( + unselectFinalItem() && + viewModel.previewDataProvider.previewType == + CONTENT_PREVIEW_PAYLOAD_SELECTION + ) { + selectionsRepo.selections + .map { it.isNotEmpty() } + .distinctUntilChanged() + .stateIn(scope = this) + .also { flow -> launch { flow.collect { onHasSelections.accept(it) } } } + } else { + MutableStateFlow(true).asStateFlow() } + val requestControlFlow = + hasSelectionFlow + .combine(hasPendingIntentFlow) { hasSelections, hasPendingIntent -> + hasSelections && !hasPendingIntent + } + .distinctUntilChanged() + viewModel.request + .combine(requestControlFlow) { request, isReady -> request to isReady } // only take ChooserRequest if there are no pending callbacks - .filter { !it.second } + .filter { it.second } .map { it.first } .distinctUntilChanged(areEquivalent = { old, new -> old === new }) .collect { onChooserRequestChanged.accept(it) } diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 26e2feeb..016eb714 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -18,7 +18,6 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix; import android.app.ActivityManager; import android.app.prediction.AppTarget; @@ -439,10 +438,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - holder.bindIcon(info); - if (info.hasDisplayIcon() && !mTargetsEnabled) { - holder.icon.setColorFilter(getSuspendedColorMatrix()); - } + holder.bindIcon(info, mTargetsEnabled); if (mAnimateItems && info.hasDisplayIcon()) { mAnimationTracker.animateIcon(holder.icon, info); } diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 7ca1c724..fc5514b6 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,6 +16,7 @@ package com.android.intentresolver; +import static com.android.intentresolver.Flags.unselectFinalItem; import static com.android.intentresolver.util.graphics.SuspendedMatrixColorFilter.getSuspendedColorMatrix; import android.content.Context; @@ -973,13 +974,26 @@ public class ResolverListAdapter extends BaseAdapter { /** * Bind view holder to a TargetInfo. */ - public void bindIcon(TargetInfo info) { + public final void bindIcon(TargetInfo info) { + bindIcon(info, true); + } + + /** + * Bind view holder to a TargetInfo. + */ + public void bindIcon(TargetInfo info, boolean isEnabled) { Drawable displayIcon = info.getDisplayIconHolder().getDisplayIcon(); icon.setImageDrawable(displayIcon); - if (info.isSuspended()) { + if (info.isSuspended() || !isEnabled) { icon.setColorFilter(getSuspendedColorMatrix()); } else { icon.setColorFilter(null); + if (unselectFinalItem() && displayIcon != null) { + // For some reason, ImageView.setColorFilter() not always propagate the call + // to the drawable and the icon remains grayscale when rebound; reset the filter + // explicitly. + displayIcon.setColorFilter(null); + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index 21308341..059ee083 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -36,4 +36,6 @@ interface HeadlineGenerator { fun getVideosHeadline(count: Int): String fun getFilesHeadline(count: Int): String + + fun getNotItemsSelectedHeadline(): String } diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index e92d9bc6..822d3097 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -93,6 +93,9 @@ constructor( return getPluralString(R.string.sharing_files, count) } + override fun getNotItemsSelectedHeadline(): String = + context.getString(R.string.select_items_to_share) + private fun getPluralString(@StringRes templateResource: Int, count: Int): String { return PluralsMessageFormatter.format( context.resources, diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt index 81c56d1e..0688ce02 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -18,12 +18,12 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel -import dagger.hilt.android.scopes.ViewModelScoped +import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow /** Stores set of selected previews. */ -@ViewModelScoped +@ActivityRetainedScoped class PreviewSelectionsRepository @Inject constructor() { val selections = MutableStateFlow(emptyMap()) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt index 97d9fa66..2d02e4fd 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import android.net.Uri +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.contentpreview.MimeTypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier @@ -60,8 +61,12 @@ constructor( } fun unselect(model: PreviewModel) { - if (selectionsRepo.selections.value.size > 1) { - updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model.uri }.values) + if (selectionsRepo.selections.value.size > 1 || unselectFinalItem()) { + selectionsRepo.selections + .updateAndGet { it - model.uri } + .values + .takeIf { it.isNotEmpty() } + ?.let { updateChooserRequest(it) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index 93ac90db..f8cf243d 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault @@ -73,6 +74,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.Shar import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import kotlin.math.abs import kotlin.math.min +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @Composable @@ -286,30 +288,46 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) { val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) if (actions.isNotEmpty()) { Spacer(Modifier.height(16.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.height(32.dp), - ) { - itemsIndexed(actions) { idx, actionViewModel -> - if (idx == 0) { - Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) - } - ShareouselAction( - label = actionViewModel.label, - onClick = { actionViewModel.onClicked() }, - ) { - actionViewModel.icon?.let { - Image( - icon = it, - modifier = Modifier.size(16.dp), - colorFilter = ColorFilter.tint(LocalContentColor.current) + val visibilityFlow = + if (unselectFinalItem()) { + viewModel.hasSelectedItems + } else { + MutableStateFlow(true) + } + val visibility by visibilityFlow.collectAsStateWithLifecycle(true) + val height = 32.dp + if (visibility) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(height), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + if (idx == 0) { + Spacer( + Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)) + ) + } + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { + Image( + icon = it, + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } + } + if (idx == actions.size - 1) { + Spacer( + Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal)) ) } - } - if (idx == actions.size - 1) { - Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) } } + } else { + Spacer(modifier = Modifier.height(height)) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 6f8be1ff..9762794e 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.util.Size import com.android.intentresolver.Flags +import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader @@ -58,6 +59,8 @@ data class ShareouselViewModel( val previews: Flow, /** List of action chips presented underneath Shareousel. */ val actions: Flow>, + /** Indicates whether there are any selected items */ + val hasSelectedItems: Flow, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ val preview: ( @@ -104,10 +107,14 @@ object ShareouselViewModelModule { selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) { contentType, numItems -> - when (contentType) { - ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) - ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) - ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + if (unselectFinalItem() && numItems == 0) { + headlineGenerator.getNotItemsSelectedHeadline() + } else { + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } } }, metadataText = chooserRequestInteractor.metadataText, @@ -128,6 +135,7 @@ object ShareouselViewModelModule { } } }, + hasSelectedItems = selectionInteractor.selections.map { it.isNotEmpty() }, preview = { key, previewHeight, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt index 87db243d..c8242333 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractorTest.kt @@ -18,16 +18,24 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.content.Intent import android.net.Uri +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import com.android.intentresolver.Flags import com.android.intentresolver.contentpreview.mimetypeClassifier import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.util.runKosmosTest import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first +import org.junit.Rule import org.junit.Test class SelectionInteractorTest { + @get:Rule val flagsRule = SetFlagsRule() + @Test + @DisableFlags(Flags.FLAG_UNSELECT_FINAL_ITEM) fun singleSelection_removalPrevented() = runKosmosTest { val initialPreview = PreviewModel( @@ -53,6 +61,33 @@ class SelectionInteractorTest { assertThat(underTest.selections.first()).containsExactly(initialPreview.uri) } + @Test + @EnableFlags(Flags.FLAG_UNSELECT_FINAL_ITEM) + fun singleSelection_itemRemovedNoPendingIntentUpdates() = runKosmosTest { + val initialPreview = + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = null, + order = 0 + ) + previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview) + + val underTest = + SelectionInteractor( + previewSelectionsRepository, + { Intent() }, + updateTargetIntentInteractor, + mimetypeClassifier, + ) + + assertThat(underTest.selections.first()).containsExactly(initialPreview.uri) + + underTest.unselect(initialPreview) + + assertThat(underTest.selections.first()).isEmpty() + assertThat(previewSelectionsRepository.selections.value).isEmpty() + } + @Test fun multipleSelections_removalAllowed() = runKosmosTest { val first = diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt index 1047d145..fc7ac751 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -347,6 +347,8 @@ class ShareouselViewModelTest { override fun getVideosHeadline(count: Int): String = "VIDEOS: $count" override fun getFilesHeadline(count: Int): String = "FILES: $count" + + override fun getNotItemsSelectedHeadline() = "Select items to share" } // instantiate the view model, and then runCurrent() so that it is fully hydrated before // starting the test -- cgit v1.2.3-59-g8ed1b From 57bad9d1cf27d0272f18285f4b31ca0e31a6411b Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 19 Aug 2024 19:36:08 -0700 Subject: Use a viewmodel property instead of reaching to the repository A small adjustment to ag/28621884 to use the ShareouselViewModel.hasSelectedItems property instead of reaching to the selection repository. Fix: 349468879 Test: atest IntentResolver-tests-unit Test: manual functionality testing Flag: com.android.intentresolver.unselect_final_item Change-Id: Ifded6890acd116a6dacb76ca2adccafccc1fb5f3 --- java/src/com/android/intentresolver/ChooserHelper.kt | 11 ++++------- .../payloadtoggle/ui/viewmodel/ShareouselViewModel.kt | 4 +++- 2 files changed, 7 insertions(+), 8 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index 7d382bfc..c26dd77c 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -32,7 +32,6 @@ import com.android.intentresolver.annotation.JavaInterop import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository -import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.platform.GlobalSettings import com.android.intentresolver.ui.viewmodel.ChooserViewModel @@ -92,7 +91,6 @@ constructor( hostActivity: Activity, private val activityResultRepo: ActivityResultRepository, private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, - private val selectionsRepo: PreviewSelectionsRepository, private val globalSettings: GlobalSettings, ) : DefaultLifecycleObserver { // This is guaranteed by Hilt, since only a ComponentActivity is injectable. @@ -168,11 +166,10 @@ constructor( viewModel.previewDataProvider.previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION ) { - selectionsRepo.selections - .map { it.isNotEmpty() } - .distinctUntilChanged() - .stateIn(scope = this) - .also { flow -> launch { flow.collect { onHasSelections.accept(it) } } } + viewModel.shareouselViewModel.hasSelectedItems.stateIn(scope = this).also { + flow -> + launch { flow.collect { onHasSelections.accept(it) } } + } } else { MutableStateFlow(true).asStateFlow() } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 9762794e..ebcd58d1 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -41,6 +41,7 @@ import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -135,7 +136,8 @@ object ShareouselViewModelModule { } } }, - hasSelectedItems = selectionInteractor.selections.map { it.isNotEmpty() }, + hasSelectedItems = + selectionInteractor.selections.map { it.isNotEmpty() }.distinctUntilChanged(), preview = { key, previewHeight, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) -- cgit v1.2.3-59-g8ed1b From 96a5e64e1974814bb56885eb62b89c65cb347194 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Mon, 12 Aug 2024 15:12:52 -0400 Subject: Scroll partially offscreen items to be fully onscreen when toggled Test: manual test using ShareTest BUG: 351883537 FIX: 351883537 Flag: com.android.intentresolver.shareousel_scroll_offscreen_selections Change-Id: Id9c7e31169bce11b722868338f87228c9450c9aa --- aconfig/FeatureFlags.aconfig | 7 ++++ .../ui/composable/ShareouselComposable.kt | 40 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 6f7e75d5..8396bc24 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -116,3 +116,10 @@ flag { description: "Allow toggling of final Shareousel item" bug: "349468879" } + +flag { + name: "shareousel_scroll_offscreen_selections" + namespace: "intentresolver" + description: "Whether to scroll items onscreen when they are partially offscreen and selected/unselected." + bug: "351883537" +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index f8cf243d..4b87d227 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -64,6 +64,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate @@ -189,6 +190,45 @@ private fun PreviewCarousel( firstSelectedIndex = min(index, firstSelectedIndex ?: Int.MAX_VALUE) } + if (shareouselScrollOffscreenSelections()) { + LaunchedEffect(index, model.uri) { + var current: Boolean? = null + previewModel.isSelected.collect { selected -> + when { + // First update will always be the current state, so we just want to + // record the state and do nothing else. + current == null -> current = selected + + // We only want to act when the state changes + current != selected -> { + current = selected + with(carouselState.layoutInfo) { + visibleItemsInfo + .firstOrNull { it.index == index } + ?.let { item -> + when { + // Item is partially past start of viewport + item.offset < viewportStartOffset -> + -viewportStartOffset + // Item is partially past end of viewport + (item.offset + item.size) > viewportEndOffset -> + item.size - viewportEndOffset + // Item is fully within viewport + else -> null + }?.let { scrollOffset -> + carouselState.animateScrollToItem( + index = index, + scrollOffset = scrollOffset, + ) + } + } + } + } + } + } + } + } + ShareouselCard( viewModel.preview( model, -- cgit v1.2.3-59-g8ed1b From 7b7c943272725f89495ade08b299ec4e431ded36 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 22 Aug 2024 19:32:32 -0700 Subject: Fix ChooserRequest.sharedText value parsing readChooserRequest was reading sharedText value from the chooser intent's extra instead of the target intent's extra. Additional refactorings: * sharedTextTitle is added to the ChooserReqeust (read from target intent's EXTRA_TITLE extra); * ChooserContentPreviewUi is modified to use ChooserReqeust instead of extracting values form the target intent. Fix: 361654855 Test: atest IntentResolver-tests-unit Test: manual testing various previw variations with the ShareTest app Flag: EXEMPT bugfix Change-Id: Ic9e0e200f4b9a84df4830e99dd42cb1bc7258caf --- .../android/intentresolver/ChooserActivity.java | 2 +- .../contentpreview/ChooserContentPreviewUi.java | 28 +++++++++++----------- .../intentresolver/data/model/ChooserRequest.kt | 2 ++ .../ui/viewmodel/ChooserRequestReader.kt | 13 ++++++---- .../contentpreview/ChooserContentPreviewUiTest.kt | 24 ++++++++++++------- .../ui/viewmodel/ChooserRequestTest.kt | 22 +++++++++++++++++ 6 files changed, 63 insertions(+), 28 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 8871ce3f..3db821c1 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -648,7 +648,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserContentPreviewUi = new ChooserContentPreviewUi( getCoroutineScope(getLifecycle()), mViewModel.getPreviewDataProvider(), - mRequest.getTargetIntent(), + mRequest, mViewModel.getImageLoader(), actionFactory, createModifyShareActionFactory(), diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 4b955c49..1128ec5d 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -22,7 +22,6 @@ import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTE import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; -import android.content.Intent; import android.content.res.Resources; import android.net.Uri; import android.text.TextUtils; @@ -34,6 +33,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.intentresolver.ContentTypeHint; +import com.android.intentresolver.data.model.ChooserRequest; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; @@ -102,7 +102,7 @@ public final class ChooserContentPreviewUi { public ChooserContentPreviewUi( CoroutineScope scope, PreviewDataProvider previewData, - Intent targetIntent, + ChooserRequest chooserRequest, ImageLoader imageLoader, ActionFactory actionFactory, Supplier modifyShareActionFactory, @@ -117,7 +117,7 @@ public final class ChooserContentPreviewUi { mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, - targetIntent, + chooserRequest, DefaultMimeTypeClassifier.INSTANCE, imageLoader, actionFactory, @@ -133,7 +133,7 @@ public final class ChooserContentPreviewUi { private ContentPreviewUi createContentPreview( PreviewDataProvider previewData, - Intent targetIntent, + ChooserRequest chooserRequest, MimeTypeClassifier typeClassifier, ImageLoader imageLoader, ActionFactory actionFactory, @@ -146,7 +146,9 @@ public final class ChooserContentPreviewUi { if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( mScope, - targetIntent, + chooserRequest.getTargetIntent().getClipData(), + chooserRequest.getSharedText(), + chooserRequest.getSharedTextTitle(), actionFactory, imageLoader, headlineGenerator, @@ -174,15 +176,14 @@ public final class ChooserContentPreviewUi { boolean isSingleImageShare = previewData.getUriCount() == 1 && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); - CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (!TextUtils.isEmpty(text)) { + if (!TextUtils.isEmpty(chooserRequest.getSharedText())) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( mScope, isSingleImageShare, previewData.getUriCount(), - targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), - targetIntent.getType(), + chooserRequest.getSharedText(), + chooserRequest.getTargetType(), actionFactory, imageLoader, typeClassifier, @@ -201,7 +202,7 @@ public final class ChooserContentPreviewUi { return new UnifiedContentPreviewUi( mScope, isSingleImageShare, - targetIntent.getType(), + chooserRequest.getTargetType(), actionFactory, imageLoader, typeClassifier, @@ -243,16 +244,15 @@ public final class ChooserContentPreviewUi { private static TextContentPreviewUi createTextPreview( CoroutineScope scope, - Intent targetIntent, + ClipData previewData, + @Nullable CharSequence sharingText, + @Nullable CharSequence previewTitle, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, HeadlineGenerator headlineGenerator, ContentTypeHint contentTypeHint, @Nullable CharSequence metadata ) { - CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); - ClipData previewData = targetIntent.getClipData(); Uri previewThumbnail = null; if (previewData != null) { if (previewData.getItemCount() > 0) { diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt index 045a17f6..c4aa2b98 100644 --- a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -156,6 +156,8 @@ data class ChooserRequest( * TODO: Constrain length? */ val sharedText: CharSequence? = null, + /** Contains title to the text content to share supplied by the source app. */ + val sharedTextTitle: CharSequence? = null, /** * Supplied to diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index f0f72489..4a194db9 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -18,7 +18,10 @@ package com.android.intentresolver.ui.viewmodel import android.content.ComponentName import android.content.Intent import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI +import android.content.Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER @@ -114,7 +117,8 @@ fun readChooserRequest( val retainInOnStop = optional(value(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false - val sharedText = optional(value(EXTRA_TEXT)) + val sharedTextTitle = targetIntent.getCharSequenceExtra(EXTRA_TITLE) + val sharedText = targetIntent.getCharSequenceExtra(EXTRA_TEXT) val chooserActions = readChooserActions() ?: emptyList() @@ -123,15 +127,15 @@ fun readChooserRequest( val additionalContentUri: Uri? val focusedItemPos: Int if (isSendAction && flags.chooserPayloadToggling()) { - additionalContentUri = optional(value(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) - focusedItemPos = optional(value(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + additionalContentUri = optional(value(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 } else { additionalContentUri = null focusedItemPos = 0 } val contentTypeHint = - when (optional(value(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + when (optional(value(EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM else -> ContentTypeHint.NONE } @@ -161,6 +165,7 @@ fun readChooserRequest( chosenComponentSender = chosenComponentSender, refinementIntentSender = refinementIntentSender, sharedText = sharedText, + sharedTextTitle = sharedTextTitle, shareTargetFilter = targetIntent.toShareTargetFilter(), additionalContentUri = additionalContentUri, focusedItemPosition = focusedItemPos, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 27d98ece..a80cc02b 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -23,6 +23,7 @@ import android.platform.test.flag.junit.DeviceFlagsValueProvider import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.FakeImageLoader import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory +import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.widget.ActionRow import com.android.intentresolver.widget.ImagePreviewView import com.google.common.truth.Truth.assertThat @@ -61,13 +62,18 @@ class ChooserContentPreviewUiTest { @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() private fun createContentPreviewUi( - targetIntent: Intent, + action: String, + sharedText: CharSequence? = null, isPayloadTogglingEnabled: Boolean = false ) = ChooserContentPreviewUi( testScope, previewData, - targetIntent, + ChooserRequest( + targetIntent = Intent(action), + sharedText = sharedText, + launchedFromPackage = "org.pkg", + ), imageLoader, actionFactory, { null }, @@ -81,7 +87,7 @@ class ChooserContentPreviewUiTest { @Test fun test_textPreviewType_useTextPreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) - val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_VIEW)) + val testSubject = createContentPreviewUi(action = Intent.ACTION_VIEW) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) @@ -92,7 +98,7 @@ class ChooserContentPreviewUiTest { @Test fun test_filePreviewType_useFilePreviewUi() { whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) - val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) + val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java) @@ -109,8 +115,8 @@ class ChooserContentPreviewUiTest { whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = createContentPreviewUi( - targetIntent = - Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") } + action = Intent.ACTION_SEND, + sharedText = "Shared text", ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) @@ -126,7 +132,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) - val testSubject = createContentPreviewUi(targetIntent = Intent(Intent.ACTION_SEND)) + val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND) assertThat(testSubject.preferredContentPreview) .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) @@ -146,8 +152,8 @@ class ChooserContentPreviewUiTest { whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = createContentPreviewUi( - targetIntent = Intent(Intent.ACTION_SEND), - isPayloadTogglingEnabled = true + action = Intent.ACTION_SEND, + isPayloadTogglingEnabled = true, ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(ShareouselContentPreviewUi::class.java) diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt index 2d5a44ed..01904c7f 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -25,6 +25,8 @@ import android.content.Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI import android.content.Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION import android.content.Intent.EXTRA_INTENT import android.content.Intent.EXTRA_REFERRER +import android.content.Intent.EXTRA_TEXT +import android.content.Intent.EXTRA_TITLE import android.net.Uri import android.service.chooser.Flags import androidx.core.net.toUri @@ -271,4 +273,24 @@ class ChooserRequestTest { assertThat(result.value.metadataText).isEqualTo(metadataText) } + + @Test + fun textSharedTextAndTitle() { + val text: CharSequence = "Shared text" + val title: CharSequence = "Title" + val targetIntent = + Intent().apply { + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_TEXT, text) + } + val model = createActivityModel(targetIntent) + + val result = readChooserRequest(model, fakeChooserServiceFlags) + + assertThat(result).isInstanceOf(Valid::class.java) + (result as Valid).value.let { request -> + assertThat(request.sharedText).isEqualTo(text) + assertThat(request.sharedTextTitle).isEqualTo(title) + } + } } -- cgit v1.2.3-59-g8ed1b From e277a3f5330ad3bcd8ed6ffb6cba73b39d30c093 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 19 Aug 2024 16:06:35 -0700 Subject: Report Shareousel preview type Bug: 329068612 Test: atest IntentResolver-tests-unit Flag: EXEMPT ui events logging Change-Id: I0a9cd4d50f4a883c162be153c9e0a0a8f10816c2 --- .../contentpreview/ShareouselContentPreviewUi.kt | 2 +- .../intentresolver/logging/EventLogImpl.java | 3 ++ .../contentpreview/ChooserContentPreviewUiTest.kt | 2 ++ .../intentresolver/logging/EventLogImplTest.java | 39 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 57a51239..ff52556a 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi : ContentPreviewUi() { - override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE + override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION override fun display( resources: Resources, diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 39d23865..8d04dd28 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -402,6 +402,9 @@ public class EventLogImpl implements EventLog { case ContentPreviewType.CONTENT_PREVIEW_FILE: return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; case ContentPreviewType.CONTENT_PREVIEW_TEXT: + case ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION: + return FrameworkStatsLog + .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA; default: return FrameworkStatsLog .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index a80cc02b..905c8517 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -157,5 +157,7 @@ class ChooserContentPreviewUiTest { ) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(ShareouselContentPreviewUi::class.java) + assertThat(testSubject.preferredContentPreview) + .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION) } } diff --git a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java index feb277ea..528c4613 100644 --- a/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java +++ b/tests/unit/src/com/android/intentresolver/logging/EventLogImplTest.java @@ -151,6 +151,45 @@ public final class EventLogImplTest { /* reselection action provided */ eq(modifyShareProvided)); } + @Test + public void shareStartedWithShareouselAndEnabledReportingFlag_imagePreviewTypeReported() { + final String packageName = "com.test.foo"; + final String mimeType = "text/plain"; + final int appProvidedDirectTargets = 123; + final int appProvidedAppTargets = 456; + final boolean workProfile = true; + final int previewType = ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; + final String intentAction = Intent.ACTION_SENDTO; + final int numCustomActions = 3; + final boolean modifyShareProvided = true; + + mChooserLogger.logShareStarted( + packageName, + mimeType, + appProvidedDirectTargets, + appProvidedAppTargets, + workProfile, + previewType, + intentAction, + numCustomActions, + modifyShareProvided); + + verify(mFrameworkLog).write( + eq(FrameworkStatsLog.SHARESHEET_STARTED), + eq(SharesheetStartedEvent.SHARE_STARTED.getId()), + eq(packageName), + /* instanceId=*/ gt(0), + eq(mimeType), + eq(appProvidedDirectTargets), + eq(appProvidedAppTargets), + eq(workProfile), + eq(FrameworkStatsLog + .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TOGGLEABLE_MEDIA), + eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), + /* custom actions provided */ eq(numCustomActions), + /* reselection action provided */ eq(modifyShareProvided)); + } + @Test public void testLogShareTargetSelected() { final int targetType = EventLogImpl.SELECTION_TYPE_SERVICE; -- cgit v1.2.3-59-g8ed1b From f760ae087ee9f3dae6b7533d5d4c2525103fd1cf Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 19 Aug 2024 21:23:31 -0700 Subject: Log payload selection changes Bug: 329068612 Test: manual testing with an injected debug logging Test: atest IntentResolver-tests-unit Flag: EXEMPT ui events logging Change-Id: Iac9e2bcf93375d5761be5287b6cf9297be399e77 --- .../domain/interactor/SelectablePreviewInteractor.kt | 3 +++ .../domain/interactor/SelectablePreviewsInteractor.kt | 4 +++- java/src/com/android/intentresolver/logging/EventLog.kt | 15 +++++++++++++++ .../com/android/intentresolver/logging/EventLogImpl.java | 5 +++++ .../domain/interactor/PayloadToggleInteractorKosmos.kt | 1 + .../com/android/intentresolver/logging/FakeEventLog.kt | 9 +++++++++ .../domain/interactor/SelectablePreviewInteractorTest.kt | 6 ++++++ 7 files changed, 42 insertions(+), 1 deletion(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt index d52a71a1..8f18ebe0 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.logging.EventLog import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.map class SelectablePreviewInteractor( private val key: PreviewModel, private val selectionInteractor: SelectionInteractor, + private val eventLog: EventLog, ) { val uri: Uri = key.uri @@ -33,6 +35,7 @@ class SelectablePreviewInteractor( /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { + eventLog.logPayloadSelectionChanged() if (isSelected) { selectionInteractor.select(key) } else { diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt index a578d0e2..d0ac8d4a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -19,6 +19,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.logging.EventLog import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -27,6 +28,7 @@ class SelectablePreviewsInteractor constructor( private val previewsRepo: CursorPreviewsRepository, private val selectionInteractor: SelectionInteractor, + private val eventLog: EventLog, ) { /** Keys of previews available for display in Shareousel. */ val previews: Flow @@ -36,5 +38,5 @@ constructor( * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual * preview associated with [key]. */ - fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor) + fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor, eventLog) } diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt index 476bd4bf..b92f0732 100644 --- a/java/src/com/android/intentresolver/logging/EventLog.kt +++ b/java/src/com/android/intentresolver/logging/EventLog.kt @@ -47,6 +47,7 @@ interface EventLog { ) fun logCustomActionSelected(positionPicked: Int) + fun logShareTargetSelected( targetType: Int, packageName: String?, @@ -60,15 +61,29 @@ interface EventLog { ) fun logDirectShareTargetReceived(category: Int, latency: Int) + fun logActionShareWithPreview(previewType: Int) + fun logActionSelected(targetType: Int) + fun logContentPreviewWarning(uri: Uri?) + fun logSharesheetTriggered() + fun logSharesheetAppLoadComplete() + fun logSharesheetDirectLoadComplete() + fun logSharesheetDirectLoadTimeout() + fun logSharesheetProfileChanged() + fun logSharesheetExpansionChanged(isCollapsed: Boolean) + fun logSharesheetAppShareRankingTimeout() + fun logSharesheetEmptyDirectShareRow() + + /** Log payload selection */ + fun logPayloadSelectionChanged() } diff --git a/java/src/com/android/intentresolver/logging/EventLogImpl.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 8d04dd28..8e9543bc 100644 --- a/java/src/com/android/intentresolver/logging/EventLogImpl.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -273,6 +273,11 @@ public class EventLogImpl implements EventLog { log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId); } + @Override + public void logPayloadSelectionChanged() { + log(SharesheetStandardEvent.SHARESHEET_PAYLOAD_TOGGLED, mInstanceId); + } + /** * Logs a UiEventReported event for a given share activity * @param event diff --git a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt index cb88cd9e..7cca414f 100644 --- a/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt +++ b/tests/shared/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/PayloadToggleInteractorKosmos.kt @@ -91,6 +91,7 @@ val Kosmos.selectablePreviewsInteractor SelectablePreviewsInteractor( previewsRepo = cursorPreviewsRepository, selectionInteractor = selectionInteractor, + eventLog = eventLog, ) val Kosmos.selectionInteractor diff --git a/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt index 9ed47db6..c2d13f1e 100644 --- a/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt +++ b/tests/shared/src/com/android/intentresolver/logging/FakeEventLog.kt @@ -164,14 +164,22 @@ class FakeEventLog @Inject constructor(private val instanceId: InstanceId) : Eve log { "logSharesheetEmptyDirectShareRow()" } } + override fun logPayloadSelectionChanged() { + log { "logPayloadSelectionChanged" } + } + data class ActionSelected(val targetType: Int) + data class CustomActionSelected(val positionPicked: Int) + data class ActionShareWithPreview(val previewType: Int) + data class ChooserActivityShown( val isWorkProfile: Boolean, val targetMimeType: String?, val systemCost: Long ) + data class ShareStarted( val packageName: String?, val mimeType: String?, @@ -183,6 +191,7 @@ class FakeEventLog @Inject constructor(private val instanceId: InstanceId) : Eve val customActionCount: Int, val modifyShareActionProvided: Boolean ) + data class ShareTargetSelected( val targetType: Int, val packageName: String?, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt index f329b8a7..5d9ddbb6 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractorTest.kt @@ -26,13 +26,16 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.Tar import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.data.repository.chooserRequestRepository +import com.android.intentresolver.logging.FakeEventLog import com.android.intentresolver.util.runKosmosTest +import com.android.internal.logging.InstanceId import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import org.junit.Test class SelectablePreviewInteractorTest { + private val eventLog = FakeEventLog(InstanceId.fakeInstanceId(0)) @Test fun reflectPreviewRepo_initState() = runKosmosTest { @@ -46,6 +49,7 @@ class SelectablePreviewInteractorTest { order = 0, ), selectionInteractor = selectionInteractor, + eventLog = eventLog, ) runCurrent() @@ -64,6 +68,7 @@ class SelectablePreviewInteractorTest { order = 0, ), selectionInteractor = selectionInteractor, + eventLog = eventLog, ) assertThat(underTest.isSelected.first()).isFalse() @@ -93,6 +98,7 @@ class SelectablePreviewInteractorTest { order = 0, ), selectionInteractor = selectionInteractor, + eventLog = eventLog, ) underTest.setSelected(true) -- cgit v1.2.3-59-g8ed1b From 6952e28dff44a02244f104aae45f9d0e6fa9166f Mon Sep 17 00:00:00 2001 From: Sunny Goyal Date: Wed, 18 Sep 2024 19:55:15 -0700 Subject: Replace atomicfu with AtomicReference atomicfu internally replaces it with AtomicReference during compile This avoids an additional preprocessing step during compile, and also makes the gradle setup easier Bug: 368177559 Test: Presubmit Flag: EXEMPT refactor Change-Id: I6840e0687b78b38df7ac5d187bf147e0c5a33e24 --- .../intentresolver/shortcuts/ShortcutLoader.kt | 38 +++++++++--------- .../contentpreview/PreviewImageLoaderTest.kt | 45 +++++++++++----------- 2 files changed, 41 insertions(+), 42 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 1fedee30..828d8561 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -41,8 +41,8 @@ import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer import com.android.intentresolver.measurements.runTracing import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicReference import java.util.function.Consumer -import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -78,20 +78,20 @@ constructor( private val isPersonalProfile: Boolean, private val targetIntentFilter: IntentFilter?, private val dispatcher: CoroutineDispatcher, - private val callback: Consumer + private val callback: Consumer, ) { private val scope = if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - private val appPredictorWatchdog = atomic(null) + private val appPredictorWatchdog = AtomicReference(null) private val appPredictorCallback = ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() private val appTargetSource = MutableSharedFlow?>( replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST, ) private val shortcutSource = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -108,7 +108,7 @@ constructor( appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, - callback: Consumer + callback: Consumer, ) : this( context, scope, @@ -117,7 +117,7 @@ constructor( userHandle == UserHandle.of(ActivityManager.getCurrentUser()), targetIntentFilter, Dispatchers.IO, - callback + callback, ) init { @@ -134,7 +134,7 @@ constructor( appTargets, shortcutData.shortcuts, shortcutData.isFromAppPredictor, - shortcutData.appPredictorTargets + shortcutData.appPredictorTargets, ) } } @@ -230,7 +230,7 @@ constructor( sendShareShortcutInfoList( emptyList(), isFromAppPredictor = false, - appPredictorTargets = null + appPredictorTargets = null, ) return } @@ -256,7 +256,7 @@ constructor( @WorkerThread private fun onAppPredictorCallback(appPredictorTargets: List) { - appPredictorWatchdog.value?.cancel() + appPredictorWatchdog.get()?.cancel() endAppPredictorQueryTrace(userHandle) Log.d(TAG, "[$id] receive app targets from AppPredictor") if (appPredictorTargets.isEmpty() && shouldQueryDirectShareTargets()) { @@ -288,7 +288,7 @@ constructor( private fun sendShareShortcutInfoList( shortcuts: List, isFromAppPredictor: Boolean, - appPredictorTargets: List? + appPredictorTargets: List?, ) { shortcutSource.tryEmit(ShortcutData(shortcuts, isFromAppPredictor, appPredictorTargets)) } @@ -297,7 +297,7 @@ constructor( appTargets: Array, shortcuts: List, isFromAppPredictor: Boolean, - appPredictorTargets: List? + appPredictorTargets: List?, ): Result { if (appPredictorTargets != null && appPredictorTargets.size != shortcuts.size) { throw RuntimeException( @@ -324,7 +324,7 @@ constructor( shortcuts, appPredictorTargets, directShareAppTargetCache, - directShareShortcutInfoCache + directShareShortcutInfoCache, ) val resultRecord = ShortcutResultInfo(displayResolveInfo, chooserTargets) resultRecords.add(resultRecord) @@ -334,7 +334,7 @@ constructor( appTargets, resultRecords.toTypedArray(), directShareAppTargetCache, - directShareShortcutInfoCache + directShareShortcutInfoCache, ) } @@ -354,7 +354,7 @@ constructor( private class ShortcutData( val shortcuts: List, val isFromAppPredictor: Boolean, - val appPredictorTargets: List? + val appPredictorTargets: List?, ) /** Resolved shortcuts with corresponding app targets. */ @@ -368,7 +368,7 @@ constructor( /** Shortcuts grouped by app target. */ val shortcutsByApp: Array, val directShareAppTargetCache: Map, - val directShareShortcutInfoCache: Map + val directShareShortcutInfoCache: Map, ) private fun endAppPredictorQueryTrace(userHandle: UserHandle) { @@ -379,12 +379,12 @@ constructor( /** Shortcuts grouped by app. */ class ShortcutResultInfo( val appTarget: DisplayResolveInfo, - val shortcuts: List + val shortcuts: List, ) private class ShortcutsAppTargetsPair( val shortcuts: List, - val appTargets: List? + val appTargets: List?, ) /** A wrapper around AppPredictor to facilitate unit-testing. */ @@ -393,7 +393,7 @@ constructor( /** [AppPredictor.registerPredictionUpdates] */ open fun registerPredictionUpdates( callbackExecutor: Executor, - callback: AppPredictor.Callback + callback: AppPredictor.Callback, ) = mAppPredictor.registerPredictionUpdates(callbackExecutor, callback) /** [AppPredictor.unregisterPredictionUpdates] */ @@ -418,7 +418,7 @@ constructor( packageName, PackageManager.ApplicationInfoFlags.of( PackageManager.GET_META_DATA.toLong() - ) + ), ) appInfo.enabled && (appInfo.flags and ApplicationInfo.FLAG_SUSPENDED) == 0 } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt index 8293264c..8c810058 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewImageLoaderTest.kt @@ -21,7 +21,6 @@ import android.net.Uri import android.util.Size import com.google.common.truth.Truth.assertThat import java.util.concurrent.atomic.AtomicInteger -import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -55,7 +54,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val b1 = testSubject.invoke(uri, Size(200, 100)) @@ -78,7 +77,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) testSubject.invoke(uri, Size(200, 100), caching = false) @@ -105,7 +104,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val b1Deferred = async { testSubject.invoke(uri, Size(200, 100), caching = false) } @@ -148,7 +147,7 @@ class PreviewImageLoaderTest { cacheSize = 1, defaultPreviewSize = 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val jobOne = launch { testSubject.invoke(uriOne, Size(200, 100)) } @@ -189,7 +188,7 @@ class PreviewImageLoaderTest { cacheSize = 1, defaultPreviewSize = 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val job = launch { testSubject.invoke(uri, Size(200, 100), caching = false) } @@ -214,7 +213,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val b1 = testSubject.invoke(uri, Size(100, 100)) @@ -240,7 +239,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val bitmap = testSubject.invoke(uri, Size(100, 100)) @@ -251,7 +250,7 @@ class PreviewImageLoaderTest { fun test_requestHigherResImage_cancelsLowerResLoading() = scope.runTest { val uri = createUri(0) - val cancelledRequestCount = atomic(0) + val cancelledRequestCount = AtomicInteger(0) val imageLoadingStarted = CompletableDeferred() val bitmapDeferred = CompletableDeferred() val thumbnailLoader = @@ -272,7 +271,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val lowResSize = 100 @@ -283,7 +282,7 @@ class PreviewImageLoaderTest { imageLoadingStarted.await() val result = async { testSubject.invoke(uri, Size(highResSize, highResSize)) } runCurrent() - assertThat(cancelledRequestCount.value).isEqualTo(1) + assertThat(cancelledRequestCount.get()).isEqualTo(1) bitmapDeferred.complete(createBitmap(highResSize, highResSize)) val bitmap = result.await() @@ -308,7 +307,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val b1 = testSubject.invoke(uri, Size(highResSize, highResSize)) @@ -333,7 +332,7 @@ class PreviewImageLoaderTest { cacheSize = 1, defaultPreviewSize, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) val b1 = testSubject(uri, Size(0, 0)) @@ -349,7 +348,7 @@ class PreviewImageLoaderTest { scope.runTest { val previewSize = Size(100, 100) val uris = List(2) { createUri(it) } - val loadingCount = atomic(0) + val loadingCount = AtomicInteger(0) val thumbnailLoader = FakeThumbnailLoader().apply { for (uri in uris) { @@ -365,19 +364,19 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) testSubject.prePopulate(uris.map { it to previewSize }) runCurrent() - assertThat(loadingCount.value).isEqualTo(1) + assertThat(loadingCount.get()).isEqualTo(1) assertThat(thumbnailLoader.invokeCalls).containsExactly(uris[0]) testSubject(uris[0], previewSize) runCurrent() - assertThat(loadingCount.value).isEqualTo(1) + assertThat(loadingCount.get()).isEqualTo(1) } @Test @@ -402,7 +401,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) testSubject(uriOne, previewSize) @@ -419,7 +418,7 @@ class PreviewImageLoaderTest { scope.runTest { val previewSize = Size(100, 100) val uri = createUri(1) - val loadingCount = atomic(0) + val loadingCount = AtomicInteger(0) val thumbnailLoader = FakeThumbnailLoader().apply { fakeInvoke[uri] = { @@ -433,13 +432,13 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) testSubject(uri, previewSize) testSubject(uri, previewSize) - assertThat(loadingCount.value).isEqualTo(2) + assertThat(loadingCount.get()).isEqualTo(2) } @Test(expected = CancellationException::class) @@ -454,7 +453,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) imageLoaderScope.cancel() testSubject(uri, Size(200, 200)) @@ -480,7 +479,7 @@ class PreviewImageLoaderTest { 1, 100, thumbnailLoader, - StandardTestDispatcher(scope.testScheduler) + StandardTestDispatcher(scope.testScheduler), ) launch { -- cgit v1.2.3-59-g8ed1b