From 62e549546887623a3ef9f4da174af7f0ebcdd835 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Thu, 22 Aug 2024 14:38:38 +0000 Subject: Remove FLAG_ENABLE_CHOOSER_RESULT (IntentResolver) Launched in V. Bug: 263474465 Test: atest ShareResultSenderImplTest Flag: EXEMPT flag removal Change-Id: I5cd53e1608b415e3b9db80ce48899c42c755660d --- java/src/com/android/intentresolver/ui/ShareResultSender.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt index dce477ec..2684b817 100644 --- a/java/src/com/android/intentresolver/ui/ShareResultSender.kt +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -30,7 +30,6 @@ import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN import android.service.chooser.ChooserResult.ResultType import android.util.Log import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.inject.Main import com.android.intentresolver.ui.model.ShareAction import dagger.assisted.Assisted @@ -64,7 +63,6 @@ fun interface IntentSenderDispatcher { } class ShareResultSenderImpl( - private val flags: ChooserServiceFlags, @Main private val scope: CoroutineScope, @Background val backgroundDispatcher: CoroutineDispatcher, private val callerUid: Int, @@ -74,13 +72,11 @@ class ShareResultSenderImpl( @AssistedInject constructor( @ActivityContext context: Context, - flags: ChooserServiceFlags, @Main scope: CoroutineScope, @Background backgroundDispatcher: CoroutineDispatcher, @Assisted callerUid: Int, @Assisted chosenComponentSender: IntentSender, ) : this( - flags, scope, backgroundDispatcher, callerUid, @@ -103,7 +99,7 @@ class ShareResultSenderImpl( override fun onActionSelected(action: ShareAction) { Log.i(TAG, "onActionSelected: $action") scope.launch { - if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + if (chooserResultSupported(callerUid)) { @ResultType val chosenAction = shareActionToChooserResult(action) val intent: Intent = createSelectedActionIntent(chosenAction) intentDispatcher.dispatchIntent(resultSender, intent) @@ -118,7 +114,7 @@ class ShareResultSenderImpl( direct: Boolean, crossProfile: Boolean, ): Intent? { - if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + if (chooserResultSupported(callerUid)) { if (crossProfile) { Log.i(TAG, "Redacting package from cross-profile ${Intent.EXTRA_CHOOSER_RESULT}") return Intent() -- cgit v1.2.3-59-g8ed1b From a2cecbe15fd16bb6567f29e7944888633e5b4d6b Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 23 Aug 2024 17:06:51 -0700 Subject: Do not store the initial intent's extra in the saved state. CreationExtras's DEFAULT_ARGS_KEY vlaue gets saved in SavedStateHandle for each view model the activity creates. Thus by storing the ActivityModel in there we effectively duplicated the initial intent's extra in the activity's saved state 4 times: DEFAULT_ARGS_KEY contains the extras (put there by ComponentActivity) plus ActivityModel per two view models we created. This change makes Chooser and Resolver activities provide default CreationExtras with empty DEFAULT_ARGS_KEY values and stores ActivityModel in the new repository class (instead of the SavedStateHandle instance). Fix: 331897641 Test: manual testing with injected logging the values being put in the activity's saved state Test: atest IntentResolver-tests-unit Test: atest Intentresolver-tests-activity Flag: EXEMPT bugfix Change-Id: Ice2e51971476b2bb963f04275d7b180c85126288 --- .../android/intentresolver/ChooserActivity.java | 20 +++--- .../android/intentresolver/ResolverActivity.java | 13 ++-- .../data/repository/ActivityModelRepository.kt | 37 ++++++++++ .../intentresolver/ext/CreationExtrasExt.kt | 6 ++ .../intentresolver/inject/ActivityModelModule.kt | 20 ++---- .../intentresolver/shared/model/ActivityModel.kt | 80 +++++++++++++++++++++ .../intentresolver/ui/model/ActivityModel.kt | 81 ---------------------- .../ui/viewmodel/ChooserRequestReader.kt | 6 +- .../ui/viewmodel/ChooserViewModel.kt | 12 ++-- .../ui/viewmodel/ResolverRequestReader.kt | 2 +- .../ui/viewmodel/ResolverViewModel.kt | 13 ++-- .../intentresolver/ext/CreationExtrasExtTest.kt | 15 ++++ .../intentresolver/ui/model/ActivityModelTest.kt | 7 +- .../ui/viewmodel/ChooserRequestTest.kt | 8 +-- .../ui/viewmodel/ResolverRequestTest.kt | 9 +-- 15 files changed, 185 insertions(+), 144 deletions(-) create mode 100644 java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt create mode 100644 java/src/com/android/intentresolver/shared/model/ActivityModel.kt delete mode 100644 java/src/com/android/intentresolver/ui/model/ActivityModel.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3db821c1..f7d81ca4 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -23,13 +23,12 @@ 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.Flags.fixShortcutsFlashing; +import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; import static com.android.intentresolver.Flags.unselectFinalItem; -import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; -import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; import static java.util.Objects.requireNonNull; @@ -102,6 +101,7 @@ import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; import com.android.intentresolver.contentpreview.HeadlineGeneratorImpl; import com.android.intentresolver.data.model.ChooserRequest; +import com.android.intentresolver.data.repository.ActivityModelRepository; import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; @@ -127,6 +127,7 @@ import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; import com.android.intentresolver.profiles.OnProfileSelectedListener; import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.ActivityModel; import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; @@ -134,7 +135,6 @@ import com.android.intentresolver.ui.ActionTitle; import com.android.intentresolver.ui.ProfilePagerResources; import com.android.intentresolver.ui.ShareResultSender; import com.android.intentresolver.ui.ShareResultSenderFactory; -import com.android.intentresolver.ui.model.ActivityModel; import com.android.intentresolver.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; @@ -149,8 +149,6 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import kotlin.Pair; - import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; @@ -273,6 +271,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject public ClipboardManager mClipboardManager; @Inject public IntentForwarding mIntentForwarding; @Inject public ShareResultSenderFactory mShareResultSenderFactory; + @Inject public ActivityModelRepository mActivityModelRepository; private ActivityModel mActivityModel; private ChooserRequest mRequest; @@ -331,15 +330,18 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); + // DEFAULT_ARGS_KEY extra is saved for each ViewModel we create. ComponentActivity puts the + // initial intent's extra into DEFAULT_ARGS_KEY thus we store these values 2 times (3 if we + // count the initial intent). We don't need those values to be saved as they don't capture + // the state. + return replaceDefaultArgs(super.getDefaultViewModelCreationExtras()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); + mActivityModelRepository.initialize(this::createActivityModel); mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() ? mCachingTargetDataLoaderProvider.get() diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index a402fc72..2f220cf1 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -21,7 +21,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE import static androidx.lifecycle.LifecycleKt.getCoroutineScope; -import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; import static java.util.Objects.requireNonNull; @@ -85,6 +85,7 @@ import androidx.viewpager.widget.ViewPager; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.data.repository.ActivityModelRepository; import com.android.intentresolver.data.repository.DevicePolicyResources; import com.android.intentresolver.domain.interactor.UserInteractor; import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; @@ -103,10 +104,10 @@ import com.android.intentresolver.profiles.OnProfileSelectedListener; import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.ActivityModel; import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.ui.ActionTitle; import com.android.intentresolver.ui.ProfilePagerResources; -import com.android.intentresolver.ui.model.ActivityModel; import com.android.intentresolver.ui.model.ResolverRequest; import com.android.intentresolver.ui.viewmodel.ResolverViewModel; import com.android.intentresolver.widget.ResolverDrawerLayout; @@ -119,8 +120,6 @@ import com.google.common.collect.ImmutableList; import dagger.hilt.android.AndroidEntryPoint; -import kotlin.Pair; - import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; @@ -150,6 +149,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public ProfilePagerResources mProfilePagerResources; @Inject public IntentForwarding mIntentForwarding; @Inject public FeatureFlags mFeatureFlags; + @Inject public ActivityModelRepository mActivityModelRepository; private ResolverViewModel mViewModel; private ResolverRequest mRequest; @@ -220,15 +220,14 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @NonNull @Override public CreationExtras getDefaultViewModelCreationExtras() { - return addDefaultArgs( - super.getDefaultViewModelCreationExtras(), - new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); + return replaceDefaultArgs(super.getDefaultViewModelCreationExtras()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.i(TAG, "onCreate"); + mActivityModelRepository.initialize(this::createActivityModel); setTheme(R.style.Theme_DeviceDefault_Resolver); mResolverHelper.setInitializer(this::initialize); } diff --git a/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt new file mode 100644 index 00000000..7c3188d2 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/ActivityModelRepository.kt @@ -0,0 +1,37 @@ +/* + * 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.data.repository + +import com.android.intentresolver.shared.model.ActivityModel +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.atomicfu.atomic + +/** An [ActivityModel] repository that captures the first value. */ +@ActivityRetainedScoped +class ActivityModelRepository @Inject constructor() { + private val _value = atomic(null) + + val value: ActivityModel + get() = requireNotNull(_value.value) { "Repository has not been initialized" } + + fun initialize(block: () -> ActivityModel) { + if (_value.value == null) { + _value.compareAndSet(null, block()) + } + } +} diff --git a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt index 2ba08c90..5635ec28 100644 --- a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt +++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt @@ -32,3 +32,9 @@ fun CreationExtras.addDefaultArgs(vararg values: Pair): Crea defaultArgs.putAll(bundleOf(*values)) return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } } + +fun CreationExtras.replaceDefaultArgs(vararg values: Pair): CreationExtras { + val mutableExtras = if (this is MutableCreationExtras) this else MutableCreationExtras(this) + mutableExtras[DEFAULT_ARGS_KEY] = bundleOf(*values) + return mutableExtras +} diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index bbd25eb7..7201bd2b 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -19,9 +19,8 @@ package com.android.intentresolver.inject import android.content.Intent import android.net.Uri import android.service.chooser.ChooserAction -import androidx.lifecycle.SavedStateHandle import com.android.intentresolver.data.model.ChooserRequest -import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.ui.viewmodel.readChooserRequest import com.android.intentresolver.util.ownedByCurrentUser import com.android.intentresolver.validation.Valid @@ -36,27 +35,20 @@ import javax.inject.Qualifier @Module @InstallIn(ViewModelComponent::class) object ActivityModelModule { - @Provides - fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel = - requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" - } - @Provides @ChooserIntent - fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + fun chooserIntent(activityModelRepo: ActivityModelRepository): Intent = + activityModelRepo.value.intent @Provides @ViewModelScoped fun provideInitialRequest( - activityModel: ActivityModel, + activityModelRepo: ActivityModelRepository, flags: ChooserServiceFlags, - ): ValidationResult = readChooserRequest(activityModel, flags) + ): ValidationResult = readChooserRequest(activityModelRepo.value, flags) @Provides - fun provideChooserRequest( - initialRequest: ValidationResult, - ): ChooserRequest = + fun provideChooserRequest(initialRequest: ValidationResult): ChooserRequest = requireNotNull((initialRequest as? Valid)?.value) { "initialRequest is Invalid, no chooser request available" } diff --git a/java/src/com/android/intentresolver/shared/model/ActivityModel.kt b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt new file mode 100644 index 00000000..c5efdeba --- /dev/null +++ b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt @@ -0,0 +1,80 @@ +/* + * 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.shared.model + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.data.model.ANDROID_APP_SCHEME +import com.android.intentresolver.ext.readParcelable +import com.android.intentresolver.ext.requireParcelable +import java.util.Objects + +/** Contains Activity-scope information about the state when started. */ +data class ActivityModel( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val launchedFromUid: Int, + /** The package of the sending app */ + val launchedFromPackage: String, + /** The referrer as supplied to the activity. */ + val referrer: Uri?, +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + launchedFromUid = source.readInt(), + launchedFromPackage = requireNotNull(source.readString()), + referrer = source.readParcelable(), + ) + + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(launchedFromUid) + dest.writeString(launchedFromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun newArray(size: Int) = arrayOfNulls(size) + + override fun createFromParcel(source: Parcel) = ActivityModel(source) + } + + @JvmStatic + fun createFrom(activity: Activity): ActivityModel { + return ActivityModel( + activity.intent, + activity.launchedFromUid, + Objects.requireNonNull(activity.launchedFromPackage), + activity.referrer, + ) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt deleted file mode 100644 index 4bcdd69b..00000000 --- a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 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.ui.model - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Parcel -import android.os.Parcelable -import com.android.intentresolver.data.model.ANDROID_APP_SCHEME -import com.android.intentresolver.ext.readParcelable -import com.android.intentresolver.ext.requireParcelable -import java.util.Objects - -/** Contains Activity-scope information about the state when started. */ -data class ActivityModel( - /** The [Intent] received by the app */ - val intent: Intent, - /** The identifier for the sending app and user */ - val launchedFromUid: Int, - /** The package of the sending app */ - val launchedFromPackage: String, - /** The referrer as supplied to the activity. */ - val referrer: Uri? -) : Parcelable { - constructor( - source: Parcel - ) : this( - intent = source.requireParcelable(), - launchedFromUid = source.readInt(), - launchedFromPackage = requireNotNull(source.readString()), - referrer = source.readParcelable() - ) - - /** A package name from referrer, if it is an android-app URI */ - val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority - - override fun describeContents() = 0 /* flags */ - - override fun writeToParcel(dest: Parcel, flags: Int) { - dest.writeParcelable(intent, flags) - dest.writeInt(launchedFromUid) - dest.writeString(launchedFromPackage) - dest.writeParcelable(referrer, flags) - } - - companion object { - const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" - - @JvmField - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun newArray(size: Int) = arrayOfNulls(size) - override fun createFromParcel(source: Parcel) = ActivityModel(source) - } - - @JvmStatic - fun createFrom(activity: Activity): ActivityModel { - return ActivityModel( - activity.intent, - activity.launchedFromUid, - Objects.requireNonNull(activity.launchedFromPackage), - activity.referrer - ) - } - } -} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index 4a194db9..13cadf37 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -49,7 +49,7 @@ import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.ext.hasSendAction import com.android.intentresolver.ext.ifMatch import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.validation.Validation import com.android.intentresolver.validation.ValidationResult @@ -69,7 +69,7 @@ internal fun Intent.maybeAddSendActionFlags() = fun readChooserRequest( model: ActivityModel, - flags: ChooserServiceFlags + flags: ChooserServiceFlags, ): ValidationResult { val extras = model.intent.extras ?: Bundle() @Suppress("DEPRECATION") @@ -87,7 +87,7 @@ fun readChooserRequest( ignored( value(EXTRA_TITLE), "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + - "property of the wrapped EXTRA_INTENT." + "property of the wrapped EXTRA_INTENT.", ) null to R.string.chooseActivity } else { diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index 619e118a..e6f12750 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -17,7 +17,6 @@ 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 @@ -26,11 +25,11 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.data.repository.ChooserRequestRepository import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags -import com.android.intentresolver.ui.model.ActivityModel -import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid import com.android.intentresolver.validation.ValidationResult @@ -49,7 +48,7 @@ private const val TAG = "ChooserViewModel" class ChooserViewModel @Inject constructor( - args: SavedStateHandle, + activityModelRepository: ActivityModelRepository, private val shareouselViewModelProvider: Lazy, private val processUpdatesInteractor: Lazy, private val fetchPreviewsInteractor: Lazy, @@ -67,10 +66,7 @@ constructor( ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } + val activityModel: ActivityModel = activityModelRepository.value val shareouselViewModel: ShareouselViewModel by lazy { // TODO: consolidate this logic, this would require a consolidated preview view model but diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt index 856d9fdd..884be635 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt @@ -20,8 +20,8 @@ import android.os.Bundle import android.os.UserHandle import com.android.intentresolver.ResolverActivity.PROFILE_PERSONAL import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.shared.model.Profile -import com.android.intentresolver.ui.model.ActivityModel import com.android.intentresolver.ui.model.ResolverRequest import com.android.intentresolver.validation.Validation import com.android.intentresolver.validation.ValidationResult diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt index a3dc58a6..3511637b 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt @@ -17,10 +17,9 @@ package com.android.intentresolver.ui.viewmodel import android.util.Log -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.android.intentresolver.ui.model.ActivityModel -import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.data.repository.ActivityModelRepository +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.ui.model.ResolverRequest import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -33,13 +32,11 @@ import kotlinx.coroutines.flow.asStateFlow private const val TAG = "ResolverViewModel" @HiltViewModel -class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { +class ResolverViewModel @Inject constructor(activityModelrepo: ActivityModelRepository) : + ViewModel() { /** Parcelable-only references provided from the creating Activity */ - val activityModel: ActivityModel = - requireNotNull(args[ACTIVITY_MODEL_KEY]) { - "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" - } + val activityModel: ActivityModel = activityModelrepo.value /** * Provided only for the express purpose of early exit in the event of an invalid request. diff --git a/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt index c09047a1..dbaee3d0 100644 --- a/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt +++ b/tests/unit/src/com/android/intentresolver/ext/CreationExtrasExtTest.kt @@ -51,4 +51,19 @@ class CreationExtrasExtTest { assertThat(defaultArgs).parcelable("POINT1").marshallsEquallyTo(Point(1, 1)) assertThat(defaultArgs).parcelable("POINT2").marshallsEquallyTo(Point(2, 2)) } + + @Test + fun replaceDefaultArgs_replacesExisting() { + val creationExtras: CreationExtras = + MutableCreationExtras().apply { + set(DEFAULT_ARGS_KEY, bundleOf("POINT1" to Point(1, 1))) + } + + val updated = creationExtras.replaceDefaultArgs("POINT2" to Point(2, 2)) + + val defaultArgs = updated[DEFAULT_ARGS_KEY] + assertThat(defaultArgs).doesNotContainKey("POINT1") + assertThat(defaultArgs).containsKey("POINT2") + assertThat(defaultArgs).parcelable("POINT2").marshallsEquallyTo(Point(2, 2)) + } } diff --git a/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt index 737f02fe..5f86159c 100644 --- a/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt @@ -21,6 +21,7 @@ import android.content.Intent.ACTION_CHOOSER import android.content.Intent.EXTRA_TEXT import android.net.Uri import com.android.intentresolver.ext.toParcelAndBack +import com.android.intentresolver.shared.model.ActivityModel import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import org.junit.Test @@ -54,7 +55,7 @@ class ActivityModelTest { intent = Intent(), launchedFromUid = 1000, launchedFromPackage = "other.example.com", - referrer = Uri.parse("android-app://app.example.com") + referrer = Uri.parse("android-app://app.example.com"), ) assertThat(launch1.referrerPackage).isEqualTo("app.example.com") @@ -67,7 +68,7 @@ class ActivityModelTest { intent = Intent(), launchedFromUid = 1000, launchedFromPackage = "example.com", - referrer = Uri.parse("http://some.other.value") + referrer = Uri.parse("http://some.other.value"), ) assertThat(launch.referrerPackage).isNull() @@ -80,7 +81,7 @@ class ActivityModelTest { intent = Intent(), launchedFromUid = 1000, launchedFromPackage = "example.com", - referrer = null + referrer = null, ) assertThat(launch.referrerPackage).isNull() 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 01904c7f..7bd4edee 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -34,7 +34,7 @@ import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.inject.FakeChooserServiceFlags -import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Importance import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.NoValue @@ -45,7 +45,7 @@ import org.junit.Test private fun createActivityModel( targetIntent: Intent?, referrer: Uri? = null, - additionalIntents: List? = null + additionalIntents: List? = null, ) = ActivityModel( Intent(ACTION_CHOOSER).apply { @@ -54,7 +54,7 @@ private fun createActivityModel( }, launchedFromUid = 10000, launchedFromPackage = "com.android.example", - referrer = referrer ?: "android-app://com.android.example".toUri() + referrer = referrer ?: "android-app://com.android.example".toUri(), ) class ChooserRequestTest { @@ -245,7 +245,7 @@ class ChooserRequestTest { val model = createActivityModel(Intent(ACTION_SEND)) model.intent.putExtra( Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT, - Intent.CHOOSER_CONTENT_TYPE_ALBUM + Intent.CHOOSER_CONTENT_TYPE_ALBUM, ) val result = readChooserRequest(model, fakeChooserServiceFlags) diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt index bd80235d..70512021 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt @@ -22,8 +22,8 @@ import android.os.UserHandle import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ResolverActivity.PROFILE_WORK +import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.shared.model.Profile.Type.WORK -import com.android.intentresolver.ui.model.ActivityModel import com.android.intentresolver.ui.model.ResolverRequest import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.UncaughtException @@ -34,15 +34,12 @@ import org.junit.Test private val targetUri = Uri.parse("content://example.com/123") -private fun createActivityModel( - targetIntent: Intent, - referrer: Uri? = null, -) = +private fun createActivityModel(targetIntent: Intent, referrer: Uri? = null) = ActivityModel( intent = targetIntent, launchedFromUid = 10000, launchedFromPackage = "com.android.example", - referrer = referrer ?: "android-app://com.android.example".toUri() + referrer = referrer ?: "android-app://com.android.example".toUri(), ) class ResolverRequestTest { -- cgit v1.2.3-59-g8ed1b From eb35273e23e3b75c4417af3f3bd689498d605fb1 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 20 Sep 2024 13:27:57 -0700 Subject: Update Chooser drawer max width value on config change. Fix: 368654066 Test: manual testing Flag: EXEMPT bug fix Change-Id: Ia4828dcff85fc8c73d8892a764883270fcacb808 --- java/src/com/android/intentresolver/ChooserActivity.java | 15 ++++++++++++++- .../android/intentresolver/grid/ChooserGridAdapter.java | 7 ------- .../intentresolver/widget/ResolverDrawerLayout.java | 12 +++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 3db821c1..41804421 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1566,6 +1566,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); + adjustMaxPreviewWidth(); adjustPreviewWidth(newConfig.orientation, null); updateStickyContentPreview(); updateTabPadding(); @@ -1578,6 +1579,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); } + private void adjustMaxPreviewWidth() { + if (mResolverDrawerLayout == null) { + return; + } + mResolverDrawerLayout.setMaxWidth( + getResources().getDimensionPixelSize(R.dimen.chooser_width)); + } + private void adjustPreviewWidth(int orientation, View parent) { int width = -1; if (mShouldDisplayLandscape) { @@ -2283,8 +2292,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); + final int maxChooserWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); boolean isLayoutUpdated = - gridAdapter.calculateChooserTargetWidth(availableWidth) + gridAdapter.calculateChooserTargetWidth( + maxChooserWidth >= 0 + ? Math.min(maxChooserWidth, availableWidth) + : availableWidth) || recyclerView.getAdapter() == null || availableWidth != mCurrAvailableWidth; diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 1dd83566..9a50d7e4 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -88,7 +88,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter= 0) { - width = Math.min(mChooserWidthPixels, width); - } - int newWidth = width / mMaxTargetsPerRow; if (newWidth != mChooserTargetWidth) { mChooserTargetWidth = newWidth; diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index 2c8140d9..07693b25 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -61,7 +61,7 @@ public class ResolverDrawerLayout extends ViewGroup { /** * Max width of the whole drawer layout */ - private final int mMaxWidth; + private int mMaxWidth; /** * Max total visible height of views not marked always-show when in the closed/initial state @@ -264,6 +264,16 @@ public class ResolverDrawerLayout extends ViewGroup { invalidate(); } + /** + * Sets max drawer width. + */ + public void setMaxWidth(int maxWidth) { + if (mMaxWidth != maxWidth) { + mMaxWidth = maxWidth; + requestLayout(); + } + } + public void setDismissLocked(boolean locked) { mDismissLocked = locked; } -- cgit v1.2.3-59-g8ed1b From 1ad19a6f6b7dab69c7287795bbbb18dcfa602780 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 4 Mar 2024 20:28:04 -0800 Subject: Fix keyboard naviagation for the nested preview scrolling NestedScrollView is copied into the project from the prebuilt sources and trivially refactored (the differerence is saved in NestedScrollView.java.patch file). ChooserNestedScrollView extends the copied NestedScrollView and disabled scolling to the child that requests focus. Bug: 325259478 Test: attach keboard and test that keyboard navigation is working Flag: com.android.intentresolver.keyboard_navigation_fix Change-Id: I759f3a21aad9197148df31c55ed1534021072bd9 --- aconfig/FeatureFlags.aconfig | 10 + .../res/layout/chooser_grid_scrollable_preview.xml | 1 + java/res/layout/chooser_list_per_profile_wrap.xml | 9 +- .../android/intentresolver/ChooserActivity.java | 14 + .../profiles/ChooserMultiProfilePagerAdapter.java | 5 + .../widget/ChooserNestedScrollView.kt | 32 +- .../intentresolver/widget/NestedScrollView.java | 2611 ++++++++++++++++++++ .../widget/NestedScrollView.java.patch | 103 + 8 files changed, 2770 insertions(+), 15 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/NestedScrollView.java create mode 100644 java/src/com/android/intentresolver/widget/NestedScrollView.java.patch (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 8396bc24..c8ad2126 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -96,6 +96,16 @@ flag { } } +flag { + name: "keyboard_navigation_fix" + namespace: "intentresolver" + description: "Enable Chooser keyboard navigation bugfix" + bug: "325259478" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "preview_image_loader" namespace: "intentresolver" diff --git a/java/res/layout/chooser_grid_scrollable_preview.xml b/java/res/layout/chooser_grid_scrollable_preview.xml index c1bcf912..02584d27 100644 --- a/java/res/layout/chooser_grid_scrollable_preview.xml +++ b/java/res/layout/chooser_grid_scrollable_preview.xml @@ -78,6 +78,7 @@ diff --git a/java/res/layout/chooser_list_per_profile_wrap.xml b/java/res/layout/chooser_list_per_profile_wrap.xml index fc0431d7..d48fcb50 100644 --- a/java/res/layout/chooser_list_per_profile_wrap.xml +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -18,14 +18,7 @@ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:descendantFocusability="blocksDescendants"> - + android:layout_height="wrap_content"> + // TabHost view will request focus on the newly activated tab. The RecyclerView + // from the tab gets focused and notifies its parents (including + // NestedScrollView) about it through #requestChildFocus method call. + // NestedScrollView's view implementation of the method will scroll to the + // focused view. As we don't want to change drawer's position upon tab change, + // ignore focus requests from tab RecyclerViews. + focused == null || focused.getId() != com.android.internal.R.id.resolver_list); + } boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; diff --git a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java index 9176cd35..677b6366 100644 --- a/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -16,6 +16,8 @@ package com.android.intentresolver.profiles; +import static com.android.intentresolver.Flags.keyboardNavigationFix; + import android.content.Context; import android.os.UserHandle; import android.view.LayoutInflater; @@ -125,6 +127,9 @@ public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< LayoutInflater inflater = LayoutInflater.from(context); ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false); + if (!keyboardNavigationFix()) { + rootView.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt index e86de888..a9577cf5 100644 --- a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -25,7 +25,7 @@ import androidx.core.view.marginBottom import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop -import androidx.core.widget.NestedScrollView +import com.android.intentresolver.Flags.keyboardNavigationFix /** * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to @@ -35,13 +35,17 @@ import androidx.core.widget.NestedScrollView */ class ChooserNestedScrollView : NestedScrollView { constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( context: Context, attrs: AttributeSet?, - defStyleAttr: Int + defStyleAttr: Int, ) : super(context, attrs, defStyleAttr) + var requestChildFocusPredicate: (View?, View?) -> Boolean = DefaultChildFocusPredicate + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val content = getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") @@ -55,13 +59,13 @@ class ChooserNestedScrollView : NestedScrollView { getChildMeasureSpec( widthMeasureSpec, paddingLeft + content.marginLeft + content.marginRight + paddingRight, - lp.width + lp.width, ) val contentHeightSpec = getChildMeasureSpec( heightMeasureSpec, paddingTop + content.marginTop + content.marginBottom + paddingBottom, - lp.height + lp.height, ) content.measure(contentWidthSpec, contentHeightSpec) @@ -76,7 +80,7 @@ class ChooserNestedScrollView : NestedScrollView { content.measure( contentWidthSpec, - MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)), ) } setMeasuredDimension( @@ -87,8 +91,8 @@ class ChooserNestedScrollView : NestedScrollView { content.marginTop + content.measuredHeight + content.marginBottom + - paddingBottom - ) + paddingBottom, + ), ) } @@ -103,4 +107,18 @@ class ChooserNestedScrollView : NestedScrollView { consumed[1] += scrollY - preScrollY } } + + override fun onRequestChildFocus(child: View?, focused: View?) { + if (keyboardNavigationFix()) { + if (requestChildFocusPredicate(child, focused)) { + super.onRequestChildFocus(child, focused) + } + } else { + super.onRequestChildFocus(child, focused) + } + } + + companion object { + val DefaultChildFocusPredicate: (View?, View?) -> Boolean = { _, _ -> true } + } } diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java b/java/src/com/android/intentresolver/widget/NestedScrollView.java new file mode 100644 index 00000000..36fc7da6 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java @@ -0,0 +1,2611 @@ +/* + * 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.widget; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.hardware.SensorManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.FocusFinder; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.animation.AnimationUtils; +import android.widget.EdgeEffect; +import android.widget.FrameLayout; +import android.widget.OverScroller; +import android.widget.ScrollView; + +import androidx.annotation.DoNotInline; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.VisibleForTesting; +import androidx.core.R; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.DifferentialMotionFlingController; +import androidx.core.view.DifferentialMotionFlingTarget; +import androidx.core.view.MotionEventCompat; +import androidx.core.view.NestedScrollingChild3; +import androidx.core.view.NestedScrollingChildHelper; +import androidx.core.view.NestedScrollingParent3; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ScrollingView; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import androidx.core.widget.EdgeEffectCompat; + +import java.util.List; + +/** + * A copy of the {@link androidx.core.widget.NestedScrollView} (from + * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar) + * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}: + * the method's body is extracted into the new protected method, + * {@link #onRequestChildFocus(View, View)}. + *

+ * For the exact change see NestedScrollView.java.patch file. + *

+ */ +public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, + NestedScrollingChild3, ScrollingView { + static final int ANIMATED_SCROLL_GAP = 250; + + static final float MAX_SCROLL_FACTOR = 0.5f; + + private static final String TAG = "NestedScrollView"; + private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 250; + + /** + * The following are copied from OverScroller to determine how far a fling will go. + */ + private static final float SCROLL_FRICTION = 0.015f; + private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1) + private static final float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9)); + private final float mPhysicalCoeff; + + /** + * When flinging the stretch towards scrolling content, it should destretch quicker than the + * fling would normally do. The visual effect of flinging the stretch looks strange as little + * appears to happen at first and then when the stretch disappears, the content starts + * scrolling quickly. + */ + private static final float FLING_DESTRETCH_FACTOR = 4f; + + /** + * Interface definition for a callback to be invoked when the scroll + * X or Y positions of a view change. + * + *

This version of the interface works on all versions of Android, back to API v4.

+ * + * @see #setOnScrollChangeListener(OnScrollChangeListener) + */ + public interface OnScrollChangeListener { + /** + * Called when the scroll position of a view changes. + * @param v The view whose scroll position has changed. + * @param scrollX Current horizontal scroll origin. + * @param scrollY Current vertical scroll origin. + * @param oldScrollX Previous horizontal scroll origin. + * @param oldScrollY Previous vertical scroll origin. + */ + void onScrollChange(@NonNull NestedScrollView v, int scrollX, int scrollY, + int oldScrollX, int oldScrollY); + } + + private long mLastScroll; + + private final Rect mTempRect = new Rect(); + private OverScroller mScroller; + + @RestrictTo(LIBRARY) + @VisibleForTesting + @NonNull + public EdgeEffect mEdgeGlowTop; + + @RestrictTo(LIBRARY) + @VisibleForTesting + @NonNull + public EdgeEffect mEdgeGlowBottom; + + /** + * Position of the last motion event; only used with touch related events (usually to assist + * in movement changes in a drag gesture). + */ + private int mLastMotionY; + + /** + * True when the layout has changed but the traversal has not come through yet. + * Ideally the view hierarchy would keep track of this for us. + */ + private boolean mIsLayoutDirty = true; + private boolean mIsLaidOut = false; + + /** + * The child to give focus to in the event that a child has requested focus while the + * layout is dirty. This prevents the scroll from being wrong if the child has not been + * laid out before requesting focus. + */ + private View mChildToScrollTo = null; + + /** + * True if the user is currently dragging this ScrollView around. This is + * not the same as 'is being flinged', which can be checked by + * mScroller.isFinished() (flinging begins when the user lifts their finger). + */ + private boolean mIsBeingDragged = false; + + /** + * Determines speed during touch scrolling + */ + private VelocityTracker mVelocityTracker; + + /** + * When set to true, the scroll view measure its child to make it fill the currently + * visible area. + */ + private boolean mFillViewport; + + /** + * Whether arrow scrolling is animated. + */ + private boolean mSmoothScrollingEnabled = true; + + private int mTouchSlop; + private int mMinimumVelocity; + private int mMaximumVelocity; + + /** + * ID of the active pointer. This is used to retain consistency during + * drags/flings if multiple pointers are used. + */ + private int mActivePointerId = INVALID_POINTER; + + /** + * Used during scrolling to retrieve the new offset within the window. Saves memory by saving + * x, y changes to this array (0 position = x, 1 position = y) vs. reallocating an x and y + * every time. + */ + private final int[] mScrollOffset = new int[2]; + + /* + * Used during scrolling to retrieve the new consumed offset within the window. + * Uses same memory saving strategy as mScrollOffset. + */ + private final int[] mScrollConsumed = new int[2]; + + // Used to track the position of the touch only events relative to the container. + private int mNestedYOffset; + + private int mLastScrollerY; + + /** + * Sentinel value for no current active pointer. + * Used by {@link #mActivePointerId}. + */ + private static final int INVALID_POINTER = -1; + + private SavedState mSavedState; + + private static final AccessibilityDelegate ACCESSIBILITY_DELEGATE = new AccessibilityDelegate(); + + private static final int[] SCROLLVIEW_STYLEABLE = new int[] { + android.R.attr.fillViewport + }; + + private final NestedScrollingParentHelper mParentHelper; + private final NestedScrollingChildHelper mChildHelper; + + private float mVerticalScrollFactor; + + private OnScrollChangeListener mOnScrollChangeListener; + + @VisibleForTesting + final DifferentialMotionFlingTargetImpl mDifferentialMotionFlingTarget = + new DifferentialMotionFlingTargetImpl(); + + @VisibleForTesting + DifferentialMotionFlingController mDifferentialMotionFlingController = + new DifferentialMotionFlingController(getContext(), mDifferentialMotionFlingTarget); + + public NestedScrollView(@NonNull Context context) { + this(context, null); + } + + public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.nestedScrollViewStyle); + } + + public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + mEdgeGlowTop = EdgeEffectCompat.create(context, attrs); + mEdgeGlowBottom = EdgeEffectCompat.create(context, attrs); + + final float ppi = context.getResources().getDisplayMetrics().density * 160.0f; + mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2) + * 39.37f // inch/meter + * ppi + * 0.84f; // look and feel tuning + + initScrollView(); + + final TypedArray a = context.obtainStyledAttributes( + attrs, SCROLLVIEW_STYLEABLE, defStyleAttr, 0); + + setFillViewport(a.getBoolean(0, false)); + + a.recycle(); + + mParentHelper = new NestedScrollingParentHelper(this); + mChildHelper = new NestedScrollingChildHelper(this); + + // ...because why else would you be using this widget? + setNestedScrollingEnabled(true); + + ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE); + } + + // NestedScrollingChild3 + + @Override + public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow, int type, @NonNull int[] consumed) { + mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow, type, consumed); + } + + // NestedScrollingChild2 + + @Override + public boolean startNestedScroll(int axes, int type) { + return mChildHelper.startNestedScroll(axes, type); + } + + @Override + public void stopNestedScroll(int type) { + mChildHelper.stopNestedScroll(type); + } + + @Override + public boolean hasNestedScrollingParent(int type) { + return mChildHelper.hasNestedScrollingParent(type); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow, int type) { + return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow, type); + } + + @Override + public boolean dispatchNestedPreScroll( + int dx, + int dy, + @Nullable int[] consumed, + @Nullable int[] offsetInWindow, + int type + ) { + return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type); + } + + // NestedScrollingChild + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean isNestedScrollingEnabled() { + return mChildHelper.isNestedScrollingEnabled(); + } + + @Override + public boolean startNestedScroll(int axes) { + return startNestedScroll(axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void stopNestedScroll() { + stopNestedScroll(ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean hasNestedScrollingParent() { + return hasNestedScrollingParent(ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, @Nullable int[] offsetInWindow) { + return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + offsetInWindow); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, + @Nullable int[] offsetInWindow) { + return dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } + + // NestedScrollingParent3 + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) { + onNestedScrollInternal(dyUnconsumed, type, consumed); + } + + private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) { + final int oldScrollY = getScrollY(); + scrollBy(0, dyUnconsumed); + final int myConsumed = getScrollY() - oldScrollY; + + if (consumed != null) { + consumed[1] += myConsumed; + } + final int myUnconsumed = dyUnconsumed - myConsumed; + + mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed); + } + + // NestedScrollingParent2 + + @Override + public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, + int type) { + return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, + int type) { + mParentHelper.onNestedScrollAccepted(child, target, axes, type); + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type); + } + + @Override + public void onStopNestedScroll(@NonNull View target, int type) { + mParentHelper.onStopNestedScroll(target, type); + stopNestedScroll(type); + } + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed, int type) { + onNestedScrollInternal(dyUnconsumed, type, null); + } + + @Override + public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, + int type) { + dispatchNestedPreScroll(dx, dy, consumed, null, type); + } + + // NestedScrollingParent + + @Override + public boolean onStartNestedScroll( + @NonNull View child, @NonNull View target, int axes) { + return onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onNestedScrollAccepted( + @NonNull View child, @NonNull View target, int axes) { + onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onStopNestedScroll(@NonNull View target) { + onStopNestedScroll(target, ViewCompat.TYPE_TOUCH); + } + + @Override + public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + onNestedScrollInternal(dyUnconsumed, ViewCompat.TYPE_TOUCH, null); + } + + @Override + public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) { + onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH); + } + + @Override + public boolean onNestedFling( + @NonNull View target, float velocityX, float velocityY, boolean consumed) { + if (!consumed) { + dispatchNestedFling(0, velocityY, true); + fling((int) velocityY); + return true; + } + return false; + } + + @Override + public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) { + return dispatchNestedPreFling(velocityX, velocityY); + } + + @Override + public int getNestedScrollAxes() { + return mParentHelper.getNestedScrollAxes(); + } + + // ScrollView import + + @Override + public boolean shouldDelayChildPressedState() { + return true; + } + + @Override + protected float getTopFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + final int length = getVerticalFadingEdgeLength(); + final int scrollY = getScrollY(); + if (scrollY < length) { + return scrollY / (float) length; + } + + return 1.0f; + } + + @Override + protected float getBottomFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + final int length = getVerticalFadingEdgeLength(); + final int bottomEdge = getHeight() - getPaddingBottom(); + final int span = child.getBottom() + lp.bottomMargin - getScrollY() - bottomEdge; + if (span < length) { + return span / (float) length; + } + + return 1.0f; + } + + /** + * @return The maximum amount this scroll view will scroll in response to + * an arrow event. + */ + public int getMaxScrollAmount() { + return (int) (MAX_SCROLL_FACTOR * getHeight()); + } + + private void initScrollView() { + mScroller = new OverScroller(getContext()); + setFocusable(true); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + setWillNotDraw(false); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mTouchSlop = configuration.getScaledTouchSlop(); + mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); + mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); + } + + @Override + public void addView(@NonNull View child) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child); + } + + @Override + public void addView(View child, int index) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index); + } + + @Override + public void addView(View child, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, params); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index, params); + } + + /** + * Register a callback to be invoked when the scroll X or Y positions of + * this view change. + *

This version of the method works on all versions of Android, back to API v4.

+ * + * @param l The listener to notify when the scroll X or Y position changes. + * @see View#getScrollX() + * @see View#getScrollY() + */ + public void setOnScrollChangeListener(@Nullable OnScrollChangeListener l) { + mOnScrollChangeListener = l; + } + + /** + * @return Returns true this ScrollView can be scrolled + */ + private boolean canScroll() { + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + return childSize > parentSpace; + } + return false; + } + + /** + * Indicates whether this ScrollView's content is stretched to fill the viewport. + * + * @return True if the content fills the viewport, false otherwise. + * + * @attr name android:fillViewport + */ + public boolean isFillViewport() { + return mFillViewport; + } + + /** + * Set whether this ScrollView should stretch its content height to fill the viewport or not. + * + * @param fillViewport True to stretch the content's height to the viewport's + * boundaries, false otherwise. + * + * @attr name android:fillViewport + */ + public void setFillViewport(boolean fillViewport) { + if (fillViewport != mFillViewport) { + mFillViewport = fillViewport; + requestLayout(); + } + } + + /** + * @return Whether arrow scrolling will animate its transition. + */ + public boolean isSmoothScrollingEnabled() { + return mSmoothScrollingEnabled; + } + + /** + * Set whether arrow scrolling will animate its transition. + * @param smoothScrollingEnabled whether arrow scrolling will animate its transition + */ + public void setSmoothScrollingEnabled(boolean smoothScrollingEnabled) { + mSmoothScrollingEnabled = smoothScrollingEnabled; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + + if (mOnScrollChangeListener != null) { + mOnScrollChangeListener.onScrollChange(this, l, t, oldl, oldt); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!mFillViewport) { + return; + } + + final int heightMode = MeasureSpec.getMode(heightMeasureSpec); + if (heightMode == MeasureSpec.UNSPECIFIED) { + return; + } + + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + int childSize = child.getMeasuredHeight(); + int parentSpace = getMeasuredHeight() + - getPaddingTop() + - getPaddingBottom() + - lp.topMargin + - lp.bottomMargin; + + if (childSize < parentSpace) { + int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, + lp.width); + int childHeightMeasureSpec = + MeasureSpec.makeMeasureSpec(parentSpace, MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Let the focused view and/or our descendants get the key first + return super.dispatchKeyEvent(event) || executeKeyEvent(event); + } + + /** + * You can call this function yourself to have the scroll view perform + * scrolling from a key event, just as if the event had been dispatched to + * it by the view hierarchy. + * + * @param event The key event to execute. + * @return Return true if the event was handled, else false. + */ + public boolean executeKeyEvent(@NonNull KeyEvent event) { + mTempRect.setEmpty(); + + if (!canScroll()) { + if (isFocused() && event.getKeyCode() != KeyEvent.KEYCODE_BACK) { + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + View nextFocused = FocusFinder.getInstance().findNextFocus(this, + currentFocused, View.FOCUS_DOWN); + return nextFocused != null + && nextFocused != this + && nextFocused.requestFocus(View.FOCUS_DOWN); + } + return false; + } + + boolean handled = false; + if (event.getAction() == KeyEvent.ACTION_DOWN) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_UP: + if (event.isAltPressed()) { + handled = fullScroll(View.FOCUS_UP); + } else { + handled = arrowScroll(View.FOCUS_UP); + } + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + if (event.isAltPressed()) { + handled = fullScroll(View.FOCUS_DOWN); + } else { + handled = arrowScroll(View.FOCUS_DOWN); + } + break; + case KeyEvent.KEYCODE_PAGE_UP: + handled = fullScroll(View.FOCUS_UP); + break; + case KeyEvent.KEYCODE_PAGE_DOWN: + handled = fullScroll(View.FOCUS_DOWN); + break; + case KeyEvent.KEYCODE_SPACE: + pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN); + break; + case KeyEvent.KEYCODE_MOVE_HOME: + pageScroll(View.FOCUS_UP); + break; + case KeyEvent.KEYCODE_MOVE_END: + pageScroll(View.FOCUS_DOWN); + break; + } + } + + return handled; + } + + private boolean inChild(int x, int y) { + if (getChildCount() > 0) { + final int scrollY = getScrollY(); + final View child = getChildAt(0); + return !(y < child.getTop() - scrollY + || y >= child.getBottom() - scrollY + || x < child.getLeft() + || x >= child.getRight()); + } + return false; + } + + private void initOrResetVelocityTracker() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } else { + mVelocityTracker.clear(); + } + } + + private void initVelocityTrackerIfNotExists() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + } + + private void recycleVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (disallowIntercept) { + recycleVelocityTracker(); + } + super.requestDisallowInterceptTouchEvent(disallowIntercept); + } + + @Override + public boolean onInterceptTouchEvent(@NonNull MotionEvent ev) { + /* + * This method JUST determines whether we want to intercept the motion. + * If we return true, onMotionEvent will be called and we do the actual + * scrolling there. + */ + + /* + * Shortcut the most recurring case: the user is in the dragging + * state and they are moving their finger. We want to intercept this + * motion. + */ + final int action = ev.getAction(); + if ((action == MotionEvent.ACTION_MOVE) && mIsBeingDragged) { + return true; + } + + switch (action & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_MOVE: { + /* + * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check + * whether the user has moved far enough from their original down touch. + */ + + /* + * Locally do absolute value. mLastMotionY is set to the y value + * of the down event. + */ + final int activePointerId = mActivePointerId; + if (activePointerId == INVALID_POINTER) { + // If we don't have a valid id, the touch down wasn't on content. + break; + } + + final int pointerIndex = ev.findPointerIndex(activePointerId); + if (pointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + activePointerId + + " in onInterceptTouchEvent"); + break; + } + + final int y = (int) ev.getY(pointerIndex); + final int yDiff = Math.abs(y - mLastMotionY); + if (yDiff > mTouchSlop + && (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) { + mIsBeingDragged = true; + mLastMotionY = y; + initVelocityTrackerIfNotExists(); + mVelocityTracker.addMovement(ev); + mNestedYOffset = 0; + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + break; + } + + case MotionEvent.ACTION_DOWN: { + final int y = (int) ev.getY(); + if (!inChild((int) ev.getX(), y)) { + mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished(); + recycleVelocityTracker(); + break; + } + + /* + * Remember location of down touch. + * ACTION_DOWN always refers to pointer index 0. + */ + mLastMotionY = y; + mActivePointerId = ev.getPointerId(0); + + initOrResetVelocityTracker(); + mVelocityTracker.addMovement(ev); + /* + * If being flinged and user touches the screen, initiate drag; + * otherwise don't. mScroller.isFinished should be false when + * being flinged. We also want to catch the edge glow and start dragging + * if one is being animated. We need to call computeScrollOffset() first so that + * isFinished() is correct. + */ + mScroller.computeScrollOffset(); + mIsBeingDragged = stopGlowAnimations(ev) || !mScroller.isFinished(); + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH); + break; + } + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + /* Release the drag */ + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + recycleVelocityTracker(); + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) { + postInvalidateOnAnimation(); + } + stopNestedScroll(ViewCompat.TYPE_TOUCH); + break; + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + } + + /* + * The only time we want to intercept motion events is if we are in the + * drag mode. + */ + return mIsBeingDragged; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent motionEvent) { + initVelocityTrackerIfNotExists(); + + final int actionMasked = motionEvent.getActionMasked(); + + if (actionMasked == MotionEvent.ACTION_DOWN) { + mNestedYOffset = 0; + } + + MotionEvent velocityTrackerMotionEvent = MotionEvent.obtain(motionEvent); + velocityTrackerMotionEvent.offsetLocation(0, mNestedYOffset); + + switch (actionMasked) { + case MotionEvent.ACTION_DOWN: { + if (getChildCount() == 0) { + return false; + } + + // If additional fingers touch the screen while a drag is in progress, this block + // of code will make sure the drag isn't interrupted. + if (mIsBeingDragged) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + + /* + * If being flinged and user touches, stop the fling. isFinished + * will be false if being flinged. + */ + if (!mScroller.isFinished()) { + abortAnimatedScroll(); + } + + initializeTouchDrag( + (int) motionEvent.getY(), + motionEvent.getPointerId(0) + ); + + break; + } + + case MotionEvent.ACTION_MOVE: { + final int activePointerIndex = motionEvent.findPointerIndex(mActivePointerId); + if (activePointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent"); + break; + } + + final int y = (int) motionEvent.getY(activePointerIndex); + int deltaY = mLastMotionY - y; + deltaY -= releaseVerticalGlow(deltaY, motionEvent.getX(activePointerIndex)); + + // Changes to dragged state if delta is greater than the slop (and not in + // the dragged state). + if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + mIsBeingDragged = true; + if (deltaY > 0) { + deltaY -= mTouchSlop; + } else { + deltaY += mTouchSlop; + } + } + + if (mIsBeingDragged) { + final int x = (int) motionEvent.getX(activePointerIndex); + int scrollOffset = scrollBy(deltaY, x, ViewCompat.TYPE_TOUCH, false); + // Updates the global positions (used by later move events to properly scroll). + mLastMotionY = y - scrollOffset; + mNestedYOffset += scrollOffset; + } + break; + } + + case MotionEvent.ACTION_UP: { + final VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId); + if ((Math.abs(initialVelocity) >= mMinimumVelocity)) { + if (!edgeEffectFling(initialVelocity) + && !dispatchNestedPreFling(0, -initialVelocity)) { + dispatchNestedFling(0, -initialVelocity, true); + fling(-initialVelocity); + } + } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, + getScrollRange())) { + postInvalidateOnAnimation(); + } + endTouchDrag(); + break; + } + + case MotionEvent.ACTION_CANCEL: { + if (mIsBeingDragged && getChildCount() > 0) { + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, + getScrollRange())) { + postInvalidateOnAnimation(); + } + } + endTouchDrag(); + break; + } + + case MotionEvent.ACTION_POINTER_DOWN: { + final int index = motionEvent.getActionIndex(); + mLastMotionY = (int) motionEvent.getY(index); + mActivePointerId = motionEvent.getPointerId(index); + break; + } + + case MotionEvent.ACTION_POINTER_UP: { + onSecondaryPointerUp(motionEvent); + mLastMotionY = + (int) motionEvent.getY(motionEvent.findPointerIndex(mActivePointerId)); + break; + } + } + + if (mVelocityTracker != null) { + mVelocityTracker.addMovement(velocityTrackerMotionEvent); + } + // Returns object back to be re-used by others. + velocityTrackerMotionEvent.recycle(); + + return true; + } + + private void initializeTouchDrag(int lastMotionY, int activePointerId) { + mLastMotionY = lastMotionY; + mActivePointerId = activePointerId; + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH); + } + + // Ends drag in a nested scroll. + private void endTouchDrag() { + mActivePointerId = INVALID_POINTER; + mIsBeingDragged = false; + + recycleVelocityTracker(); + stopNestedScroll(ViewCompat.TYPE_TOUCH); + + mEdgeGlowTop.onRelease(); + mEdgeGlowBottom.onRelease(); + } + + /* + * Handles scroll events for both touch and non-touch events (mouse scroll wheel, + * rotary button, keyboard, etc.). + * + * Note: This function returns the total scroll offset for this scroll event which is required + * for calculating the total scroll between multiple move events (touch). This returned value + * is NOT needed for non-touch events since a scroll is a one time event (vs. touch where a + * drag may be triggered multiple times with the movement of the finger). + */ + // TODO: You should rename this to nestedScrollBy() so it is different from View.scrollBy + private int scrollBy( + int verticalScrollDistance, + int x, + int touchType, + boolean isSourceMouseOrKeyboard + ) { + int totalScrollOffset = 0; + + /* + * Starts nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.). + * This is in contrast to a touch event which would trigger the start of nested scrolling + * with a touch down event outside of this method, since for a single gesture scrollBy() + * might be called several times for a move event for a single drag gesture. + */ + if (touchType == ViewCompat.TYPE_NON_TOUCH) { + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, touchType); + } + + // Dispatches scrolling delta amount available to parent (to consume what it needs). + // Note: The amounts the parent consumes are saved in arrays named mScrollConsumed and + // mScrollConsumed to save space. + if (dispatchNestedPreScroll( + 0, + verticalScrollDistance, + mScrollConsumed, + mScrollOffset, + touchType) + ) { + // Deducts the scroll amount (y) consumed by the parent (x in position 0, + // y in position 1). Nested scroll only works with Y position (so we don't use x). + verticalScrollDistance -= mScrollConsumed[1]; + totalScrollOffset += mScrollOffset[1]; + } + + // Retrieves the scroll y position (top position of this view) and scroll Y range (how far + // the scroll can go). + final int initialScrollY = getScrollY(); + final int scrollRangeY = getScrollRange(); + + // Overscroll is for adding animations at the top/bottom of a view when the user scrolls + // beyond the beginning/end of the view. Overscroll is not used with a mouse. + boolean canOverscroll = canOverScroll() && !isSourceMouseOrKeyboard; + + // Scrolls content in the current View, but clamps it if it goes too far. + boolean hitScrollBarrier = + overScrollByCompat( + 0, + verticalScrollDistance, + 0, + initialScrollY, + 0, + scrollRangeY, + 0, + 0, + true + ) && !hasNestedScrollingParent(touchType); + + // The position may have been adjusted in the previous call, so we must revise our values. + final int scrollYDelta = getScrollY() - initialScrollY; + final int unconsumedY = verticalScrollDistance - scrollYDelta; + + // Reset the Y consumed scroll to zero + mScrollConsumed[1] = 0; + + // Dispatch the unconsumed delta Y to the children to consume. + dispatchNestedScroll( + 0, + scrollYDelta, + 0, + unconsumedY, + mScrollOffset, + touchType, + mScrollConsumed + ); + + totalScrollOffset += mScrollOffset[1]; + + // Handle overscroll of the children. + verticalScrollDistance -= mScrollConsumed[1]; + int newScrollY = initialScrollY + verticalScrollDistance; + + if (newScrollY < 0) { + if (canOverscroll) { + EdgeEffectCompat.onPullDistance( + mEdgeGlowTop, + (float) -verticalScrollDistance / getHeight(), + (float) x / getWidth() + ); + + if (!mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onRelease(); + } + } + + } else if (newScrollY > scrollRangeY) { + if (canOverscroll) { + EdgeEffectCompat.onPullDistance( + mEdgeGlowBottom, + (float) verticalScrollDistance / getHeight(), + 1.f - ((float) x / getWidth()) + ); + + if (!mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onRelease(); + } + } + } + + if (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished()) { + postInvalidateOnAnimation(); + hitScrollBarrier = false; + } + + if (hitScrollBarrier && (touchType == ViewCompat.TYPE_TOUCH)) { + // Break our velocity if we hit a scroll barrier. + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + + /* + * Ends nested scrolling for non-touch events (mouse scroll wheel, rotary button, etc.). + * As noted above, this is in contrast to a touch event. + */ + if (touchType == ViewCompat.TYPE_NON_TOUCH) { + stopNestedScroll(touchType); + + // Required for scrolling with Rotary Device stretch top/bottom to work properly + mEdgeGlowTop.onRelease(); + mEdgeGlowBottom.onRelease(); + } + + return totalScrollOffset; + } + + /** + * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should + * animate with a fling. It will animate with a fling if the velocity will remove the + * EdgeEffect through its normal operation. + * + * @param edgeEffect The EdgeEffect that might absorb the velocity. + * @param velocity The velocity of the fling motion + * @return true if the velocity should be absorbed or false if it should be flung. + */ + private boolean shouldAbsorb(@NonNull EdgeEffect edgeEffect, int velocity) { + if (velocity > 0) { + return true; + } + float distance = EdgeEffectCompat.getDistance(edgeEffect) * getHeight(); + + // This is flinging without the spring, so let's see if it will fling past the overscroll + float flingDistance = getSplineFlingDistance(-velocity); + + return flingDistance < distance; + } + + /** + * If mTopGlow or mBottomGlow is currently active and the motion will remove some of the + * stretch, this will consume any of unconsumedY that the glow can. If the motion would + * increase the stretch, or the EdgeEffect isn't a stretch, then nothing will be consumed. + * + * @param unconsumedY The vertical delta that might be consumed by the vertical EdgeEffects + * @return The remaining unconsumed delta after the edge effects have consumed. + */ + int consumeFlingInVerticalStretch(int unconsumedY) { + int height = getHeight(); + if (unconsumedY > 0 && EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0f) { + float deltaDistance = -unconsumedY * FLING_DESTRETCH_FACTOR / height; + int consumed = Math.round(-height / FLING_DESTRETCH_FACTOR + * EdgeEffectCompat.onPullDistance(mEdgeGlowTop, deltaDistance, 0.5f)); + if (consumed != unconsumedY) { + mEdgeGlowTop.finish(); + } + return unconsumedY - consumed; + } + if (unconsumedY < 0 && EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0f) { + float deltaDistance = unconsumedY * FLING_DESTRETCH_FACTOR / height; + int consumed = Math.round(height / FLING_DESTRETCH_FACTOR + * EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, deltaDistance, 0.5f)); + if (consumed != unconsumedY) { + mEdgeGlowBottom.finish(); + } + return unconsumedY - consumed; + } + return unconsumedY; + } + + /** + * Copied from OverScroller, this returns the distance that a fling with the given velocity + * will go. + * @param velocity The velocity of the fling + * @return The distance that will be traveled by a fling of the given velocity. + */ + private float getSplineFlingDistance(int velocity) { + final double l = + Math.log(INFLEXION * Math.abs(velocity) / (SCROLL_FRICTION * mPhysicalCoeff)); + final double decelMinusOne = DECELERATION_RATE - 1.0; + return (float) (SCROLL_FRICTION * mPhysicalCoeff + * Math.exp(DECELERATION_RATE / decelMinusOne * l)); + } + + private boolean edgeEffectFling(int velocityY) { + boolean consumed = true; + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + if (shouldAbsorb(mEdgeGlowTop, velocityY)) { + mEdgeGlowTop.onAbsorb(velocityY); + } else { + fling(-velocityY); + } + } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + if (shouldAbsorb(mEdgeGlowBottom, -velocityY)) { + mEdgeGlowBottom.onAbsorb(-velocityY); + } else { + fling(-velocityY); + } + } else { + consumed = false; + } + return consumed; + } + + /** + * This stops any edge glow animation that is currently running by applying a + * 0 length pull at the displacement given by the provided MotionEvent. On pre-S devices, + * this method does nothing, allowing any animating edge effect to continue animating and + * returning false always. + * + * @param e The motion event to use to indicate the finger position for the displacement of + * the current pull. + * @return true if any edge effect had an existing effect to be drawn ond the + * animation was stopped or false if no edge effect had a value to display. + */ + private boolean stopGlowAnimations(MotionEvent e) { + boolean stopped = false; + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + EdgeEffectCompat.onPullDistance(mEdgeGlowTop, 0, e.getX() / getWidth()); + stopped = true; + } + if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, 0, 1 - e.getX() / getWidth()); + stopped = true; + } + return stopped; + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + // TODO: Make this decision more intelligent. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mLastMotionY = (int) ev.getY(newPointerIndex); + mActivePointerId = ev.getPointerId(newPointerIndex); + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + } + + @Override + public boolean onGenericMotionEvent(@NonNull MotionEvent motionEvent) { + if (motionEvent.getAction() == MotionEvent.ACTION_SCROLL && !mIsBeingDragged) { + final float verticalScroll; + final int x; + final int flingAxis; + + if (MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_CLASS_POINTER)) { + verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_VSCROLL); + x = (int) motionEvent.getX(); + flingAxis = MotionEvent.AXIS_VSCROLL; + } else if ( + MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_ROTARY_ENCODER) + ) { + verticalScroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + // Since a Wear rotary event doesn't have a true X and we want to support proper + // overscroll animations, we put the x at the center of the screen. + x = getWidth() / 2; + flingAxis = MotionEvent.AXIS_SCROLL; + } else { + verticalScroll = 0; + x = 0; + flingAxis = 0; + } + + if (verticalScroll != 0) { + // Rotary and Mouse scrolls are inverted from a touch scroll. + final int invertedDelta = (int) (verticalScroll * getVerticalScrollFactorCompat()); + + final boolean isSourceMouse = + MotionEventCompat.isFromSource(motionEvent, InputDevice.SOURCE_MOUSE); + + scrollBy(-invertedDelta, x, ViewCompat.TYPE_NON_TOUCH, isSourceMouse); + if (flingAxis != 0) { + mDifferentialMotionFlingController.onMotionEvent(motionEvent, flingAxis); + } + + return true; + } + } + return false; + } + + /** + * Returns true if the NestedScrollView supports over scroll. + */ + private boolean canOverScroll() { + final int mode = getOverScrollMode(); + return mode == OVER_SCROLL_ALWAYS + || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && getScrollRange() > 0); + } + + @VisibleForTesting + float getVerticalScrollFactorCompat() { + if (mVerticalScrollFactor == 0) { + TypedValue outValue = new TypedValue(); + final Context context = getContext(); + if (!context.getTheme().resolveAttribute( + android.R.attr.listPreferredItemHeight, outValue, true)) { + throw new IllegalStateException( + "Expected theme to define listPreferredItemHeight."); + } + mVerticalScrollFactor = outValue.getDimension( + context.getResources().getDisplayMetrics()); + } + return mVerticalScrollFactor; + } + + @Override + protected void onOverScrolled(int scrollX, int scrollY, + boolean clampedX, boolean clampedY) { + super.scrollTo(scrollX, scrollY); + } + + @SuppressWarnings({"SameParameterValue", "unused"}) + boolean overScrollByCompat(int deltaX, int deltaY, + int scrollX, int scrollY, + int scrollRangeX, int scrollRangeY, + int maxOverScrollX, int maxOverScrollY, + boolean isTouchEvent) { + + final int overScrollMode = getOverScrollMode(); + final boolean canScrollHorizontal = + computeHorizontalScrollRange() > computeHorizontalScrollExtent(); + final boolean canScrollVertical = + computeVerticalScrollRange() > computeVerticalScrollExtent(); + + final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS + || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal); + final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS + || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical); + + int newScrollX = scrollX + deltaX; + if (!overScrollHorizontal) { + maxOverScrollX = 0; + } + + int newScrollY = scrollY + deltaY; + if (!overScrollVertical) { + maxOverScrollY = 0; + } + + // Clamp values if at the limits and record + final int left = -maxOverScrollX; + final int right = maxOverScrollX + scrollRangeX; + final int top = -maxOverScrollY; + final int bottom = maxOverScrollY + scrollRangeY; + + boolean clampedX = false; + if (newScrollX > right) { + newScrollX = right; + clampedX = true; + } else if (newScrollX < left) { + newScrollX = left; + clampedX = true; + } + + boolean clampedY = false; + if (newScrollY > bottom) { + newScrollY = bottom; + clampedY = true; + } else if (newScrollY < top) { + newScrollY = top; + clampedY = true; + } + + if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) { + mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange()); + } + + onOverScrolled(newScrollX, newScrollY, clampedX, clampedY); + + return clampedX || clampedY; + } + + int getScrollRange() { + int scrollRange = 0; + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + scrollRange = Math.max(0, childSize - parentSpace); + } + return scrollRange; + } + + /** + *

+ * Finds the next focusable component that fits in the specified bounds. + *

+ * + * @param topFocus look for a candidate is the one at the top of the bounds + * if topFocus is true, or at the bottom of the bounds if topFocus is + * false + * @param top the top offset of the bounds in which a focusable must be + * found + * @param bottom the bottom offset of the bounds in which a focusable must + * be found + * @return the next focusable component in the bounds or null if none can + * be found + */ + private View findFocusableViewInBounds(boolean topFocus, int top, int bottom) { + + List focusables = getFocusables(View.FOCUS_FORWARD); + View focusCandidate = null; + + /* + * A fully contained focusable is one where its top is below the bound's + * top, and its bottom is above the bound's bottom. A partially + * contained focusable is one where some part of it is within the + * bounds, but it also has some part that is not within bounds. A fully contained + * focusable is preferred to a partially contained focusable. + */ + boolean foundFullyContainedFocusable = false; + + int count = focusables.size(); + for (int i = 0; i < count; i++) { + View view = focusables.get(i); + int viewTop = view.getTop(); + int viewBottom = view.getBottom(); + + if (top < viewBottom && viewTop < bottom) { + /* + * the focusable is in the target area, it is a candidate for + * focusing + */ + + final boolean viewIsFullyContained = (top < viewTop) && (viewBottom < bottom); + + if (focusCandidate == null) { + /* No candidate, take this one */ + focusCandidate = view; + foundFullyContainedFocusable = viewIsFullyContained; + } else { + final boolean viewIsCloserToBoundary = + (topFocus && viewTop < focusCandidate.getTop()) + || (!topFocus && viewBottom > focusCandidate.getBottom()); + + if (foundFullyContainedFocusable) { + if (viewIsFullyContained && viewIsCloserToBoundary) { + /* + * We're dealing with only fully contained views, so + * it has to be closer to the boundary to beat our + * candidate + */ + focusCandidate = view; + } + } else { + if (viewIsFullyContained) { + /* Any fully contained view beats a partially contained view */ + focusCandidate = view; + foundFullyContainedFocusable = true; + } else if (viewIsCloserToBoundary) { + /* + * Partially contained view beats another partially + * contained view if it's closer + */ + focusCandidate = view; + } + } + } + } + } + + return focusCandidate; + } + + /** + *

Handles scrolling in response to a "page up/down" shortcut press. This + * method will scroll the view by one page up or down and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go one page up or + * {@link View#FOCUS_DOWN} to go one page down + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean pageScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + if (down) { + mTempRect.top = getScrollY() + height; + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + int bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom(); + if (mTempRect.top + height > bottom) { + mTempRect.top = bottom - height; + } + } + } else { + mTempRect.top = getScrollY() - height; + if (mTempRect.top < 0) { + mTempRect.top = 0; + } + } + mTempRect.bottom = mTempRect.top + height; + + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + *

Handles scrolling in response to a "home/end" shortcut press. This + * method will scroll the view to the top or bottom and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go the top of the view or + * {@link View#FOCUS_DOWN} to go the bottom + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean fullScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + mTempRect.top = 0; + mTempRect.bottom = height; + + if (down) { + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + mTempRect.bottom = view.getBottom() + lp.bottomMargin + getPaddingBottom(); + mTempRect.top = mTempRect.bottom - height; + } + } + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + *

Scrolls the view to make the area defined by top and + * bottom visible. This method attempts to give the focus + * to a component visible in this area. If no component can be focused in + * the new visible area, the focus is reclaimed by this ScrollView.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go upward, {@link View#FOCUS_DOWN} to downward + * @param top the top offset of the new area to be made visible + * @param bottom the bottom offset of the new area to be made visible + * @return true if the key event is consumed by this method, false otherwise + */ + private boolean scrollAndFocus(int direction, int top, int bottom) { + boolean handled = true; + + int height = getHeight(); + int containerTop = getScrollY(); + int containerBottom = containerTop + height; + boolean up = direction == View.FOCUS_UP; + + View newFocused = findFocusableViewInBounds(up, top, bottom); + if (newFocused == null) { + newFocused = this; + } + + if (top >= containerTop && bottom <= containerBottom) { + handled = false; + } else { + int delta = up ? (top - containerTop) : (bottom - containerBottom); + scrollBy(delta, 0, ViewCompat.TYPE_NON_TOUCH, true); + } + + if (newFocused != findFocus()) newFocused.requestFocus(direction); + + return handled; + } + + /** + * Handle scrolling in response to an up or down arrow click. + * + * @param direction The direction corresponding to the arrow key that was + * pressed + * @return True if we consumed the event, false otherwise + */ + public boolean arrowScroll(int direction) { + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + + View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction); + + final int maxJump = getMaxScrollAmount(); + + if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump, getHeight())) { + nextFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(nextFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + + scrollBy(scrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true); + nextFocused.requestFocus(direction); + + } else { + // no new focus + int scrollDelta = maxJump; + + if (direction == View.FOCUS_UP && getScrollY() < scrollDelta) { + scrollDelta = getScrollY(); + } else if (direction == View.FOCUS_DOWN) { + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int daBottom = child.getBottom() + lp.bottomMargin; + int screenBottom = getScrollY() + getHeight() - getPaddingBottom(); + scrollDelta = Math.min(daBottom - screenBottom, maxJump); + } + } + if (scrollDelta == 0) { + return false; + } + + int finalScrollDelta = direction == View.FOCUS_DOWN ? scrollDelta : -scrollDelta; + scrollBy(finalScrollDelta, 0, ViewCompat.TYPE_NON_TOUCH, true); + } + + if (currentFocused != null && currentFocused.isFocused() + && isOffScreen(currentFocused)) { + // previously focused item still has focus and is off screen, give + // it up (take it back to ourselves) + // (also, need to temporarily force FOCUS_BEFORE_DESCENDANTS so we are + // sure to + // get it) + final int descendantFocusability = getDescendantFocusability(); // save + setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); + requestFocus(); + setDescendantFocusability(descendantFocusability); // restore + } + return true; + } + + /** + * @return whether the descendant of this scroll view is scrolled off + * screen. + */ + private boolean isOffScreen(View descendant) { + return !isWithinDeltaOfScreen(descendant, 0, getHeight()); + } + + /** + * @return whether the descendant of this scroll view is within delta + * pixels of being on the screen. + */ + private boolean isWithinDeltaOfScreen(View descendant, int delta, int height) { + descendant.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(descendant, mTempRect); + + return (mTempRect.bottom + delta) >= getScrollY() + && (mTempRect.top - delta) <= (getScrollY() + height); + } + + /** + * Smooth scroll by a Y delta + * + * @param delta the number of pixels to scroll by on the Y axis + */ + private void doScrollY(int delta) { + if (delta != 0) { + if (mSmoothScrollingEnabled) { + smoothScrollBy(0, delta); + } else { + scrollBy(0, delta); + } + } + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + */ + public final void smoothScrollBy(int dx, int dy) { + smoothScrollBy(dx, dy, DEFAULT_SMOOTH_SCROLL_DURATION, false); + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + */ + public final void smoothScrollBy(int dx, int dy, int scrollDurationMs) { + smoothScrollBy(dx, dy, scrollDurationMs, false); + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + * @param withNestedScrolling whether to include nested scrolling operations. + */ + private void smoothScrollBy(int dx, int dy, int scrollDurationMs, boolean withNestedScrolling) { + if (getChildCount() == 0) { + // Nothing to do. + return; + } + long duration = AnimationUtils.currentAnimationTimeMillis() - mLastScroll; + if (duration > ANIMATED_SCROLL_GAP) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int childSize = child.getHeight() + lp.topMargin + lp.bottomMargin; + int parentSpace = getHeight() - getPaddingTop() - getPaddingBottom(); + final int scrollY = getScrollY(); + final int maxY = Math.max(0, childSize - parentSpace); + dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY; + mScroller.startScroll(getScrollX(), scrollY, 0, dy, scrollDurationMs); + runAnimatedScroll(withNestedScrolling); + } else { + if (!mScroller.isFinished()) { + abortAnimatedScroll(); + } + scrollBy(dx, dy); + } + mLastScroll = AnimationUtils.currentAnimationTimeMillis(); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + */ + public final void smoothScrollTo(int x, int y) { + smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, false); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + */ + public final void smoothScrollTo(int x, int y, int scrollDurationMs) { + smoothScrollTo(x, y, scrollDurationMs, false); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param withNestedScrolling whether to include nested scrolling operations. + */ + // This should be considered private, it is package private to avoid a synthetic ancestor. + @SuppressWarnings("SameParameterValue") + void smoothScrollTo(int x, int y, boolean withNestedScrolling) { + smoothScrollTo(x, y, DEFAULT_SMOOTH_SCROLL_DURATION, withNestedScrolling); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + * @param scrollDurationMs the duration of the smooth scroll operation in milliseconds + * @param withNestedScrolling whether to include nested scrolling operations. + */ + // This should be considered private, it is package private to avoid a synthetic ancestor. + void smoothScrollTo(int x, int y, int scrollDurationMs, boolean withNestedScrolling) { + smoothScrollBy(x - getScrollX(), y - getScrollY(), scrollDurationMs, withNestedScrolling); + } + + /** + *

The scroll range of a scroll view is the overall height of all of its + * children.

+ */ + @Override + public int computeVerticalScrollRange() { + final int count = getChildCount(); + final int parentSpace = getHeight() - getPaddingBottom() - getPaddingTop(); + if (count == 0) { + return parentSpace; + } + + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int scrollRange = child.getBottom() + lp.bottomMargin; + final int scrollY = getScrollY(); + final int overscrollBottom = Math.max(0, scrollRange - parentSpace); + if (scrollY < 0) { + scrollRange -= scrollY; + } else if (scrollY > overscrollBottom) { + scrollRange += scrollY - overscrollBottom; + } + + return scrollRange; + } + + @Override + public int computeVerticalScrollOffset() { + return Math.max(0, super.computeVerticalScrollOffset()); + } + + @Override + public int computeVerticalScrollExtent() { + return super.computeVerticalScrollExtent(); + } + + @Override + public int computeHorizontalScrollRange() { + return super.computeHorizontalScrollRange(); + } + + @Override + public int computeHorizontalScrollOffset() { + return super.computeHorizontalScrollOffset(); + } + + @Override + public int computeHorizontalScrollExtent() { + return super.computeHorizontalScrollExtent(); + } + + @Override + protected void measureChild(@NonNull View child, int parentWidthMeasureSpec, + int parentHeightMeasureSpec) { + ViewGroup.LayoutParams lp = child.getLayoutParams(); + + int childWidthMeasureSpec; + int childHeightMeasureSpec; + + childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, getPaddingLeft() + + getPaddingRight(), lp.width); + + childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, + int parentHeightMeasureSpec, int heightUsed) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + + final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin + + widthUsed, lp.width); + final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + public void computeScroll() { + + if (mScroller.isFinished()) { + return; + } + + mScroller.computeScrollOffset(); + final int y = mScroller.getCurrY(); + int unconsumed = consumeFlingInVerticalStretch(y - mLastScrollerY); + mLastScrollerY = y; + + // Nested Scrolling Pre Pass + mScrollConsumed[1] = 0; + dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null, + ViewCompat.TYPE_NON_TOUCH); + unconsumed -= mScrollConsumed[1]; + + final int range = getScrollRange(); + + if (unconsumed != 0) { + // Internal Scroll + final int oldScrollY = getScrollY(); + overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false); + final int scrolledByMe = getScrollY() - oldScrollY; + unconsumed -= scrolledByMe; + + // Nested Scrolling Post Pass + mScrollConsumed[1] = 0; + dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset, + ViewCompat.TYPE_NON_TOUCH, mScrollConsumed); + unconsumed -= mScrollConsumed[1]; + } + + if (unconsumed != 0) { + final int mode = getOverScrollMode(); + final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS + || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); + if (canOverscroll) { + if (unconsumed < 0) { + if (mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity()); + } + } else { + if (mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity()); + } + } + } + abortAnimatedScroll(); + } + + if (!mScroller.isFinished()) { + postInvalidateOnAnimation(); + } else { + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + } + + /** + * If either of the vertical edge glows are currently active, this consumes part or all of + * deltaY on the edge glow. + * + * @param deltaY The pointer motion, in pixels, in the vertical direction, positive + * for moving down and negative for moving up. + * @param x The vertical position of the pointer. + * @return The amount of deltaY that has been consumed by the + * edge glow. + */ + private int releaseVerticalGlow(int deltaY, float x) { + // First allow releasing existing overscroll effect: + float consumed = 0; + float displacement = x / getWidth(); + float pullDistance = (float) deltaY / getHeight(); + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) != 0) { + consumed = -EdgeEffectCompat.onPullDistance(mEdgeGlowTop, -pullDistance, displacement); + if (EdgeEffectCompat.getDistance(mEdgeGlowTop) == 0) { + mEdgeGlowTop.onRelease(); + } + } else if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) != 0) { + consumed = EdgeEffectCompat.onPullDistance(mEdgeGlowBottom, pullDistance, + 1 - displacement); + if (EdgeEffectCompat.getDistance(mEdgeGlowBottom) == 0) { + mEdgeGlowBottom.onRelease(); + } + } + int pixelsConsumed = Math.round(consumed * getHeight()); + if (pixelsConsumed != 0) { + invalidate(); + } + return pixelsConsumed; + } + + private void runAnimatedScroll(boolean participateInNestedScrolling) { + if (participateInNestedScrolling) { + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH); + } else { + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + mLastScrollerY = getScrollY(); + postInvalidateOnAnimation(); + } + + private void abortAnimatedScroll() { + mScroller.abortAnimation(); + stopNestedScroll(ViewCompat.TYPE_NON_TOUCH); + } + + /** + * Scrolls the view to the given child. + * + * @param child the View to scroll to + */ + private void scrollToChild(View child) { + child.getDrawingRect(mTempRect); + + /* Offset from child's local coordinates to ScrollView coordinates */ + offsetDescendantRectToMyCoords(child, mTempRect); + + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + + if (scrollDelta != 0) { + scrollBy(0, scrollDelta); + } + } + + /** + * If rect is off screen, scroll just enough to get it (or at least the + * first screen size chunk of it) on screen. + * + * @param rect The rectangle. + * @param immediate True to scroll immediately without animation + * @return true if scrolling was performed + */ + private boolean scrollToChildRect(Rect rect, boolean immediate) { + final int delta = computeScrollDeltaToGetChildRectOnScreen(rect); + final boolean scroll = delta != 0; + if (scroll) { + if (immediate) { + scrollBy(0, delta); + } else { + smoothScrollBy(0, delta); + } + } + return scroll; + } + + /** + * Compute the amount to scroll in the Y direction in order to get + * a rectangle completely on the screen (or, if taller than the screen, + * at least the first screen size chunk of it). + * + * @param rect The rect. + * @return The scroll delta. + */ + protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) { + if (getChildCount() == 0) return 0; + + int height = getHeight(); + int screenTop = getScrollY(); + int screenBottom = screenTop + height; + int actualScreenBottom = screenBottom; + + int fadingEdge = getVerticalFadingEdgeLength(); + + // TODO: screenTop should be incremented by fadingEdge * getTopFadingEdgeStrength (but for + // the target scroll distance). + // leave room for top fading edge as long as rect isn't at very top + if (rect.top > 0) { + screenTop += fadingEdge; + } + + // TODO: screenBottom should be decremented by fadingEdge * getBottomFadingEdgeStrength (but + // for the target scroll distance). + // leave room for bottom fading edge as long as rect isn't at very bottom + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (rect.bottom < child.getHeight() + lp.topMargin + lp.bottomMargin) { + screenBottom -= fadingEdge; + } + + int scrollYDelta = 0; + + if (rect.bottom > screenBottom && rect.top > screenTop) { + // need to move down to get it in view: move down just enough so + // that the entire rectangle is in view (or at least the first + // screen size chunk). + + if (rect.height() > height) { + // just enough to get screen size chunk on + scrollYDelta += (rect.top - screenTop); + } else { + // get entire rect at bottom of screen + scrollYDelta += (rect.bottom - screenBottom); + } + + // make sure we aren't scrolling beyond the end of our content + int bottom = child.getBottom() + lp.bottomMargin; + int distanceToBottom = bottom - actualScreenBottom; + scrollYDelta = Math.min(scrollYDelta, distanceToBottom); + + } else if (rect.top < screenTop && rect.bottom < screenBottom) { + // need to move up to get it in view: move up just enough so that + // entire rectangle is in view (or at least the first screen + // size chunk of it). + + if (rect.height() > height) { + // screen size chunk + scrollYDelta -= (screenBottom - rect.bottom); + } else { + // entire rect at top + scrollYDelta -= (screenTop - rect.top); + } + + // make sure we aren't scrolling any further than the top our content + scrollYDelta = Math.max(scrollYDelta, -getScrollY()); + } + return scrollYDelta; + } + + @Override + public void requestChildFocus(View child, View focused) { + onRequestChildFocus(child, focused); + super.requestChildFocus(child, focused); + } + + protected void onRequestChildFocus(View child, View focused) { + if (!mIsLayoutDirty) { + scrollToChild(focused); + } else { + // The child may not be laid out yet, we can't compute the scroll yet + mChildToScrollTo = focused; + } + } + + + /** + * When looking for focus in children of a scroll view, need to be a little + * more careful not to give focus to something that is scrolled off screen. + * + * This is more expensive than the default {@link ViewGroup} + * implementation, otherwise this behavior might have been made the default. + */ + @Override + protected boolean onRequestFocusInDescendants(int direction, + Rect previouslyFocusedRect) { + + // convert from forward / backward notation to up / down / left / right + // (ugh). + if (direction == View.FOCUS_FORWARD) { + direction = View.FOCUS_DOWN; + } else if (direction == View.FOCUS_BACKWARD) { + direction = View.FOCUS_UP; + } + + final View nextFocus = previouslyFocusedRect == null + ? FocusFinder.getInstance().findNextFocus(this, null, direction) + : FocusFinder.getInstance().findNextFocusFromRect( + this, previouslyFocusedRect, direction); + + if (nextFocus == null) { + return false; + } + + if (isOffScreen(nextFocus)) { + return false; + } + + return nextFocus.requestFocus(direction, previouslyFocusedRect); + } + + @Override + public boolean requestChildRectangleOnScreen(@NonNull View child, Rect rectangle, + boolean immediate) { + // offset into coordinate space of this scroll view + rectangle.offset(child.getLeft() - child.getScrollX(), + child.getTop() - child.getScrollY()); + + return scrollToChildRect(rectangle, immediate); + } + + @Override + public void requestLayout() { + mIsLayoutDirty = true; + super.requestLayout(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mIsLayoutDirty = false; + // Give a child focus if it needs it + if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) { + scrollToChild(mChildToScrollTo); + } + mChildToScrollTo = null; + + if (!mIsLaidOut) { + // If there is a saved state, scroll to the position saved in that state. + if (mSavedState != null) { + scrollTo(getScrollX(), mSavedState.scrollPosition); + mSavedState = null; + } // mScrollY default value is "0" + + // Make sure current scrollY position falls into the scroll range. If it doesn't, + // scroll such that it does. + int childSize = 0; + if (getChildCount() > 0) { + View child = getChildAt(0); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + childSize = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin; + } + int parentSpace = b - t - getPaddingTop() - getPaddingBottom(); + int currentScrollY = getScrollY(); + int newScrollY = clamp(currentScrollY, parentSpace, childSize); + if (newScrollY != currentScrollY) { + scrollTo(getScrollX(), newScrollY); + } + } + + // Calling this with the present values causes it to re-claim them + scrollTo(getScrollX(), getScrollY()); + mIsLaidOut = true; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + mIsLaidOut = false; + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + View currentFocused = findFocus(); + if (null == currentFocused || this == currentFocused) { + return; + } + + // If the currently-focused view was visible on the screen when the + // screen was at the old height, then scroll the screen to make that + // view visible with the new screen height. + if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) { + currentFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(currentFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + doScrollY(scrollDelta); + } + } + + /** + * Return true if child is a descendant of parent, (or equal to the parent). + */ + private static boolean isViewDescendantOf(View child, View parent) { + if (child == parent) { + return true; + } + + final ViewParent theParent = child.getParent(); + return (theParent instanceof ViewGroup) && isViewDescendantOf((View) theParent, parent); + } + + /** + * Fling the scroll view + * + * @param velocityY The initial velocity in the Y direction. Positive + * numbers mean that the finger/cursor is moving down the screen, + * which means we want to scroll towards the top. + */ + public void fling(int velocityY) { + if (getChildCount() > 0) { + + mScroller.fling(getScrollX(), getScrollY(), // start + 0, velocityY, // velocities + 0, 0, // x + Integer.MIN_VALUE, Integer.MAX_VALUE, // y + 0, 0); // overscroll + runAnimatedScroll(true); + } + } + + /** + * {@inheritDoc} + * + *

This version also clamps the scrolling to the bounds of our child. + */ + @Override + public void scrollTo(int x, int y) { + // we rely on the fact the View.scrollBy calls scrollTo. + if (getChildCount() > 0) { + View child = getChildAt(0); + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + int parentSpaceHorizontal = getWidth() - getPaddingLeft() - getPaddingRight(); + int childSizeHorizontal = child.getWidth() + lp.leftMargin + lp.rightMargin; + int parentSpaceVertical = getHeight() - getPaddingTop() - getPaddingBottom(); + int childSizeVertical = child.getHeight() + lp.topMargin + lp.bottomMargin; + x = clamp(x, parentSpaceHorizontal, childSizeHorizontal); + y = clamp(y, parentSpaceVertical, childSizeVertical); + if (x != getScrollX() || y != getScrollY()) { + super.scrollTo(x, y); + } + } + } + + @Override + public void draw(@NonNull Canvas canvas) { + super.draw(canvas); + final int scrollY = getScrollY(); + if (!mEdgeGlowTop.isFinished()) { + final int restoreCount = canvas.save(); + int width = getWidth(); + int height = getHeight(); + int xTranslation = 0; + int yTranslation = Math.min(0, scrollY); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || Api21Impl.getClipToPadding(this)) { + width -= getPaddingLeft() + getPaddingRight(); + xTranslation += getPaddingLeft(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Api21Impl.getClipToPadding(this)) { + height -= getPaddingTop() + getPaddingBottom(); + yTranslation += getPaddingTop(); + } + canvas.translate(xTranslation, yTranslation); + mEdgeGlowTop.setSize(width, height); + if (mEdgeGlowTop.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + if (!mEdgeGlowBottom.isFinished()) { + final int restoreCount = canvas.save(); + int width = getWidth(); + int height = getHeight(); + int xTranslation = 0; + int yTranslation = Math.max(getScrollRange(), scrollY) + height; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP + || Api21Impl.getClipToPadding(this)) { + width -= getPaddingLeft() + getPaddingRight(); + xTranslation += getPaddingLeft(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Api21Impl.getClipToPadding(this)) { + height -= getPaddingTop() + getPaddingBottom(); + yTranslation -= getPaddingBottom(); + } + canvas.translate(xTranslation - width, yTranslation); + canvas.rotate(180, width, 0); + mEdgeGlowBottom.setSize(width, height); + if (mEdgeGlowBottom.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + } + + private static int clamp(int n, int my, int child) { + if (my >= child || n < 0) { + /* my >= child is this case: + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * + * n < 0 is this case: + * |------ me ------| + * |-------- child --------| + * |-- mScrollX --| + */ + return 0; + } + if ((my + n) > child) { + /* this case: + * |------ me ------| + * |------ child ------| + * |-- mScrollX --| + */ + return child - my; + } + return n; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mSavedState = ss; + requestLayout(); + } + + @NonNull + @Override + protected Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + SavedState ss = new SavedState(superState); + ss.scrollPosition = getScrollY(); + return ss; + } + + static class SavedState extends BaseSavedState { + public int scrollPosition; + + SavedState(Parcelable superState) { + super(superState); + } + + SavedState(Parcel source) { + super(source); + scrollPosition = source.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(scrollPosition); + } + + @NonNull + @Override + public String toString() { + return "HorizontalScrollView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " scrollPosition=" + scrollPosition + "}"; + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + static class AccessibilityDelegate extends AccessibilityDelegateCompat { + @Override + public boolean performAccessibilityAction(View host, int action, Bundle arguments) { + if (super.performAccessibilityAction(host, action, arguments)) { + return true; + } + final NestedScrollView nsvHost = (NestedScrollView) host; + if (!nsvHost.isEnabled()) { + return false; + } + int height = nsvHost.getHeight(); + Rect rect = new Rect(); + // Gets the visible rect on the screen except for the rotation or scale cases which + // might affect the result. + if (nsvHost.getMatrix().isIdentity() && nsvHost.getGlobalVisibleRect(rect)) { + height = rect.height(); + } + switch (action) { + case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: + case android.R.id.accessibilityActionScrollDown: { + final int viewportHeight = height - nsvHost.getPaddingBottom() + - nsvHost.getPaddingTop(); + final int targetScrollY = Math.min(nsvHost.getScrollY() + viewportHeight, + nsvHost.getScrollRange()); + if (targetScrollY != nsvHost.getScrollY()) { + nsvHost.smoothScrollTo(0, targetScrollY, true); + return true; + } + } + return false; + case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: + case android.R.id.accessibilityActionScrollUp: { + final int viewportHeight = height - nsvHost.getPaddingBottom() + - nsvHost.getPaddingTop(); + final int targetScrollY = Math.max(nsvHost.getScrollY() - viewportHeight, 0); + if (targetScrollY != nsvHost.getScrollY()) { + nsvHost.smoothScrollTo(0, targetScrollY, true); + return true; + } + } + return false; + } + return false; + } + + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + final NestedScrollView nsvHost = (NestedScrollView) host; + info.setClassName(ScrollView.class.getName()); + if (nsvHost.isEnabled()) { + final int scrollRange = nsvHost.getScrollRange(); + if (scrollRange > 0) { + info.setScrollable(true); + if (nsvHost.getScrollY() > 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_BACKWARD); + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_UP); + } + if (nsvHost.getScrollY() < scrollRange) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat + .ACTION_SCROLL_DOWN); + } + } + } + } + + @Override + public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(host, event); + final NestedScrollView nsvHost = (NestedScrollView) host; + event.setClassName(ScrollView.class.getName()); + final boolean scrollable = nsvHost.getScrollRange() > 0; + event.setScrollable(scrollable); + event.setScrollX(nsvHost.getScrollX()); + event.setScrollY(nsvHost.getScrollY()); + AccessibilityRecordCompat.setMaxScrollX(event, nsvHost.getScrollX()); + AccessibilityRecordCompat.setMaxScrollY(event, nsvHost.getScrollRange()); + } + } + + class DifferentialMotionFlingTargetImpl implements DifferentialMotionFlingTarget { + @Override + public boolean startDifferentialMotionFling(float velocity) { + if (velocity == 0) { + return false; + } + stopDifferentialMotionFling(); + fling((int) velocity); + return true; + } + + @Override + public void stopDifferentialMotionFling() { + mScroller.abortAnimation(); + } + + @Override + public float getScaledScrollFactor() { + return -getVerticalScrollFactorCompat(); + } + } + + @RequiresApi(21) + static class Api21Impl { + private Api21Impl() { + // This class is not instantiable. + } + + @DoNotInline + static boolean getClipToPadding(ViewGroup viewGroup) { + return viewGroup.getClipToPadding(); + } + } +} diff --git a/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch new file mode 100644 index 00000000..913d3b1a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/NestedScrollView.java.patch @@ -0,0 +1,103 @@ +--- prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar!/androidx/core/widget/NestedScrollView.java 1980-02-01 00:00:00.000000000 -0800 ++++ packages/modules/IntentResolver/java/src/com/android/intentresolver/widget/NestedScrollView.java 2024-03-04 17:17:47.357059016 -0800 +@@ -1,5 +1,5 @@ + /* +- * Copyright (C) 2015 The Android Open Source Project ++ * 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. +@@ -15,10 +15,9 @@ + */ + + +-package androidx.core.widget; ++package com.android.intentresolver.widget; + + import static androidx.annotation.RestrictTo.Scope.LIBRARY; +-import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; + + import android.content.Context; + import android.content.res.TypedArray; +@@ -67,13 +66,19 @@ + import androidx.core.view.ViewCompat; + import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; + import androidx.core.view.accessibility.AccessibilityRecordCompat; ++import androidx.core.widget.EdgeEffectCompat; + + import java.util.List; + + /** +- * NestedScrollView is just like {@link ScrollView}, but it supports acting +- * as both a nested scrolling parent and child on both new and old versions of Android. +- * Nested scrolling is enabled by default. ++ * A copy of the {@link androidx.core.widget.NestedScrollView} (from ++ * prebuilts/sdk/current/androidx/m2repository/androidx/core/core/1.13.0-beta01/core-1.13.0-beta01-sources.jar) ++ * without any functional changes with a pure refactoring of {@link #requestChildFocus(View, View)}: ++ * the method's body is extracted into the new protected method, ++ * {@link #onRequestChildFocus(View, View)}. ++ *

++ * For the exact change see NestedScrollView.java.patch file. ++ *

+ */ + public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, + NestedScrollingChild3, ScrollingView { +@@ -1858,7 +1863,6 @@ + *

The scroll range of a scroll view is the overall height of all of its + * children.

+ */ +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollRange() { + final int count = getChildCount(); +@@ -1881,31 +1885,26 @@ + return scrollRange; + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollOffset() { + return Math.max(0, super.computeVerticalScrollOffset()); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeVerticalScrollExtent() { + return super.computeVerticalScrollExtent(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollRange() { + return super.computeHorizontalScrollRange(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollOffset() { + return super.computeHorizontalScrollOffset(); + } + +- @RestrictTo(LIBRARY_GROUP_PREFIX) + @Override + public int computeHorizontalScrollExtent() { + return super.computeHorizontalScrollExtent(); +@@ -2163,13 +2162,17 @@ + + @Override + public void requestChildFocus(View child, View focused) { ++ onRequestChildFocus(child, focused); ++ super.requestChildFocus(child, focused); ++ } ++ ++ protected void onRequestChildFocus(View child, View focused) { + if (!mIsLayoutDirty) { + scrollToChild(focused); + } else { + // The child may not be laid out yet, we can't compute the scroll yet + mChildToScrollTo = focused; + } +- super.requestChildFocus(child, focused); + } + + -- cgit v1.2.3-59-g8ed1b From 148852fb8d8a0d182b7918852c62d73a43d4855e Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 27 Sep 2024 14:16:08 +0000 Subject: Add other null check in decorateActionFactoryWithRefinement The nullability for `getEditButtonRunnable()` was addressed in ag/28555982 but a similar fix is still needed for the copy button. Bug: 353397828 Flag: EXEMPT bugfix Test: manual Change-Id: I193eaeba55abded5f4726976dc0839d44215726c --- 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 3db821c1..4fc8fd9d 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2174,6 +2174,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override @Nullable public Runnable getCopyButtonRunnable() { + if (originalFactory.getCopyButtonRunnable() == null) return null; return () -> { if (!mRefinementManager.maybeHandleSelection( RefinementType.COPY_ACTION, -- cgit v1.2.3-59-g8ed1b From f6400db571602fbbb3c5fa88276c3e5ed40792da Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Fri, 1 Dec 2023 09:36:53 -0800 Subject: Retrieve URI title metadata through separate query calls As a file name accessed independently from preview-related metadata and also asynchronously, make it be retrieved through a separate provider calls. This way an error during preview metadata call won't affect the file name reading. Bug: 365748223 Test: atest IntentResolver-tests-unit Test: manual testing with a test app that shares a MediaProvider video Flag: com.android.intentresolver.individual_metadata_title_read Change-Id: I2e87913149e679fee22e4371d2b42f485c8b04e4 --- aconfig/FeatureFlags.aconfig | 10 + .../contentpreview/PreviewDataProvider.kt | 69 ++-- .../contentpreview/UriMetadataHelpers.kt | 37 +-- .../ui/viewmodel/ChooserViewModel.kt | 1 - .../contentpreview/PreviewDataProviderTest.kt | 358 ++++++++++++++------- 5 files changed, 313 insertions(+), 162 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index c8ad2126..6ac6efb3 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -19,6 +19,16 @@ flag { bug: "328029692" } +flag { + name: "individual_metadata_title_read" + namespace: "intentresolver" + description: "Enables separate title URI metadata calls" + bug: "304686417" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "refine_system_actions" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 9b2dbebf..07cbaa04 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -24,15 +24,16 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL import android.provider.Downloads import android.provider.OpenableColumns +import android.service.chooser.Flags.chooserPayloadToggling import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting +import com.android.intentresolver.Flags.individualMetadataTitleRead import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE 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 @@ -55,14 +56,19 @@ import kotlinx.coroutines.withTimeoutOrNull * A set of metadata columns we read for a content URI (see * [PreviewDataProvider.UriRecord.readQueryResult] method). */ -@VisibleForTesting -val METADATA_COLUMNS = +private val METADATA_COLUMNS = arrayOf( DocumentsContract.Document.COLUMN_FLAGS, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, OpenableColumns.DISPLAY_NAME, - Downloads.Impl.COLUMN_TITLE + Downloads.Impl.COLUMN_TITLE, ) + +/** Preview-related metadata columns. */ +@VisibleForTesting +val ICON_METADATA_COLUMNS = + arrayOf(DocumentsContract.Document.COLUMN_FLAGS, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + private const val TIMEOUT_MS = 1_000L /** @@ -77,7 +83,6 @@ constructor( private val targetIntent: Intent, private val additionalContentUri: Uri?, private val contentResolver: ContentInterface, - private val featureFlags: ChooserServiceFlags, private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { @@ -128,7 +133,7 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT - } else if (featureFlags.chooserPayloadToggling() && shouldShowPayloadSelection()) { + } else if (chooserPayloadToggling() && shouldShowPayloadSelection()) { // TODO: replace with the proper flags injection CONTENT_PREVIEW_PAYLOAD_SELECTION } else { @@ -141,7 +146,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "An attempt to read preview type from a cancelled scope", - e + e, ) CONTENT_PREVIEW_FILE } @@ -159,7 +164,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "Failed to check URI authorities; no payload toggling", - it + it, ) } .getOrDefault(false) @@ -183,7 +188,7 @@ constructor( Log.w( ContentPreviewUi.TAG, "An attempt to read first file info from a cancelled scope", - e + e, ) } builder.build() @@ -212,14 +217,20 @@ constructor( if (records.isEmpty()) { throw IndexOutOfBoundsException("There are no shared URIs") } - callerScope.launch { - val result = scope.async { getFirstFileName() }.await() - callback.accept(result) - } + callerScope.launch { callback.accept(getFirstFileName()) } } + /** + * Returns a title for the first shared URI which is read from URI metadata or, if the metadata + * is not provided, derived from the URI. + */ @Throws(IndexOutOfBoundsException::class) - private fun getFirstFileName(): String { + suspend fun getFirstFileName(): String { + return scope.async { getFirstFileNameInternal() }.await() + } + + @Throws(IndexOutOfBoundsException::class) + private fun getFirstFileNameInternal(): String { if (records.isEmpty()) throw IndexOutOfBoundsException("There are no shared URIs") val record = records[0] @@ -282,16 +293,23 @@ constructor( get() = query.supportsThumbnail val title: String - get() = query.title + get() = if (individualMetadataTitleRead()) titleFromQuery else query.title val iconUri: Uri? get() = query.iconUri - private val query by lazy { readQueryResult() } + private val query by lazy { + readQueryResult( + if (individualMetadataTitleRead()) ICON_METADATA_COLUMNS else METADATA_COLUMNS + ) + } + + private val titleFromQuery by lazy { + readDisplayNameFromQuery().takeIf { !TextUtils.isEmpty(it) } ?: readTitleFromQuery() + } - private fun readQueryResult(): QueryResult = - // TODO: rewrite using methods from UiMetadataHelpers.kt - contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor -> + private fun readQueryResult(columns: Array): QueryResult = + contentResolver.querySafe(uri, columns)?.use { cursor -> if (!cursor.moveToFirst()) return@use null var flagColIdx = -1 @@ -329,12 +347,23 @@ constructor( QueryResult(supportsThumbnail, title, iconUri) } ?: QueryResult() + + private fun readTitleFromQuery(): String = readStringColumn(Downloads.Impl.COLUMN_TITLE) + + private fun readDisplayNameFromQuery(): String = + readStringColumn(OpenableColumns.DISPLAY_NAME) + + private fun readStringColumn(column: String): String = + contentResolver.querySafe(uri, arrayOf(column))?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + cursor.readString(column) + } ?: "" } private class QueryResult( val supportsThumbnail: Boolean = false, val title: String = "", - val iconUri: Uri? = null + val iconUri: Uri? = null, ) } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt index c532b9a5..80d0e058 100644 --- a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -22,11 +22,8 @@ import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL -import android.provider.Downloads import android.provider.MediaStore.MediaColumns.HEIGHT import android.provider.MediaStore.MediaColumns.WIDTH -import android.provider.OpenableColumns -import android.text.TextUtils import android.util.Log import android.util.Size import com.android.intentresolver.measurements.runTracing @@ -78,12 +75,7 @@ internal fun Cursor.readSupportsThumbnail(): Boolean = .getOrDefault(false) internal fun Cursor.readPreviewUri(): Uri? = - runCatching { - columnNames - .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) - .takeIf { it >= 0 } - ?.let { getString(it)?.let(Uri::parse) } - } + runCatching { readString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI)?.let(Uri::parse) } .getOrNull() fun Cursor.readSize(): Size? { @@ -105,34 +97,15 @@ fun Cursor.readSize(): Size? { } } -internal fun Cursor.readTitle(): String = - runCatching { - var nameColIndex = -1 - var titleColIndex = -1 - // TODO: double-check why Cursor#getColumnInded didn't work - columnNames.forEachIndexed { i, columnName -> - when (columnName) { - OpenableColumns.DISPLAY_NAME -> nameColIndex = i - Downloads.Impl.COLUMN_TITLE -> titleColIndex = i - } - } - - var title = "" - if (nameColIndex >= 0) { - title = getString(nameColIndex) ?: "" - } - if (TextUtils.isEmpty(title) && titleColIndex >= 0) { - title = getString(titleColIndex) ?: "" - } - title - } - .getOrDefault("") +internal fun Cursor.readString(columnName: String): String? = + runCatching { columnNames.indexOf(columnName).takeIf { it >= 0 }?.let { getString(it) } } + .getOrNull() private fun logProviderPermissionWarning(uri: Uri, dataName: String) { // The ContentResolver already logs the exception. Log something more informative. Log.w( ContentPreviewUi.TAG, "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + - " ensure that the sharesheet is given permission." + " ensure that the sharesheet is given permission.", ) } diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index e6f12750..fe7e9109 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -95,7 +95,6 @@ constructor( chooserRequest.targetIntent, chooserRequest.additionalContentUri, contentResolver, - flags, ) } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 370ee044..3dae760c 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -21,9 +21,15 @@ import android.content.Intent import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.FlagsParameterization +import android.platform.test.flag.junit.SetFlagsRule import android.provider.DocumentsContract -import android.service.chooser.FakeFeatureFlagsImpl -import android.service.chooser.Flags +import android.provider.Downloads +import android.provider.OpenableColumns +import android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING +import com.android.intentresolver.Flags.FLAG_INDIVIDUAL_METADATA_TITLE_READ import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope @@ -32,21 +38,26 @@ 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.junit.runner.RunWith +import org.junit.runners.Parameterized import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@RunWith(Parameterized::class) @OptIn(ExperimentalCoroutinesApi::class) -class PreviewDataProviderTest { +class PreviewDataProviderTest(flags: FlagsParameterization) { private val contentResolver = mock() private val mimeTypeClassifier = DefaultMimeTypeClassifier private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) - private val featureFlags = - FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } + @get:Rule val setFlagsRule = SetFlagsRule(flags) private fun createDataProvider( targetIntent: Intent, @@ -54,15 +65,7 @@ class PreviewDataProviderTest { additionalContentUri: Uri? = null, resolver: ContentInterface = contentResolver, typeClassifier: MimeTypeClassifier = mimeTypeClassifier, - ) = - PreviewDataProvider( - scope, - targetIntent, - additionalContentUri, - resolver, - featureFlags, - typeClassifier, - ) + ) = PreviewDataProvider(scope, targetIntent, additionalContentUri, resolver, typeClassifier) @Test fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { @@ -74,21 +77,49 @@ class PreviewDataProviderTest { } @Test - fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/notes.txt") - val targetIntent = - Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - type = "text/plain" - } - whenever(contentResolver.getType(uri)).thenReturn("text/plain") - val testSubject = createDataProvider(targetIntent) + fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() = + testScope.runTest { + val fileName = "notes.txt" + val uri = Uri.parse("content://org.pkg.app/$fileName") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + type = "text/plain" + } + whenever(contentResolver.getType(uri)).thenReturn("text/plain") + val testSubject = createDataProvider(targetIntent) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - verify(contentResolver, times(1)).getType(any()) - } + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.getFirstFileName()).isEqualTo(fileName) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_sendSingleTextFileWithDisplayNameAndTitle_displayNameTakesPrecedenceOverTitle() = + testScope.runTest { + val uri = Uri.parse("content://org.pkg.app/1234") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + type = "text/plain" + } + whenever(contentResolver.getType(uri)).thenReturn("text/plain") + val title = "Notes" + val displayName = "Notes.txt" + whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn( + MatrixCursor(arrayOf(Downloads.Impl.COLUMN_TITLE, OpenableColumns.DISPLAY_NAME)) + .apply { addRow(arrayOf(title, displayName)) } + ) + contentResolver.setTitle(uri, title) + contentResolver.setDisplayName(uri, displayName) + val testSubject = createDataProvider(targetIntent) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.getFirstFileName()).isEqualTo(displayName) + } @Test fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { @@ -114,60 +145,145 @@ class PreviewDataProviderTest { } @Test - fun test_sendSingleNonImage_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/paper.pdf") - val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - val testSubject = createDataProvider(targetIntent) + fun test_sendSingleFile_resolvesToFilePreviewUi() = + testScope.runTest { + val fileName = "paper.pdf" + val uri = Uri.parse("content://org.pkg.app/$fileName") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + val testSubject = createDataProvider(targetIntent) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(fileName) + verify(contentResolver, times(1)).getType(any()) + } @Test - fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = - Intent(Intent.ACTION_SEND).apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) - val testSubject = createDataProvider(targetIntent) + fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() = + testScope.runTest { + val fileName = "image.png" + val uri = Uri.parse("content://org.pkg.app/$fileName") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) + val testSubject = createDataProvider(targetIntent) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(fileName) + verify(contentResolver, times(1)).getType(any()) + } @Test - fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = - Intent(Intent.ACTION_SEND).apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getStreamTypes(uri, "*/*")) - .thenThrow(SecurityException("test failure")) - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) - .thenThrow(SecurityException("test failure")) - val testSubject = createDataProvider(targetIntent) + fun test_sendSingleFileWithFailingMetadata_resolvesToFilePreviewUi() = + testScope.runTest { + val fileName = "manual.pdf" + val uri = Uri.parse("content://org.pkg.app/$fileName") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenThrow(SecurityException("test failure")) + whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(SecurityException("test failure")) + val testSubject = createDataProvider(targetIntent) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(fileName) + verify(contentResolver, times(1)).getType(any()) + } @Test - fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { + @EnableFlags(FLAG_INDIVIDUAL_METADATA_TITLE_READ) + fun test_sendSingleImageWithFailingGetTypeDisjointTitleRead_resolvesToFilePreviewUi() = + testScope.runTest { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) + val title = "Image Title" + contentResolver.setTitle(uri, title) + val testSubject = createDataProvider(targetIntent) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(title) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_sendSingleFileWithFailingImageMetadata_resolvesToFilePreviewUi() = + testScope.runTest { + val fileName = "notes.pdf" + val uri = Uri.parse("content://org.pkg.app/$fileName") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getType(uri)).thenReturn("application/pdf") + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenThrow(SecurityException("test failure")) + whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())) + .thenThrow(SecurityException("test failure")) + val testSubject = createDataProvider(targetIntent) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(fileName) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + @EnableFlags(FLAG_INDIVIDUAL_METADATA_TITLE_READ) + fun test_sendSingleFileWithFailingImageMetadataIndividualTitleRead_resolvesToFilePreviewUi() = + testScope.runTest { + val uri = Uri.parse("content://org.pkg.app/image.png") + val targetIntent = + Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + } + whenever(contentResolver.getStreamTypes(uri, "*/*")) + .thenThrow(SecurityException("test failure")) + whenever(contentResolver.query(uri, ICON_METADATA_COLUMNS, null, null)) + .thenThrow(SecurityException("test failure")) + val displayName = "display name" + contentResolver.setDisplayName(uri, displayName) + val testSubject = createDataProvider(targetIntent) + + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(1) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(displayName) + verify(contentResolver, times(1)).getType(any()) + } + + @Test + fun test_SingleFileUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { val uri = Uri.parse("content://org.pkg.app/paper.pdf") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getStreamTypes(uri, "*/*")) @@ -189,7 +305,7 @@ class PreviewDataProviderTest { arrayOf( DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or DocumentsContract.Document.FLAG_SUPPORTS_METADATA - ) + ), ) } @@ -206,7 +322,8 @@ class PreviewDataProviderTest { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") val cursor = MatrixCursor(columns).apply { addRow(values) } - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) + whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(cursor) val testSubject = createDataProvider(targetIntent) @@ -224,12 +341,13 @@ class PreviewDataProviderTest { val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } whenever(contentResolver.getType(uri)).thenReturn("application/pdf") val cursor = MatrixCursor(emptyArray()) - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)).thenReturn(cursor) + whenever(contentResolver.query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(cursor) val testSubject = createDataProvider(targetIntent) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - verify(contentResolver, times(1)).query(uri, METADATA_COLUMNS, null, null) + verify(contentResolver, times(1)).query(eq(uri), anyOrNull(), anyOrNull(), anyOrNull()) assertThat(cursor.isClosed).isTrue() } @@ -244,7 +362,7 @@ class PreviewDataProviderTest { ArrayList().apply { add(uri1) add(uri2) - } + }, ) } whenever(contentResolver.getType(uri1)).thenReturn("image/png") @@ -272,7 +390,7 @@ class PreviewDataProviderTest { ArrayList().apply { add(uri1) add(uri2) - } + }, ) } val testSubject = createDataProvider(targetIntent) @@ -286,7 +404,7 @@ class PreviewDataProviderTest { } @Test - fun test_someNonImageUriWithPreview_useImagePreviewUi() { + fun test_someFileUrisWithPreview_useImagePreviewUi() { val uri1 = Uri.parse("content://org.pkg.app/test.mp4") val uri2 = Uri.parse("content://org.pkg.app/test.pdf") val targetIntent = @@ -296,7 +414,7 @@ class PreviewDataProviderTest { ArrayList().apply { add(uri1) add(uri2) - } + }, ) } whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") @@ -312,29 +430,32 @@ class PreviewDataProviderTest { } @Test - fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() { - val uri1 = Uri.parse("content://org.pkg.app/test.html") - val uri2 = Uri.parse("content://org.pkg.app/test.pdf") - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList().apply { - add(uri1) - add(uri2) - } - ) - } - whenever(contentResolver.getType(uri1)).thenReturn("text/html") - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = createDataProvider(targetIntent) + fun test_allFileUrisWithoutPreview_useFilePreviewUi() = + testScope.runTest { + val firstFileName = "test.html" + val uri1 = Uri.parse("content://org.pkg.app/$firstFileName") + val uri2 = Uri.parse("content://org.pkg.app/test.pdf") + val targetIntent = + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra( + Intent.EXTRA_STREAM, + ArrayList().apply { + add(uri1) + add(uri2) + }, + ) + } + whenever(contentResolver.getType(uri1)).thenReturn("text/html") + whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") + val testSubject = createDataProvider(targetIntent) - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(2) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(2)).getType(any()) - } + assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) + assertThat(testSubject.uriCount).isEqualTo(2) + assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) + assertThat(testSubject.firstFileInfo?.previewUri).isNull() + assertThat(testSubject.getFirstFileName()).isEqualTo(firstFileName) + verify(contentResolver, times(2)).getType(any()) + } @Test fun test_imagePreviewFileInfoFlow_dataLoadedOnce() = @@ -348,7 +469,7 @@ class PreviewDataProviderTest { ArrayList().apply { add(uri1) add(uri2) - } + }, ) } whenever(contentResolver.getType(uri1)).thenReturn("text/html") @@ -372,11 +493,11 @@ class PreviewDataProviderTest { } @Test - fun sendItemsWithAdditionalContentUri_showPayloadTogglingUi() { + @EnableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun sendImageWithAdditionalContentUri_showPayloadTogglingUi() { 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, @@ -392,7 +513,8 @@ class PreviewDataProviderTest { } @Test - fun sendItemsWithAdditionalContentUri_showImagePreviewUi() { + @DisableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) + fun sendImageWithAdditionalContentUriAndDisabledFlag_showImagePreviewUi() { 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") @@ -410,11 +532,11 @@ class PreviewDataProviderTest { } @Test + @EnableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) fun sendItemsWithAdditionalContentUriWithSameAuthority_showImagePreviewUi() { 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, @@ -434,10 +556,28 @@ class PreviewDataProviderTest { val testSubject = createDataProvider( targetIntent, - additionalContentUri = Uri.parse("content://org.pkg.app/extracontent") + additionalContentUri = Uri.parse("content://org.pkg.app/extracontent"), ) assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) verify(contentResolver, never()).getType(any()) } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun parameters(): List = + FlagsParameterization.allCombinationsOf(FLAG_INDIVIDUAL_METADATA_TITLE_READ) + } +} + +private fun ContentInterface.setDisplayName(uri: Uri, displayName: String) = + setMetadata(uri, arrayOf(OpenableColumns.DISPLAY_NAME), arrayOf(displayName)) + +private fun ContentInterface.setTitle(uri: Uri, title: String) = + setMetadata(uri, arrayOf(Downloads.Impl.COLUMN_TITLE), arrayOf(title)) + +private fun ContentInterface.setMetadata(uri: Uri, columns: Array, values: Array) { + whenever(query(uri, columns, null, null)) + .thenReturn(MatrixCursor(columns).apply { addRow(values) }) } -- cgit v1.2.3-59-g8ed1b From de09e2d769cbffdc3ce206e5bd28aec85ddaf635 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 7 Oct 2024 15:43:44 -0700 Subject: CachingTargetDataLoader to cache bitmaps and not drawables A preparation refactoring. Make ChachingTargetDataLoader to cache icon bitmaps instead of the end drawables. Drawables carry state and the same drawable can be used in two places in the target grid (i.e. in the ranked targets row and in the all-target grid) yet the new hover effect needs to be provided independently for each position. Bug: 295175912 Test: manual basic functinality testing including Shareousel selection change. Test: atest IntentResolver-tests-unit Flag: EXEMPT refactoring Change-Id: I1c5e6333310e0984e39e22cab8cf162f2f31d6e8 Change-Id: I4a3b5dff8fcfcffe7742be8ea3bd348351332c9e --- .../ChooserTargetActionsDialogFragment.java | 3 +- .../intentresolver/TargetPresentationGetter.java | 11 +--- .../intentresolver/icons/BaseLoadIconTask.java | 17 +++--- .../icons/CachingTargetDataLoader.kt | 33 +++++++----- .../icons/DefaultTargetDataLoader.kt | 21 +++++--- .../icons/LoadDirectShareIconTask.java | 20 +++---- .../android/intentresolver/icons/LoadIconTask.java | 19 ++++--- .../intentresolver/icons/TargetDataLoader.kt | 10 ++-- .../intentresolver/icons/TargetDataLoaderModule.kt | 6 ++- .../intentresolver/ResolverWrapperActivity.java | 2 +- .../icons/CachingTargetDataLoaderTest.kt | 61 +++++++++++++++++++++- 11 files changed, 131 insertions(+), 72 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index ae80fad4..ff0bda01 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -33,6 +33,7 @@ import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; @@ -136,7 +137,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment final TargetPresentationGetter pg = getProvidingAppPresentationGetter(); title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel()); - icon.setImageDrawable(pg.getIcon(mUserHandle)); + icon.setImageDrawable(new BitmapDrawable(getResources(), pg.getIconBitmap(mUserHandle))); rv.setAdapter(new VHAdapter(items)); return v; diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java index 910c65c9..ac74366e 100644 --- a/java/src/com/android/intentresolver/TargetPresentationGetter.java +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -23,7 +23,6 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.UserHandle; import android.text.TextUtils; @@ -77,21 +76,13 @@ public abstract class TargetPresentationGetter { @Nullable protected abstract String getAppLabelForSubstitutePermission(); - private Context mContext; + private final Context mContext; private final int mIconDpi; private final boolean mHasSubstitutePermission; private final ApplicationInfo mAppInfo; protected PackageManager mPm; - /** - * Retrieve the image that should be displayed as the icon when this target is presented to the - * specified {@code userHandle}. - */ - public Drawable getIcon(UserHandle userHandle) { - return new BitmapDrawable(mContext.getResources(), getIconBitmap(userHandle)); - } - /** * Retrieve the image that should be displayed as the icon when this target is presented to the * specified {@code userHandle}. diff --git a/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java index 2eceb89c..f09fcfc5 100644 --- a/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java +++ b/java/src/com/android/intentresolver/icons/BaseLoadIconTask.java @@ -17,34 +17,31 @@ package com.android.intentresolver.icons; import android.content.Context; -import android.graphics.drawable.Drawable; +import android.graphics.Bitmap; import android.os.AsyncTask; -import com.android.intentresolver.R; +import androidx.annotation.Nullable; + import com.android.intentresolver.TargetPresentationGetter; import java.util.function.Consumer; -abstract class BaseLoadIconTask extends AsyncTask { +abstract class BaseLoadIconTask extends AsyncTask { protected final Context mContext; protected final TargetPresentationGetter.Factory mPresentationFactory; - private final Consumer mCallback; + private final Consumer mCallback; BaseLoadIconTask( Context context, TargetPresentationGetter.Factory presentationFactory, - Consumer callback) { + Consumer callback) { mContext = context; mPresentationFactory = presentationFactory; mCallback = callback; } - protected final Drawable loadIconPlaceholder() { - return mContext.getDrawable(R.drawable.resolver_icon_placeholder); - } - @Override - protected final void onPostExecute(Drawable d) { + protected final void onPostExecute(@Nullable Bitmap d) { mCallback.accept(d); } } diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt index 8474b4c3..b0c26777 100644 --- a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -17,6 +17,9 @@ package com.android.intentresolver.icons import android.content.ComponentName +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.UserHandle import androidx.collection.LruCache @@ -28,23 +31,26 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching -private typealias IconCache = LruCache +private typealias IconCache = LruCache class CachingTargetDataLoader( + private val context: Context, private val targetDataLoader: TargetDataLoader, private val cacheSize: Int = 100, -) : TargetDataLoader() { +) : TargetDataLoader { @GuardedBy("self") private val perProfileIconCache = HashMap() override fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, - callback: Consumer + callback: Consumer, ): Drawable? { val cacheKey = info.toCacheKey() - return getCachedAppIcon(cacheKey, userHandle) + return getCachedAppIcon(cacheKey, userHandle)?.let { BitmapDrawable(context.resources, it) } ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable -> - getProfileIconCache(userHandle).put(cacheKey, drawable) + (drawable as? BitmapDrawable)?.bitmap?.let { + getProfileIconCache(userHandle).put(cacheKey, it) + } callback.accept(drawable) } } @@ -52,13 +58,17 @@ class CachingTargetDataLoader( override fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, - callback: Consumer + callback: Consumer, ): Drawable? { val cacheKey = info.toCacheKey() - return cacheKey?.let { getCachedAppIcon(it, userHandle) } + return cacheKey + ?.let { getCachedAppIcon(it, userHandle) } + ?.let { BitmapDrawable(context.resources, it) } ?: targetDataLoader.getOrLoadDirectShareIcon(info, userHandle) { drawable -> if (cacheKey != null) { - getProfileIconCache(userHandle).put(cacheKey, drawable) + (drawable as? BitmapDrawable)?.bitmap?.let { + getProfileIconCache(userHandle).put(cacheKey, it) + } } callback.accept(drawable) } @@ -69,7 +79,7 @@ class CachingTargetDataLoader( override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) - private fun getCachedAppIcon(component: String, userHandle: UserHandle): Drawable? = + private fun getCachedAppIcon(component: String, userHandle: UserHandle): Bitmap? = getProfileIconCache(userHandle)[component] private fun getProfileIconCache(userHandle: UserHandle): IconCache = @@ -78,10 +88,7 @@ class CachingTargetDataLoader( } private fun DisplayResolveInfo.toCacheKey() = - ComponentName( - resolveInfo.activityInfo.packageName, - resolveInfo.activityInfo.name, - ) + ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name) .flattenToString() private fun SelectableTargetInfo.toCacheKey(): String? = diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index e7392f58..117c769d 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.icons import android.app.ActivityManager import android.content.Context +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.AsyncTask import android.os.UserHandle @@ -26,6 +27,7 @@ import androidx.annotation.GuardedBy import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.R import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo @@ -40,12 +42,12 @@ class DefaultTargetDataLoader( private val context: Context, private val lifecycle: Lifecycle, private val isAudioCaptureDevice: Boolean, -) : TargetDataLoader() { +) : TargetDataLoader { private val presentationFactory = TargetPresentationGetter.Factory( context, context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity - ?: error("Unable to access ActivityManager") + ?: error("Unable to access ActivityManager"), ) private val nextTaskId = AtomicInteger(0) @GuardedBy("self") private val activeTasks = SparseArray>() @@ -68,9 +70,11 @@ class DefaultTargetDataLoader( callback: Consumer, ): Drawable? { val taskId = nextTaskId.getAndIncrement() - LoadIconTask(context, info, userHandle, presentationFactory) { result -> + LoadIconTask(context, info, presentationFactory) { bitmap -> removeTask(taskId) - callback.accept(result) + callback.accept( + bitmap?.let { BitmapDrawable(context.resources, it) } ?: loadIconPlaceholder() + ) } .also { addTask(taskId, it) } .executeOnExecutor(executor) @@ -87,9 +91,11 @@ class DefaultTargetDataLoader( context.createContextAsUser(userHandle, 0), info, presentationFactory, - ) { result -> + ) { bitmap -> removeTask(taskId) - callback.accept(result) + callback.accept( + bitmap?.let { BitmapDrawable(context.resources, it) } ?: loadIconPlaceholder() + ) } .also { addTask(taskId, it) } .executeOnExecutor(executor) @@ -123,6 +129,9 @@ class DefaultTargetDataLoader( synchronized(activeTasks) { activeTasks.remove(id) } } + private fun loadIconPlaceholder(): Drawable = + requireNotNull(context.getDrawable(R.drawable.resolver_icon_placeholder)) + private fun destroy() { synchronized(activeTasks) { for (i in 0 until activeTasks.size()) { diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index e2c0362d..641a0d6a 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -23,7 +23,6 @@ import android.content.pm.LauncherApps; import android.content.pm.PackageManager; import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Trace; @@ -50,19 +49,20 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { Context context, SelectableTargetInfo targetInfo, TargetPresentationGetter.Factory presentationFactory, - Consumer callback) { + Consumer callback) { super(context, presentationFactory, callback); mTargetInfo = targetInfo; } @Override - protected Drawable doInBackground(Void... voids) { - Drawable drawable = null; + @Nullable + protected Bitmap doInBackground(Void... voids) { + Bitmap iconBitmap = null; Trace.beginSection("shortcut-icon"); try { final Icon icon = mTargetInfo.getChooserTargetIcon(); if (icon == null || UriFilters.hasValidIcon(icon)) { - drawable = getChooserTargetIconDrawable( + iconBitmap = getChooserTargetIconBitmap( mContext, icon, mTargetInfo.getChooserTargetComponentName(), @@ -71,25 +71,21 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { Log.e(TAG, "Failed to load shortcut icon for " + mTargetInfo.getChooserTargetComponentName() + "; no access"); } - if (drawable == null) { - drawable = loadIconPlaceholder(); - } } catch (Exception e) { Log.e( TAG, "Failed to load shortcut icon for " + mTargetInfo.getChooserTargetComponentName(), e); - drawable = loadIconPlaceholder(); } finally { Trace.endSection(); } - return drawable; + return iconBitmap; } @WorkerThread @Nullable - private Drawable getChooserTargetIconDrawable( + private Bitmap getChooserTargetIconBitmap( Context context, @Nullable Icon icon, ComponentName targetComponentName, @@ -129,6 +125,6 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); sif.recycle(); - return new BitmapDrawable(context.getResources(), directShareBadgedIcon); + return directShareBadgedIcon; } } diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java index 75132208..4573fadf 100644 --- a/java/src/com/android/intentresolver/icons/LoadIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java @@ -19,11 +19,12 @@ package com.android.intentresolver.icons; import android.content.ComponentName; import android.content.Context; import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; +import android.graphics.Bitmap; import android.os.Trace; -import android.os.UserHandle; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.intentresolver.TargetPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -32,38 +33,36 @@ import java.util.function.Consumer; class LoadIconTask extends BaseLoadIconTask { private static final String TAG = "IconTask"; protected final DisplayResolveInfo mDisplayResolveInfo; - private final UserHandle mUserHandle; private final ResolveInfo mResolveInfo; LoadIconTask( Context context, DisplayResolveInfo dri, - UserHandle userHandle, TargetPresentationGetter.Factory presentationFactory, - Consumer callback) { + Consumer callback) { super(context, presentationFactory, callback); - mUserHandle = userHandle; mDisplayResolveInfo = dri; mResolveInfo = dri.getResolveInfo(); } @Override - protected Drawable doInBackground(Void... params) { + @Nullable + protected Bitmap doInBackground(Void... params) { Trace.beginSection("app-icon"); try { return loadIconForResolveInfo(mResolveInfo); } catch (Exception e) { ComponentName componentName = mDisplayResolveInfo.getResolvedComponentName(); Log.e(TAG, "Failed to load app icon for " + componentName, e); - return loadIconPlaceholder(); + return null; } finally { Trace.endSection(); } } - protected final Drawable loadIconForResolveInfo(ResolveInfo ri) { + protected final Bitmap loadIconForResolveInfo(ResolveInfo ri) { // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons // should be badged. - return mPresentationFactory.makePresentationGetter(ri).getIcon(ri.userHandle); + return mPresentationFactory.makePresentationGetter(ri).getIconBitmap(ri.userHandle); } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 935b527a..7cbd040e 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -23,24 +23,24 @@ import com.android.intentresolver.chooser.SelectableTargetInfo import java.util.function.Consumer /** A target data loader contract. Added to support testing. */ -abstract class TargetDataLoader { +interface TargetDataLoader { /** Load an app target icon */ - abstract fun getOrLoadAppTargetIcon( + fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer, ): Drawable? /** Load a shortcut icon */ - abstract fun getOrLoadDirectShareIcon( + fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer, ): Drawable? /** Load target label */ - abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer) + fun loadLabel(info: DisplayResolveInfo, callback: Consumer) /** Loads DisplayResolveInfo's display label synchronously, if needed */ - abstract fun getOrLoadLabel(info: DisplayResolveInfo) + fun getOrLoadLabel(info: DisplayResolveInfo) } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index 9c0acb11..86ebb9d9 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -39,6 +39,8 @@ object TargetDataLoaderModule { @Provides @ActivityScoped @Caching - fun cachingTargetDataLoader(targetDataLoader: TargetDataLoader): TargetDataLoader = - CachingTargetDataLoader(targetDataLoader) + fun cachingTargetDataLoader( + @ActivityContext context: Context, + targetDataLoader: TargetDataLoader, + ): TargetDataLoader = CachingTargetDataLoader(context, targetDataLoader) } diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index 22633085..0d317dc3 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -160,7 +160,7 @@ public class ResolverWrapperActivity extends ResolverActivity { } } - private static class TargetDataLoaderWrapper extends TargetDataLoader { + private static class TargetDataLoaderWrapper implements TargetDataLoader { private final TargetDataLoader mTargetDataLoader; private final CountingIdlingResource mLabelIdlingResource; diff --git a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt index a36b512b..c5063eed 100644 --- a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt @@ -21,11 +21,16 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutInfo import android.graphics.Bitmap +import android.graphics.Color import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon import android.os.UserHandle +import com.android.intentresolver.ResolverDataProvider.createResolveInfo +import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo +import com.android.intentresolver.chooser.TargetInfo import java.util.function.Consumer import org.junit.Test import org.mockito.kotlin.any @@ -37,6 +42,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class CachingTargetDataLoaderTest { + private val context = mock() private val userHandle = UserHandle.of(1) @Test @@ -61,7 +67,7 @@ class CachingTargetDataLoaderTest { on { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } doReturn null } - val testSubject = CachingTargetDataLoader(targetDataLoader) + val testSubject = CachingTargetDataLoader(context, targetDataLoader) val callback = Consumer {} testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback) @@ -102,7 +108,7 @@ class CachingTargetDataLoaderTest { } .whenever(targetDataLoader) .getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) - val testSubject = CachingTargetDataLoader(targetDataLoader) + val testSubject = CachingTargetDataLoader(context, targetDataLoader) val callback = Consumer {} testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback) @@ -112,6 +118,57 @@ class CachingTargetDataLoaderTest { 1 * { getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) } } } + + @Test + fun onlyBitmapsAreCached() { + val context = + mock { + on { userId } doReturn 1 + on { packageName } doReturn "package" + } + val colorTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + createResolveInfo(1, userHandle.identifier), + Intent(), + ) as DisplayResolveInfo + val bitmapTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + createResolveInfo(2, userHandle.identifier), + Intent(), + ) as DisplayResolveInfo + + val targetDataLoader = mock() + doAnswer { + val target = it.arguments[0] as TargetInfo + val callback = it.arguments[2] as Consumer + val drawable = + if (target === bitmapTargetInfo) { + BitmapDrawable(createBitmap()) + } else { + ColorDrawable(Color.RED) + } + callback.accept(drawable) + null + } + .whenever(targetDataLoader) + .getOrLoadAppTargetIcon(any(), eq(userHandle), any()) + val testSubject = CachingTargetDataLoader(context, targetDataLoader) + val callback = Consumer {} + + testSubject.getOrLoadAppTargetIcon(colorTargetInfo, userHandle, callback) + testSubject.getOrLoadAppTargetIcon(colorTargetInfo, userHandle, callback) + testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback) + testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback) + + verify(targetDataLoader) { + 2 * { getOrLoadAppTargetIcon(eq(colorTargetInfo), eq(userHandle), any()) } + } + verify(targetDataLoader) { + 1 * { getOrLoadAppTargetIcon(eq(bitmapTargetInfo), eq(userHandle), any()) } + } + } } private fun createBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) -- cgit v1.2.3-59-g8ed1b From b134f387d15bf2b3184c38b4c0397265056bbd1c Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Tue, 8 Oct 2024 13:29:48 -0700 Subject: Add Launcher hover effect Use Launcher iconlib's FastBitmapDrawable for chooser item icons to enable the Launcher pointer hover effect. Add a custom ChooserTargetItemView to dispatch hover events. Modify the chooser item layout to use the new view and replace image view margins with paddings to accommodate scaled-up icons on hover. Bug: 295175912 Test: atest IntentResolver-tests-unit Test: manual testing for icons hover effect including the case of disabled icons when no selections is made in Shareousel Flag: com.android.intentresolver.target_hover_and_keyboard_focus_states Change-Id: I9bc468895b72dc6770b8c4c7eeac7673b4d6a8b4 --- Android.bp | 1 + aconfig/FeatureFlags.aconfig | 7 +++ java/res/layout/chooser_grid_item_hover.xml | 67 ++++++++++++++++++++ java/res/values/dimens.xml | 4 ++ .../android/intentresolver/ChooserListAdapter.java | 32 +++++++++- .../intentresolver/chooser/DisplayResolveInfo.java | 7 +++ .../android/intentresolver/chooser/TargetInfo.java | 4 +- .../icons/CachingTargetDataLoader.kt | 29 ++++++--- .../icons/DefaultTargetDataLoader.kt | 18 ++++-- .../intentresolver/icons/HoverBitmapDrawable.kt | 41 ++++++++++++ .../intentresolver/widget/ChooserTargetItemView.kt | 73 ++++++++++++++++++++++ .../icons/CachingTargetDataLoaderTest.kt | 13 ++++ 12 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 java/res/layout/chooser_grid_item_hover.xml create mode 100644 java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt create mode 100644 java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt (limited to 'java/src') diff --git a/Android.bp b/Android.bp index 75e29a8c..c0e09105 100644 --- a/Android.bp +++ b/Android.bp @@ -54,6 +54,7 @@ android_library { "dagger2", "hilt_android", "IntentResolverFlagsLib", + "iconloader", "jsr330", "kotlin-stdlib", "kotlinx_coroutines", diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 6ac6efb3..6004890e 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -116,6 +116,13 @@ flag { } } +flag { + name: "target_hover_and_keyboard_focus_states" + namespace: "intentresolver" + description: "Adopt Launcher pointer hover and keyboard novigation focus effects for targets." + bug: "295175912" +} + flag { name: "preview_image_loader" namespace: "intentresolver" diff --git a/java/res/layout/chooser_grid_item_hover.xml b/java/res/layout/chooser_grid_item_hover.xml new file mode 100644 index 00000000..f4396ec6 --- /dev/null +++ b/java/res/layout/chooser_grid_item_hover.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index a1f03276..f85ad069 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -34,6 +34,10 @@ 288dp 56dp 22dp + 8dp + 7dp + 72dp + 70dp 18sp 12sp 12sp diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 016eb714..c2bb1f23 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.Flags.targetHoverAndKeyboardFocusStates; import android.app.ActivityManager; import android.app.prediction.AppTarget; @@ -59,6 +60,8 @@ import com.android.intentresolver.widget.BadgeTextView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; +import com.google.common.collect.ImmutableList; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -365,7 +368,10 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate(R.layout.chooser_grid_item, parent, false); + int layout = targetHoverAndKeyboardFocusStates() + ? R.layout.chooser_grid_item_hover + : R.layout.chooser_grid_item; + return mInflater.inflate(layout, parent, false); } @Override @@ -522,8 +528,10 @@ public class ChooserListAdapter extends ResolverListAdapter { public void updateAlphabeticalList(Runnable onCompleted) { final DisplayResolveInfoAzInfoComparator comparator = new DisplayResolveInfoAzInfoComparator(mContext); - final List allTargets = new ArrayList<>(); - allTargets.addAll(getTargetsInCurrentDisplayList()); + ImmutableList displayList = getTargetsInCurrentDisplayList(); + final List allTargets = + new ArrayList<>(displayList.size() + mCallerTargets.size()); + allTargets.addAll(displayList); allTargets.addAll(mCallerTargets); new AsyncTask>() { @@ -543,6 +551,24 @@ public class ChooserListAdapter extends ResolverListAdapter { // Consolidate multiple targets from same app. return allTargets .stream() + .map(appTarget -> { + if (targetHoverAndKeyboardFocusStates()) { + // Icon drawables are effectively cached per target info. + // Without cloning target infos, the same target info could be used + // for two different positions in the grid: once in the ranked + // targets row (from ResolverListAdapter#mDisplayList or + // #mCallerTargets, see #getItem()) and again in the all-app-target + // grid (copied from #mDisplayList and #mCallerTargets to + // #mSortedList). + // Using the same drawable for two list items would result in visual + // effects being applied to both simultaneously. + DisplayResolveInfo copy = appTarget.copy(); + copy.getDisplayIconHolder().setDisplayIcon(null); + return copy; + } else { + return appTarget; + } + }) .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() + "#" + target.getDisplayLabel() diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 5e44c53e..f0674a27 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -239,4 +239,11 @@ public class DisplayResolveInfo implements TargetInfo { public void setPinned(boolean pinned) { mPinned = pinned; } + + /** + * Creates a copy of the object. + */ + public DisplayResolveInfo copy() { + return new DisplayResolveInfo(this); + } } diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index ba6c3c05..e5f40001 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -65,7 +65,7 @@ public interface TargetInfo { * @param icon the icon to return on subsequent calls to {@link #getDisplayIcon()}. * Implementations may discard this request as a no-op if they don't support setting. */ - void setDisplayIcon(Drawable icon); + void setDisplayIcon(@Nullable Drawable icon); } /** A simple mutable-container implementation of {@link IconHolder}. */ @@ -78,7 +78,7 @@ public interface TargetInfo { return mDisplayIcon; } - public void setDisplayIcon(Drawable icon) { + public void setDisplayIcon(@Nullable Drawable icon) { mDisplayIcon = icon; } } diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt index b0c26777..793b7621 100644 --- a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -23,6 +23,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.UserHandle import androidx.collection.LruCache +import com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import java.util.function.Consumer @@ -46,11 +47,9 @@ class CachingTargetDataLoader( callback: Consumer, ): Drawable? { val cacheKey = info.toCacheKey() - return getCachedAppIcon(cacheKey, userHandle)?.let { BitmapDrawable(context.resources, it) } + return getCachedAppIcon(cacheKey, userHandle)?.toDrawable() ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable -> - (drawable as? BitmapDrawable)?.bitmap?.let { - getProfileIconCache(userHandle).put(cacheKey, it) - } + drawable.extractBitmap()?.let { getProfileIconCache(userHandle).put(cacheKey, it) } callback.accept(drawable) } } @@ -61,12 +60,10 @@ class CachingTargetDataLoader( callback: Consumer, ): Drawable? { val cacheKey = info.toCacheKey() - return cacheKey - ?.let { getCachedAppIcon(it, userHandle) } - ?.let { BitmapDrawable(context.resources, it) } + return cacheKey?.let { getCachedAppIcon(it, userHandle) }?.toDrawable() ?: targetDataLoader.getOrLoadDirectShareIcon(info, userHandle) { drawable -> if (cacheKey != null) { - (drawable as? BitmapDrawable)?.bitmap?.let { + drawable.extractBitmap()?.let { getProfileIconCache(userHandle).put(cacheKey, it) } } @@ -102,4 +99,20 @@ class CachingTargetDataLoader( append(directShareShortcutInfo?.id ?: "") } } + + private fun Bitmap.toDrawable(): Drawable { + return if (targetHoverAndKeyboardFocusStates()) { + HoverBitmapDrawable(this) + } else { + BitmapDrawable(context.resources, this) + } + } + + private fun Drawable.extractBitmap(): Bitmap? { + return when (this) { + is BitmapDrawable -> bitmap + is HoverBitmapDrawable -> bitmap + else -> null + } + } } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 117c769d..e181f4f3 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.icons import android.app.ActivityManager import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.AsyncTask @@ -27,6 +28,7 @@ import androidx.annotation.GuardedBy import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates import com.android.intentresolver.R import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo @@ -72,9 +74,7 @@ class DefaultTargetDataLoader( val taskId = nextTaskId.getAndIncrement() LoadIconTask(context, info, presentationFactory) { bitmap -> removeTask(taskId) - callback.accept( - bitmap?.let { BitmapDrawable(context.resources, it) } ?: loadIconPlaceholder() - ) + callback.accept(bitmap?.toDrawable() ?: loadIconPlaceholder()) } .also { addTask(taskId, it) } .executeOnExecutor(executor) @@ -93,9 +93,7 @@ class DefaultTargetDataLoader( presentationFactory, ) { bitmap -> removeTask(taskId) - callback.accept( - bitmap?.let { BitmapDrawable(context.resources, it) } ?: loadIconPlaceholder() - ) + callback.accept(bitmap?.toDrawable() ?: loadIconPlaceholder()) } .also { addTask(taskId, it) } .executeOnExecutor(executor) @@ -140,4 +138,12 @@ class DefaultTargetDataLoader( activeTasks.clear() } } + + private fun Bitmap.toDrawable(): Drawable { + return if (targetHoverAndKeyboardFocusStates()) { + HoverBitmapDrawable(this) + } else { + BitmapDrawable(context.resources, this) + } + } } diff --git a/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt b/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt new file mode 100644 index 00000000..4a21df92 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/HoverBitmapDrawable.kt @@ -0,0 +1,41 @@ +/* + * 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 + * + * https://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.icons + +import android.graphics.Bitmap +import com.android.launcher3.icons.FastBitmapDrawable + +/** A [FastBitmapDrawable] extension that provides access to the bitmap. */ +class HoverBitmapDrawable(val bitmap: Bitmap) : FastBitmapDrawable(bitmap) { + + override fun newConstantState(): FastBitmapConstantState { + return HoverBitmapDrawableState(bitmap, iconColor) + } + + private class HoverBitmapDrawableState(private val bitmap: Bitmap, color: Int) : + FastBitmapConstantState(bitmap, color) { + override fun createDrawable(): FastBitmapDrawable { + return HoverBitmapDrawable(bitmap) + } + } + + companion object { + init { + setFlagHoverEnabled(true) + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt new file mode 100644 index 00000000..28934495 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt @@ -0,0 +1,73 @@ +/* + * 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 + * + * https://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.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout + +class ChooserTargetItemView( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, +) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + ) : this(context, attrs, defStyleAttr, 0) + + private var iconView: ImageView? = null + + override fun onViewAdded(child: View) { + super.onViewAdded(child) + if (child is ImageView) { + iconView = child + } + } + + override fun onViewRemoved(child: View?) { + super.onViewRemoved(child) + if (child === iconView) { + iconView = null + } + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + val iconView = iconView ?: return false + if (!isEnabled) return true + when (event.action) { + MotionEvent.ACTION_HOVER_ENTER -> { + iconView.isHovered = true + } + MotionEvent.ACTION_HOVER_EXIT -> { + iconView.isHovered = false + } + } + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent?) = true +} diff --git a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt index c5063eed..2f0ed423 100644 --- a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt @@ -138,6 +138,12 @@ class CachingTargetDataLoaderTest { createResolveInfo(2, userHandle.identifier), Intent(), ) as DisplayResolveInfo + val hoverBitmapTargetInfo = + DisplayResolveInfo.newDisplayResolveInfo( + Intent(), + createResolveInfo(3, userHandle.identifier), + Intent(), + ) as DisplayResolveInfo val targetDataLoader = mock() doAnswer { @@ -146,6 +152,8 @@ class CachingTargetDataLoaderTest { val drawable = if (target === bitmapTargetInfo) { BitmapDrawable(createBitmap()) + } else if (target === hoverBitmapTargetInfo) { + HoverBitmapDrawable(createBitmap()) } else { ColorDrawable(Color.RED) } @@ -161,6 +169,8 @@ class CachingTargetDataLoaderTest { testSubject.getOrLoadAppTargetIcon(colorTargetInfo, userHandle, callback) testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback) testSubject.getOrLoadAppTargetIcon(bitmapTargetInfo, userHandle, callback) + testSubject.getOrLoadAppTargetIcon(hoverBitmapTargetInfo, userHandle, callback) + testSubject.getOrLoadAppTargetIcon(hoverBitmapTargetInfo, userHandle, callback) verify(targetDataLoader) { 2 * { getOrLoadAppTargetIcon(eq(colorTargetInfo), eq(userHandle), any()) } @@ -168,6 +178,9 @@ class CachingTargetDataLoaderTest { verify(targetDataLoader) { 1 * { getOrLoadAppTargetIcon(eq(bitmapTargetInfo), eq(userHandle), any()) } } + verify(targetDataLoader) { + 1 * { getOrLoadAppTargetIcon(eq(hoverBitmapTargetInfo), eq(userHandle), any()) } + } } } -- cgit v1.2.3-59-g8ed1b From 66c6c352139fbfcf89951ea4d8e93b92d07609ca Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 10 Oct 2024 21:41:22 -0700 Subject: Add keboard focus outline for Chooser targets This change applies the same focus outline as Launcher but the end result still needs more polishing. Some noticeable issues are: * the outline may overlap with a long label; * targets with a one-line labels look adjusted to the top and not centered; * with the light system ui theme, the outer online frame is barely visible compare to the inner outline (this is also true for Launcher). Bug: 295175912 Test: visual effect testing Flag: com.android.intentresolver.target_hover_and_keyboard_focus_states Change-Id: I1d22b187e0cc4b95c385d4f5b956effa31fd4505 --- java/res/layout/chooser_grid_item_hover.xml | 7 ++- java/res/values-sw600dp/dimens.xml | 1 + java/res/values/attrs.xml | 7 +++ java/res/values/dimens.xml | 2 + .../intentresolver/widget/ChooserTargetItemView.kt | 70 +++++++++++++++++++++- 5 files changed, 85 insertions(+), 2 deletions(-) (limited to 'java/src') diff --git a/java/res/layout/chooser_grid_item_hover.xml b/java/res/layout/chooser_grid_item_hover.xml index f4396ec6..05206065 100644 --- a/java/res/layout/chooser_grid_item_hover.xml +++ b/java/res/layout/chooser_grid_item_hover.xml @@ -19,6 +19,7 @@ + android:defaultFocusHighlightEnabled="false" + app:focusOutlineWidth="@dimen/chooser_item_focus_outline_width" + app:focusOutlineCornerRadius="@dimen/chooser_item_focus_outline_corner_radius" + app:focusOutlineColor="?androidprv:attr/materialColorSecondaryFixed" + app:focusInnerOutlineColor="?androidprv:attr/materialColorOnSecondaryFixedVariant"> 624dp 250dp + 16dp diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index c9f2c300..8c3ff7e2 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -56,4 +56,11 @@ + + + + + + + diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index f85ad069..515343b6 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -41,6 +41,8 @@ 18sp 12sp 12sp + 11dp + 2dp 32dp 0dp 18dp diff --git a/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt index 28934495..b5a4d617 100644 --- a/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt +++ b/java/src/com/android/intentresolver/widget/ChooserTargetItemView.kt @@ -17,11 +17,16 @@ package com.android.intentresolver.widget import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint import android.util.AttributeSet +import android.util.TypedValue import android.view.MotionEvent import android.view.View import android.widget.ImageView import android.widget.LinearLayout +import com.android.intentresolver.R class ChooserTargetItemView( context: Context, @@ -29,6 +34,14 @@ class ChooserTargetItemView( defStyleAttr: Int, defStyleRes: Int, ) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { + private val outlineRadius: Float + private val outlineWidth: Float + private val outlinePaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val outlineInnerPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private var iconView: ImageView? = null + constructor(context: Context) : this(context, null) constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) @@ -39,7 +52,28 @@ class ChooserTargetItemView( defStyleAttr: Int, ) : this(context, attrs, defStyleAttr, 0) - private var iconView: ImageView? = null + init { + val a = context.obtainStyledAttributes(attrs, R.styleable.ChooserTargetItemView) + val defaultWidth = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 2f, + context.resources.displayMetrics, + ) + outlineRadius = + a.getDimension(R.styleable.ChooserTargetItemView_focusOutlineCornerRadius, 0f) + outlineWidth = + a.getDimension(R.styleable.ChooserTargetItemView_focusOutlineWidth, defaultWidth) + + outlinePaint.strokeWidth = outlineWidth + outlinePaint.color = + a.getColor(R.styleable.ChooserTargetItemView_focusOutlineColor, Color.TRANSPARENT) + + outlineInnerPaint.strokeWidth = outlineWidth + outlineInnerPaint.color = + a.getColor(R.styleable.ChooserTargetItemView_focusInnerOutlineColor, Color.TRANSPARENT) + a.recycle() + } override fun onViewAdded(child: View) { super.onViewAdded(child) @@ -70,4 +104,38 @@ class ChooserTargetItemView( } override fun onInterceptHoverEvent(event: MotionEvent?) = true + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + if (isFocused) { + drawFocusInnerOutline(canvas) + drawFocusOutline(canvas) + } + } + + private fun drawFocusInnerOutline(canvas: Canvas) { + val outlineOffset = outlineWidth + outlineWidth / 2 + canvas.drawRoundRect( + outlineOffset, + outlineOffset, + maxOf(0f, width - outlineOffset), + maxOf(0f, height - outlineOffset), + outlineRadius - outlineWidth, + outlineRadius - outlineWidth, + outlineInnerPaint, + ) + } + + private fun drawFocusOutline(canvas: Canvas) { + val outlineOffset = outlineWidth / 2 + canvas.drawRoundRect( + outlineOffset, + outlineOffset, + maxOf(0f, width - outlineOffset), + maxOf(0f, height - outlineOffset), + outlineRadius, + outlineRadius, + outlinePaint, + ) + } } -- cgit v1.2.3-59-g8ed1b From 971b4d127f288ee179f1f35f9e7d200ba961797a Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Mon, 26 Aug 2024 21:16:57 -0700 Subject: Save Shareousel state. Fix: 362347212 Test: manual testing with injected debug logging Flag: com.android.intentresolver.save_shareousel_state Change-Id: Ibe393e84c0d7884fb1b7611e72df0c7779afce34 --- aconfig/FeatureFlags.aconfig | 10 ++++ .../interactor/UpdateChooserRequestInteractor.kt | 26 +------- .../intentresolver/domain/ChooserRequestExt.kt | 70 ++++++++++++++++++++++ .../intentresolver/inject/ActivityModelModule.kt | 26 +++++++- .../ui/viewmodel/ChooserRequestReader.kt | 4 +- .../ui/viewmodel/ChooserViewModel.kt | 26 +++++++- 6 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 java/src/com/android/intentresolver/domain/ChooserRequestExt.kt (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index 6004890e..c23b51ae 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -130,6 +130,16 @@ flag { bug: "348665058" } +flag { + name: "save_shareousel_state" + namespace: "intentresolver" + description: "Preserve Shareousel state over a system-initiated process death." + bug: "362347212" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "shareousel_update_exclude_components_extra" namespace: "intentresolver" 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 4fe5e8d5..fc193eca 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,14 +17,13 @@ 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 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate -import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.domain.updateWith import javax.inject.Inject import kotlinx.coroutines.flow.update @@ -36,28 +35,7 @@ constructor( @CustomAction private val pendingIntentSender: PendingIntentSender, ) { fun applyUpdate(targetIntent: Intent, update: ShareouselUpdate) { - repository.chooserRequest.update { current -> - current.copy( - targetIntent = targetIntent, - callerChooserTargets = - update.callerTargets.getOrDefault(current.callerChooserTargets), - modifyShareAction = - update.modifyShareAction.getOrDefault(current.modifyShareAction), - additionalTargets = update.alternateIntents.getOrDefault(current.additionalTargets), - chosenComponentSender = - update.resultIntentSender.getOrDefault(current.chosenComponentSender), - refinementIntentSender = - 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 - } - ) - } + repository.chooserRequest.update { it.updateWith(targetIntent, update) } update.customActions.onValue { actions -> repository.customActions.value = actions.map { it.toCustomActionModel(pendingIntentSender) } diff --git a/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt b/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt new file mode 100644 index 00000000..5ca3ad20 --- /dev/null +++ b/java/src/com/android/intentresolver/domain/ChooserRequestExt.kt @@ -0,0 +1,70 @@ +/* + * 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 + * + * https://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.domain + +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +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_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.os.Bundle +import com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault +import com.android.intentresolver.data.model.ChooserRequest + +/** Creates a new ChooserRequest with the target intent and updates from a Shareousel callback */ +fun ChooserRequest.updateWith(targetIntent: Intent, update: ShareouselUpdate): ChooserRequest = + copy( + targetIntent = targetIntent, + callerChooserTargets = update.callerTargets.getOrDefault(callerChooserTargets), + modifyShareAction = update.modifyShareAction.getOrDefault(modifyShareAction), + additionalTargets = update.alternateIntents.getOrDefault(additionalTargets), + chosenComponentSender = update.resultIntentSender.getOrDefault(chosenComponentSender), + refinementIntentSender = update.refinementIntentSender.getOrDefault(refinementIntentSender), + metadataText = update.metadataText.getOrDefault(metadataText), + chooserActions = update.customActions.getOrDefault(chooserActions), + filteredComponentNames = + if (shareouselUpdateExcludeComponentsExtra()) { + update.excludeComponents.getOrDefault(filteredComponentNames) + } else { + filteredComponentNames + }, + ) + +/** Save ChooserRequest values that can be updated by the Shareousel into a Bundle */ +fun ChooserRequest.saveUpdates(bundle: Bundle): Bundle { + bundle.putParcelable(EXTRA_INTENT, targetIntent) + bundle.putParcelableArray(EXTRA_CHOOSER_TARGETS, callerChooserTargets.toTypedArray()) + bundle.putParcelable(EXTRA_CHOOSER_MODIFY_SHARE_ACTION, modifyShareAction) + bundle.putParcelableArray(EXTRA_ALTERNATE_INTENTS, additionalTargets.toTypedArray()) + bundle.putParcelable(EXTRA_CHOOSER_RESULT_INTENT_SENDER, chosenComponentSender) + bundle.putParcelable(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, chosenComponentSender) + bundle.putParcelable(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, refinementIntentSender) + bundle.putCharSequence(EXTRA_METADATA_TEXT, metadataText) + bundle.putParcelableArray(EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions.toTypedArray()) + if (shareouselUpdateExcludeComponentsExtra()) { + bundle.putParcelableArray(EXTRA_EXCLUDE_COMPONENTS, filteredComponentNames.toTypedArray()) + } + return bundle +} diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index 7201bd2b..5b92c05b 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -18,9 +18,13 @@ package com.android.intentresolver.inject import android.content.Intent import android.net.Uri +import android.os.Bundle import android.service.chooser.ChooserAction +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.Flags.saveShareouselState import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.data.repository.ActivityModelRepository +import com.android.intentresolver.ui.viewmodel.CHOOSER_REQUEST_KEY import com.android.intentresolver.ui.viewmodel.readChooserRequest import com.android.intentresolver.util.ownedByCurrentUser import com.android.intentresolver.validation.Valid @@ -44,8 +48,13 @@ object ActivityModelModule { @ViewModelScoped fun provideInitialRequest( activityModelRepo: ActivityModelRepository, + savedStateHandle: SavedStateHandle, flags: ChooserServiceFlags, - ): ValidationResult = readChooserRequest(activityModelRepo.value, flags) + ): ValidationResult { + val activityModel = activityModelRepo.value + val extras = restoreChooserRequestExtras(activityModel.intent.extras, savedStateHandle) + return readChooserRequest(activityModel, flags, extras) + } @Provides fun provideChooserRequest(initialRequest: ValidationResult): ChooserRequest = @@ -117,3 +126,18 @@ private val Intent.contentUris: Sequence } } } + +private fun restoreChooserRequestExtras( + initialExtras: Bundle?, + savedStateHandle: SavedStateHandle, +): Bundle = + if (saveShareouselState()) { + savedStateHandle.get(CHOOSER_REQUEST_KEY)?.let { savedSateBundle -> + Bundle().apply { + initialExtras?.let { putAll(it) } + putAll(savedSateBundle) + } + } ?: initialExtras + } else { + initialExtras + } ?: Bundle() diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index 13cadf37..846cae9e 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -70,10 +70,10 @@ internal fun Intent.maybeAddSendActionFlags() = fun readChooserRequest( model: ActivityModel, flags: ChooserServiceFlags, + savedState: Bundle = model.intent.extras ?: Bundle(), ): ValidationResult { - val extras = model.intent.extras ?: Bundle() @Suppress("DEPRECATION") - return validateFrom(extras::get) { + return validateFrom(savedState::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() val isSendAction = targetIntent.hasSendAction() diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index fe7e9109..2292a63c 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -16,9 +16,12 @@ package com.android.intentresolver.ui.viewmodel import android.content.ContentInterface +import android.os.Bundle import android.util.Log +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.intentresolver.Flags.saveShareouselState import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.PreviewDataProvider import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor @@ -27,6 +30,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.Shar import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.domain.saveUpdates import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.shared.model.ActivityModel @@ -43,11 +47,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus private const val TAG = "ChooserViewModel" +const val CHOOSER_REQUEST_KEY = "chooser-request" @HiltViewModel class ChooserViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, activityModelRepository: ActivityModelRepository, private val shareouselViewModelProvider: Lazy, private val processUpdatesInteractor: Lazy, @@ -99,8 +105,24 @@ constructor( } init { - if (initialRequest is Invalid) { - Log.w(TAG, "initialRequest is Invalid, initialization failed") + when (initialRequest) { + is Invalid -> { + Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + is Valid -> { + if (saveShareouselState()) { + val isRestored = + savedStateHandle.get(CHOOSER_REQUEST_KEY)?.takeIf { !it.isEmpty } != + null + savedStateHandle.setSavedStateProvider(CHOOSER_REQUEST_KEY) { + Bundle().also { result -> + request.value + .takeIf { isRestored || it != initialRequest.value } + ?.saveUpdates(result) + } + } + } + } } } } -- cgit v1.2.3-59-g8ed1b From 76271ceb801fe192c5a34edf1a1e6d9bec7019ab Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 25 Oct 2024 15:55:04 -0700 Subject: Remove android.service.chooser.chooser_payload_toggling flag Bug: 302691505 Test: presubmits Flag: EXEMPT flag removal Change-Id: I5250ac14301e09274d5e971349d3e44cd25545ef --- .../android/intentresolver/ChooserActivity.java | 35 ++++---------- .../android/intentresolver/ChooserListAdapter.java | 7 --- .../contentpreview/ChooserContentPreviewUi.java | 8 +--- .../contentpreview/PreviewDataProvider.kt | 3 +- .../intentresolver/icons/TargetDataLoaderModule.kt | 15 +++--- .../intentresolver/inject/ActivityModelModule.kt | 3 +- .../ui/viewmodel/ChooserRequestReader.kt | 4 +- .../ui/viewmodel/ChooserViewModel.kt | 6 --- .../contentpreview/ChooserContentPreviewUiTest.kt | 18 ++----- .../contentpreview/PreviewDataProviderTest.kt | 23 --------- .../ui/viewmodel/ChooserRequestTest.kt | 55 +++++----------------- 11 files changed, 35 insertions(+), 142 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index c8387c4e..250edaf2 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -171,7 +171,6 @@ import java.util.function.Consumer; import java.util.function.Supplier; import javax.inject.Inject; -import javax.inject.Provider; /** * The Chooser Activity handles intent resolution specifically for sharing intents - @@ -257,16 +256,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; @Inject public ChooserHelper mChooserHelper; @Inject public FeatureFlags mFeatureFlags; - @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; @Inject public EventLog mEventLog; @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; @Inject @ImageEditor public Optional mImageEditor; @Inject @NearbyShare public Optional mNearbyShare; - protected TargetDataLoader mTargetDataLoader; - @Inject public Provider mTargetDataLoaderProvider; @Inject @Caching - public Provider mCachingTargetDataLoaderProvider; + public TargetDataLoader mTargetDataLoader; @Inject public DevicePolicyResources mDevicePolicyResources; @Inject public ProfilePagerResources mProfilePagerResources; @Inject public PackageManager mPackageManager; @@ -345,20 +341,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Log.i(TAG, "onCreate"); mActivityModelRepository.initialize(this::createActivityModel); - mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() - ? mCachingTargetDataLoaderProvider.get() - : mTargetDataLoaderProvider.get(); - setTheme(R.style.Theme_DeviceDefault_Chooser); // Initializer is invoked when this function returns, via Lifecycle. mChooserHelper.setInitializer(this::initialize); - if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { - mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); - mChooserHelper.setOnPendingSelection(this::onPendingSelection); - if (unselectFinalItem()) { - mChooserHelper.setOnHasSelections(this::onHasSelections); - } + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + mChooserHelper.setOnPendingSelection(this::onPendingSelection); + if (unselectFinalItem()) { + mChooserHelper.setOnHasSelections(this::onHasSelections); } } private int mInitialProfile = -1; @@ -659,8 +649,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate, new HeadlineGeneratorImpl(this), mRequest.getContentTypeHint(), - mRequest.getMetadataText(), - mChooserServiceFeatureFlags.chooserPayloadToggling()); + mRequest.getMetadataText()); updateStickyContentPreview(); if (shouldShowStickyContentPreview()) { getEventLog().logActionShareWithPreview( @@ -777,9 +766,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void recreatePagerAdapter() { - if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) { - return; - } destroyProfileRecords(); createProfileRecords( new AppPredictorFactory( @@ -2454,17 +2440,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "getDisplayResolveInfoCount() == 0"); - if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) { + if (rebuildComplete) { onAppTargetsLoaded(listAdapter); } chooserListAdapter.notifyDataSetChanged(); } else { - if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { - chooserListAdapter.updateAlphabeticalList( - () -> onAppTargetsLoaded(listAdapter)); - } else { - chooserListAdapter.updateAlphabeticalList(); - } + chooserListAdapter.updateAlphabeticalList(() -> onAppTargetsLoaded(listAdapter)); } if (rebuildComplete) { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index c2bb1f23..563d7d1a 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -515,13 +515,6 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - /** - * Group application targets - */ - public void updateAlphabeticalList() { - updateAlphabeticalList(() -> {}); - } - /** * Group application targets */ diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 1128ec5d..4166e5ae 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -51,7 +51,6 @@ import java.util.function.Supplier; public final class ChooserContentPreviewUi { private final CoroutineScope mScope; - private final boolean mIsPayloadTogglingEnabled; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -109,11 +108,8 @@ public final class ChooserContentPreviewUi { TransitionElementStatusCallback transitionElementStatusCallback, HeadlineGenerator headlineGenerator, ContentTypeHint contentTypeHint, - @Nullable CharSequence metadata, - // TODO: replace with the FeatureFlag ref when v1 is gone - boolean isPayloadTogglingEnabled) { + @Nullable CharSequence metadata) { mScope = scope; - mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, @@ -169,7 +165,7 @@ public final class ChooserContentPreviewUi { return fileContentPreviewUi; } - if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { + if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION) { transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO return new ShareouselContentPreviewUi(); } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 07cbaa04..d7b9077d 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -24,7 +24,6 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL import android.provider.Downloads import android.provider.OpenableColumns -import android.service.chooser.Flags.chooserPayloadToggling import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting @@ -133,7 +132,7 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT - } else if (chooserPayloadToggling() && shouldShowPayloadSelection()) { + } else if (shouldShowPayloadSelection()) { // TODO: replace with the proper flags injection CONTENT_PREVIEW_PAYLOAD_SELECTION } else { diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index 86ebb9d9..d0bd9740 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -29,18 +29,15 @@ import dagger.hilt.android.scopes.ActivityScoped @Module @InstallIn(ActivityComponent::class) object TargetDataLoaderModule { - @Provides - @ActivityScoped - fun targetDataLoader( - @ActivityContext context: Context, - @ActivityOwned lifecycle: Lifecycle, - ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) - @Provides @ActivityScoped @Caching fun cachingTargetDataLoader( @ActivityContext context: Context, - targetDataLoader: TargetDataLoader, - ): TargetDataLoader = CachingTargetDataLoader(context, targetDataLoader) + @ActivityOwned lifecycle: Lifecycle, + ): TargetDataLoader = + CachingTargetDataLoader( + context, + DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false), + ) } diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt index 5b92c05b..60eff925 100644 --- a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -49,11 +49,10 @@ object ActivityModelModule { fun provideInitialRequest( activityModelRepo: ActivityModelRepository, savedStateHandle: SavedStateHandle, - flags: ChooserServiceFlags, ): ValidationResult { val activityModel = activityModelRepo.value val extras = restoreChooserRequestExtras(activityModel.intent.extras, savedStateHandle) - return readChooserRequest(activityModel, flags, extras) + return readChooserRequest(activityModel, extras) } @Provides diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index 846cae9e..1644e409 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -48,7 +48,6 @@ import com.android.intentresolver.R import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.ext.hasSendAction import com.android.intentresolver.ext.ifMatch -import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.util.hasValidIcon import com.android.intentresolver.validation.Validation @@ -69,7 +68,6 @@ internal fun Intent.maybeAddSendActionFlags() = fun readChooserRequest( model: ActivityModel, - flags: ChooserServiceFlags, savedState: Bundle = model.intent.extras ?: Bundle(), ): ValidationResult { @Suppress("DEPRECATION") @@ -126,7 +124,7 @@ fun readChooserRequest( val additionalContentUri: Uri? val focusedItemPos: Int - if (isSendAction && flags.chooserPayloadToggling()) { + if (isSendAction) { additionalContentUri = optional(value(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) focusedItemPos = optional(value(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 } else { diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index 2292a63c..8597d802 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -32,7 +32,6 @@ import com.android.intentresolver.data.repository.ActivityModelRepository import com.android.intentresolver.data.repository.ChooserRequestRepository import com.android.intentresolver.domain.saveUpdates import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ChooserServiceFlags import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -59,7 +58,6 @@ constructor( private val processUpdatesInteractor: Lazy, private val fetchPreviewsInteractor: Lazy, @Background private val bgDispatcher: CoroutineDispatcher, - private val flags: ChooserServiceFlags, /** * Provided only for the express purpose of early exit in the event of an invalid request. * @@ -77,10 +75,6 @@ constructor( val shareouselViewModel: ShareouselViewModel by lazy { // TODO: consolidate this logic, this would require a consolidated preview view model but // for now just postpone starting the payload selection preview machinery until it's needed - assert(flags.chooserPayloadToggling()) { - "An attempt to use payload selection preview with the disabled flag" - } - viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } shareouselViewModelProvider.get() diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt index 905c8517..ef0703e6 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt @@ -61,11 +61,7 @@ class ChooserContentPreviewUiTest { private val transitionCallback = mock() @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() - private fun createContentPreviewUi( - action: String, - sharedText: CharSequence? = null, - isPayloadTogglingEnabled: Boolean = false - ) = + private fun createContentPreviewUi(action: String, sharedText: CharSequence? = null) = ChooserContentPreviewUi( testScope, previewData, @@ -81,7 +77,6 @@ class ChooserContentPreviewUiTest { headlineGenerator, ContentTypeHint.NONE, testMetadataText, - isPayloadTogglingEnabled, ) @Test @@ -114,10 +109,7 @@ class ChooserContentPreviewUiTest { .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) val testSubject = - createContentPreviewUi( - action = Intent.ACTION_SEND, - sharedText = "Shared text", - ) + createContentPreviewUi(action = Intent.ACTION_SEND, sharedText = "Shared text") assertThat(testSubject.mContentPreviewUi) .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) verify(previewData, times(1)).imagePreviewFileInfoFlow @@ -150,11 +142,7 @@ class ChooserContentPreviewUiTest { whenever(previewData.firstFileInfo) .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) whenever(previewData.imagePreviewFileInfoFlow).thenReturn(MutableSharedFlow()) - val testSubject = - createContentPreviewUi( - action = Intent.ACTION_SEND, - isPayloadTogglingEnabled = true, - ) + val testSubject = createContentPreviewUi(action = Intent.ACTION_SEND) assertThat(testSubject.mContentPreviewUi) .isInstanceOf(ShareouselContentPreviewUi::class.java) assertThat(testSubject.preferredContentPreview) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt index 3dae760c..9884a675 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt @@ -21,14 +21,12 @@ import android.content.Intent import android.database.MatrixCursor import android.media.MediaMetadata import android.net.Uri -import android.platform.test.annotations.DisableFlags import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.FlagsParameterization import android.platform.test.flag.junit.SetFlagsRule import android.provider.DocumentsContract import android.provider.Downloads import android.provider.OpenableColumns -import android.service.chooser.Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING import com.android.intentresolver.Flags.FLAG_INDIVIDUAL_METADATA_TITLE_READ import com.google.common.truth.Truth.assertThat import kotlin.coroutines.EmptyCoroutineContext @@ -493,7 +491,6 @@ class PreviewDataProviderTest(flags: FlagsParameterization) { } @Test - @EnableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) fun sendImageWithAdditionalContentUri_showPayloadTogglingUi() { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } @@ -513,26 +510,6 @@ class PreviewDataProviderTest(flags: FlagsParameterization) { } @Test - @DisableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) - fun sendImageWithAdditionalContentUriAndDisabledFlag_showImagePreviewUi() { - 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") - val testSubject = - createDataProvider( - targetIntent, - additionalContentUri = Uri.parse("content://org.pkg.app.extracontent"), - ) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) - verify(contentResolver, times(1)).getType(any()) - } - - @Test - @EnableFlags(FLAG_CHOOSER_PAYLOAD_TOGGLING) fun sendItemsWithAdditionalContentUriWithSameAuthority_showImagePreviewUi() { val uri = Uri.parse("content://org.pkg.app/image.png") val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_STREAM, uri) } 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 7bd4edee..71f28950 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -28,12 +28,10 @@ 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 import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint import com.android.intentresolver.data.model.ChooserRequest -import com.android.intentresolver.inject.FakeChooserServiceFlags import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Importance import com.android.intentresolver.validation.Invalid @@ -59,13 +57,10 @@ private fun createActivityModel( class ChooserRequestTest { - private val fakeChooserServiceFlags = - FakeChooserServiceFlags().apply { setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) } - @Test fun missingIntent() { val model = createActivityModel(targetIntent = null) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Invalid::class.java) result as Invalid @@ -80,7 +75,7 @@ class ChooserRequestTest { val model = createActivityModel(targetIntent = Intent(ACTION_SEND), referrer) model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -97,7 +92,7 @@ class ChooserRequestTest { val model = createActivityModel(targetIntent = intent, referrer = referrer) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -112,7 +107,7 @@ class ChooserRequestTest { model.intent.putExtras(bundleOf(EXTRA_REFERRER to referrer)) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -126,7 +121,7 @@ class ChooserRequestTest { val intent2 = Intent(ACTION_SEND_MULTIPLE) val model = createActivityModel(targetIntent = intent1, additionalIntents = listOf(intent2)) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -139,7 +134,7 @@ class ChooserRequestTest { val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) val model = createActivityModel(targetIntent = intent) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -149,7 +144,6 @@ class ChooserRequestTest { @Test fun testRequest_actionSendWithAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 val model = @@ -158,7 +152,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -167,36 +161,15 @@ class ChooserRequestTest { assertThat(result.value.focusedItemPosition).isEqualTo(position) } - @Test - fun testRequest_actionSendWithAdditionalContentUri_parametersIgnoredWhenFlagDisabled() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, false) - val uri = Uri.parse("content://org.pkg/path") - val position = 10 - val model = - createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { - intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, uri) - intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) - } - val result = readChooserRequest(model, fakeChooserServiceFlags) - - assertThat(result).isInstanceOf(Valid::class.java) - result as Valid - - assertThat(result.value.additionalContentUri).isNull() - assertThat(result.value.focusedItemPosition).isEqualTo(0) - assertThat(result.warnings).isEmpty() - } - @Test fun testRequest_actionSendWithInvalidAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val model = createActivityModel(targetIntent = Intent(ACTION_SEND)).apply { intent.putExtra(EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI, "__invalid__") intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, "__invalid__") } - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -207,10 +180,9 @@ class ChooserRequestTest { @Test fun testRequest_actionSendWithoutAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val model = createActivityModel(targetIntent = Intent(ACTION_SEND)) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -221,7 +193,6 @@ class ChooserRequestTest { @Test fun testRequest_actionViewWithAdditionalContentUri() { - fakeChooserServiceFlags.setFlag(Flags.FLAG_CHOOSER_PAYLOAD_TOGGLING, true) val uri = Uri.parse("content://org.pkg/path") val position = 10 val model = @@ -230,7 +201,7 @@ class ChooserRequestTest { intent.putExtra(EXTRA_CHOOSER_FOCUSED_ITEM_POSITION, position) } - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -248,7 +219,7 @@ class ChooserRequestTest { Intent.CHOOSER_CONTENT_TYPE_ALBUM, ) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -266,7 +237,7 @@ class ChooserRequestTest { intent.putExtra(Intent.EXTRA_METADATA_TEXT, metadataText) } - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) result as Valid @@ -285,7 +256,7 @@ class ChooserRequestTest { } val model = createActivityModel(targetIntent) - val result = readChooserRequest(model, fakeChooserServiceFlags) + val result = readChooserRequest(model) assertThat(result).isInstanceOf(Valid::class.java) (result as Valid).value.let { request -> -- cgit v1.2.3-59-g8ed1b From dc5cdc53ca218c912d9ffe0d3fcc0a99538cdb60 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 23 Oct 2024 14:28:52 -0700 Subject: Make DefaultTargetDataLoader assist-injectable A preparation step for an alternative implementation. Bug: 349847176 Test: atest IntentResolver-tests-activity Flag: EXEMPT refactoring Change-Id: I2b23a27edb6d2d9711af79f06643413861403add --- .../com/android/intentresolver/ResolverActivity.java | 6 ++---- .../intentresolver/icons/DefaultTargetDataLoader.kt | 20 ++++++++++++++++---- .../intentresolver/icons/TargetDataLoaderModule.kt | 10 +++------- 3 files changed, 21 insertions(+), 15 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 2f220cf1..38259281 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -150,6 +150,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Inject public IntentForwarding mIntentForwarding; @Inject public FeatureFlags mFeatureFlags; @Inject public ActivityModelRepository mActivityModelRepository; + @Inject public DefaultTargetDataLoader.Factory mTargetDataLoaderFactory; private ResolverViewModel mViewModel; private ResolverRequest mRequest; @@ -334,10 +335,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); mResolvingHome = mRequest.isResolvingHome(); - mTargetDataLoader = new DefaultTargetDataLoader( - this, - getLifecycle(), - mRequest.isAudioCaptureDevice()); + mTargetDataLoader = mTargetDataLoaderFactory.create(mRequest.isAudioCaptureDevice()); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index e181f4f3..e80a4a7c 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -33,6 +33,11 @@ import com.android.intentresolver.R import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo +import com.android.intentresolver.inject.ActivityOwned +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer import kotlinx.coroutines.Dispatchers @@ -40,10 +45,12 @@ import kotlinx.coroutines.asExecutor /** An actual [TargetDataLoader] implementation. */ // TODO: replace async tasks with coroutines. -class DefaultTargetDataLoader( - private val context: Context, - private val lifecycle: Lifecycle, - private val isAudioCaptureDevice: Boolean, +class DefaultTargetDataLoader +@AssistedInject +constructor( + @ActivityContext private val context: Context, + @ActivityOwned private val lifecycle: Lifecycle, + @Assisted private val isAudioCaptureDevice: Boolean, ) : TargetDataLoader { private val presentationFactory = TargetPresentationGetter.Factory( @@ -146,4 +153,9 @@ class DefaultTargetDataLoader( BitmapDrawable(context.resources, this) } } + + @AssistedFactory + interface Factory { + fun create(isAudioCaptureDevice: Boolean): DefaultTargetDataLoader + } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index d0bd9740..21ff654f 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -17,8 +17,6 @@ package com.android.intentresolver.icons import android.content.Context -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.inject.ActivityOwned import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,10 +32,8 @@ object TargetDataLoaderModule { @Caching fun cachingTargetDataLoader( @ActivityContext context: Context, - @ActivityOwned lifecycle: Lifecycle, + dataLoaderFactory: DefaultTargetDataLoader.Factory, ): TargetDataLoader = - CachingTargetDataLoader( - context, - DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false), - ) + // Intended to be used in Chooser only thus the hardcoded isAudioCaptureDevice value. + CachingTargetDataLoader(context, dataLoaderFactory.create(isAudioCaptureDevice = false)) } -- cgit v1.2.3-59-g8ed1b From 2588fa09d9a6f162c97593318558a25dd1758d84 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 25 Oct 2024 14:38:19 -0700 Subject: Set a11y role description for the edit, modify share, and action buttons Use Button instead of TextView for the action button and the modify share widgets. Set a11y role description for the edit chip with a custom a11y delegate. Fix: 374035886 Test: manual testing Flag: EXEMPT bugfix Change-Id: I69dd97cb8cb6a847849a1a7b272f0877745e62a0 --- java/res/layout/chooser_action_view.xml | 2 +- java/res/layout/chooser_grid_preview_image.xml | 3 +- java/res/layout/chooser_headline_row.xml | 2 +- java/res/values/attrs.xml | 1 + java/res/values/strings.xml | 2 + .../widget/ScrollableImagePreviewView.kt | 50 +++++++++++++--------- .../ViewRoleDescriptionAccessibilityDelegate.kt | 29 +++++++++++++ 7 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt (limited to 'java/src') diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml index 6177821a..b4859258 100644 --- a/java/res/layout/chooser_action_view.xml +++ b/java/res/layout/chooser_action_view.xml @@ -14,7 +14,7 @@ ~ limitations under the License --> - + app:itemOuterSpacing="@dimen/chooser_edge_margin_normal" + app:editButtonRoleDescription="@string/role_description_button"/> diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 01be653f..4e19249b 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -60,7 +60,7 @@ app:barrierDirection="start" app:constraint_referenced_ids="reselection_action,include_text_action" /> - + diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 4f77d248..2261a4a8 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -338,4 +338,6 @@ Selectable item + + Button diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index c706e3ee..935a8724 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -71,38 +71,39 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { constructor( context: Context, attrs: AttributeSet?, - defStyleAttr: Int + defStyleAttr: Int, ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + val editButtonRoleDescription: CharSequence? context .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0) .use { a -> var innerSpacing = a.getDimensionPixelSize( R.styleable.ScrollableImagePreviewView_itemInnerSpacing, - -1 + -1, ) if (innerSpacing < 0) { innerSpacing = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 3f, - context.resources.displayMetrics + context.resources.displayMetrics, ) .toInt() } outerSpacing = a.getDimensionPixelSize( R.styleable.ScrollableImagePreviewView_itemOuterSpacing, - -1 + -1, ) if (outerSpacing < 0) { outerSpacing = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 16f, - context.resources.displayMetrics + context.resources.displayMetrics, ) .toInt() } @@ -110,10 +111,13 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { maxWidthHint = a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1) + + editButtonRoleDescription = + a.getText(R.styleable.ScrollableImagePreviewView_editButtonRoleDescription) } val itemAnimator = ItemAnimator() super.setItemAnimator(itemAnimator) - super.setAdapter(Adapter(context, itemAnimator.getAddDuration())) + super.setAdapter(Adapter(context, itemAnimator.getAddDuration(), editButtonRoleDescription)) } private var batchLoader: BatchPreviewLoader? = null @@ -125,7 +129,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { */ 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 @@ -217,7 +220,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { onNoPreviewCallback?.run() } previewAdapter.markLoaded() - } + }, ) maybeLoadAspectRatios() } @@ -281,24 +284,25 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { val type: PreviewType, val uri: Uri, val editAction: Runnable?, - internal var aspectRatioString: String + internal var aspectRatioString: String, ) { constructor( type: PreviewType, uri: Uri, - editAction: Runnable? + editAction: Runnable?, ) : this(type, uri, editAction, "1:1") } enum class PreviewType { Image, Video, - File + File, } private class Adapter( private val context: Context, private val fadeInDurationMs: Long, + private val editButtonRoleDescription: CharSequence?, ) : RecyclerView.Adapter() { private val previews = ArrayList() private val imagePreviewDescription = @@ -409,6 +413,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewSize, fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, + editButtonRoleDescription, previewReadyCallback = if ( position == firstImagePos && transitionStatusElementCallback != null @@ -416,7 +421,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { this::onTransitionElementReady } else { null - } + }, ) } } @@ -461,7 +466,8 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { previewSize: Size, fadeInDurationMs: Long, isSharedTransitionElement: Boolean, - previewReadyCallback: ((String) -> Unit)? + editButtonRoleDescription: CharSequence?, + previewReadyCallback: ((String) -> Unit)?, ) { image.setImageDrawable(null) image.alpha = 1f @@ -495,6 +501,12 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { editActionContainer?.apply { setOnClickListener { onClick.run() } visibility = View.VISIBLE + if (editButtonRoleDescription != null) { + ViewCompat.setAccessibilityDelegate( + this, + ViewRoleDescriptionAccessibilityDelegate(editButtonRoleDescription), + ) + } } } resetScope().launch { @@ -568,7 +580,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { PluralsMessageFormatter.format( itemView.context.resources, mapOf(PLURALS_COUNT to count), - R.string.other_files + R.string.other_files, ) } @@ -611,7 +623,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { state: State, viewHolder: RecyclerView.ViewHolder, changeFlags: Int, - payloads: MutableList + payloads: MutableList, ): ItemHolderInfo { return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let { holderInfo -> @@ -626,7 +638,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun animateDisappearance( viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, - postLayoutInfo: ItemHolderInfo? + postLayoutInfo: ItemHolderInfo?, ): Boolean { if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) { val view = viewHolder.itemView @@ -647,10 +659,8 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { super.onRemoveFinished(viewHolder) } - private inner class LoadingItemHolderInfo( - holderInfo: ItemHolderInfo, - val parentLeft: Int, - ) : ItemHolderInfo() { + private inner class LoadingItemHolderInfo(holderInfo: ItemHolderInfo, val parentLeft: Int) : + ItemHolderInfo() { init { left = holderInfo.left top = holderInfo.top diff --git a/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt b/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt new file mode 100644 index 00000000..8fe7144a --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ViewRoleDescriptionAccessibilityDelegate.kt @@ -0,0 +1,29 @@ +/* + * 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 + * + * https://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.widget + +import android.view.View +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat + +class ViewRoleDescriptionAccessibilityDelegate(private val roleDescription: CharSequence) : + AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.roleDescription = roleDescription + } +} -- cgit v1.2.3-59-g8ed1b From 40389e404f72cb13f9632fa86cd0f13905f682ea Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Fri, 1 Nov 2024 13:38:34 -0700 Subject: Fix shortcut icon loading. The SimpleIconFactory constructor fails when invoked with a context returned by Context.createContextAsUser(). Previously, shortcut icons were loaded due to the reuse of cached icon factory instances from prior tasks. This change introduces SimpleIconFactory as a dependency to ensure correct instance creation. Fix: 376891146 Test: Disable SimpleIconFactory instance pooling through an injecetd debug code and verify that shortcut icons are loaded. Test: atest IntentResolver-tests-unit Flag: EXEMPT bugfix Change-Id: I2ad2a2bda2e4e152b11be852a04873d3c8104166 --- .../ChooserTargetActionsDialogFragment.java | 6 ++- .../android/intentresolver/SimpleIconFactory.java | 15 ++++-- .../intentresolver/TargetPresentationGetter.java | 53 +++++++++++++++------- .../icons/DefaultTargetDataLoader.kt | 12 ++--- .../icons/LoadDirectShareIconTask.java | 12 +++-- .../intentresolver/icons/TargetDataLoaderModule.kt | 21 +++++++++ .../intentresolver/TargetPresentationGetterTest.kt | 26 +++++++---- 7 files changed, 105 insertions(+), 40 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index ff0bda01..8070fc84 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -281,7 +281,11 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment final int iconDpi = am.getLauncherLargeIconDensity(); // Use the matching application icon and label for the title, any TargetInfo will do - return new TargetPresentationGetter.Factory(getContext(), iconDpi) + final Context context = getContext(); + return new TargetPresentationGetter.Factory( + () -> SimpleIconFactory.obtain(context), + context.getPackageManager(), + iconDpi) .makePresentationGetter(mTargetInfos.get(0).getResolveInfo()); } diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index f4871e36..afb7d19e 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -64,7 +64,7 @@ import java.util.Optional; * possibly badged. It is intended to be used only by Sharesheet for the Q release with custom code. */ @Deprecated -public class SimpleIconFactory { +public class SimpleIconFactory implements AutoCloseable { private static final SynchronizedPool sPool = @@ -139,6 +139,11 @@ public class SimpleIconFactory { "Expected theme to define iconfactoryBadgeSize."); } + @Override + public void close() { + recycle(); + } + /** * Recycles the SimpleIconFactory so others may use it. * @@ -146,9 +151,11 @@ public class SimpleIconFactory { */ @Deprecated public void recycle() { - // Return to default background color - setWrapperBackgroundColor(Color.WHITE); - sPool.release(this); + if (sPoolEnabled) { + // Return to default background color + setWrapperBackgroundColor(Color.WHITE); + sPool.release(this); + } } /** diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java index ac74366e..3a7f807d 100644 --- a/java/src/com/android/intentresolver/TargetPresentationGetter.java +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -30,6 +29,8 @@ import android.util.Log; import androidx.annotation.Nullable; +import javax.inject.Provider; + /** * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon * and label over any IntentFilter or Activity icon to increase user understanding, with an @@ -48,22 +49,29 @@ public abstract class TargetPresentationGetter { /** Helper to build appropriate type-specific {@link TargetPresentationGetter} instances. */ public static class Factory { - private final Context mContext; + private final Provider mIconFactoryProvider; + private final PackageManager mPackageManager; private final int mIconDpi; - public Factory(Context context, int iconDpi) { - mContext = context; + public Factory( + Provider iconfactoryProvider, + PackageManager packageManager, + int iconDpi) { + mIconFactoryProvider = iconfactoryProvider; + mPackageManager = packageManager; mIconDpi = iconDpi; } /** Make a {@link TargetPresentationGetter} for an {@link ActivityInfo}. */ public TargetPresentationGetter makePresentationGetter(ActivityInfo activityInfo) { - return new ActivityInfoPresentationGetter(mContext, mIconDpi, activityInfo); + return new ActivityInfoPresentationGetter( + mIconFactoryProvider, mPackageManager, mIconDpi, activityInfo); } /** Make a {@link TargetPresentationGetter} for a {@link ResolveInfo}. */ public TargetPresentationGetter makePresentationGetter(ResolveInfo resolveInfo) { - return new ResolveInfoPresentationGetter(mContext, mIconDpi, resolveInfo); + return new ResolveInfoPresentationGetter( + mIconFactoryProvider, mPackageManager, mIconDpi, resolveInfo); } } @@ -76,7 +84,7 @@ public abstract class TargetPresentationGetter { @Nullable protected abstract String getAppLabelForSubstitutePermission(); - private final Context mContext; + private final Provider mIconFactoryProvider; private final int mIconDpi; private final boolean mHasSubstitutePermission; private final ApplicationInfo mAppInfo; @@ -107,9 +115,10 @@ public abstract class TargetPresentationGetter { drawable = mAppInfo.loadIcon(mPm); } - SimpleIconFactory iconFactory = SimpleIconFactory.obtain(mContext); - Bitmap icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle); - iconFactory.recycle(); + Bitmap icon; + try (SimpleIconFactory iconFactory = mIconFactoryProvider.get()) { + icon = iconFactory.createUserBadgedIconBitmap(drawable, userHandle); + } return icon; } @@ -159,9 +168,13 @@ public abstract class TargetPresentationGetter { return res.getDrawableForDensity(resId, mIconDpi); } - private TargetPresentationGetter(Context context, int iconDpi, ApplicationInfo appInfo) { - mContext = context; - mPm = context.getPackageManager(); + private TargetPresentationGetter( + Provider iconfactoryProvider, + PackageManager packageManager, + int iconDpi, + ApplicationInfo appInfo) { + mIconFactoryProvider = iconfactoryProvider; + mPm = packageManager; mAppInfo = appInfo; mIconDpi = iconDpi; mHasSubstitutePermission = (PackageManager.PERMISSION_GRANTED == mPm.checkPermission( @@ -174,8 +187,11 @@ public abstract class TargetPresentationGetter { private final ResolveInfo mResolveInfo; ResolveInfoPresentationGetter( - Context context, int iconDpi, ResolveInfo resolveInfo) { - super(context, iconDpi, resolveInfo.activityInfo); + Provider iconfactoryProvider, + PackageManager packageManager, + int iconDpi, + ResolveInfo resolveInfo) { + super(iconfactoryProvider, packageManager, iconDpi, resolveInfo.activityInfo); mResolveInfo = resolveInfo; } @@ -221,8 +237,11 @@ public abstract class TargetPresentationGetter { private final ActivityInfo mActivityInfo; ActivityInfoPresentationGetter( - Context context, int iconDpi, ActivityInfo activityInfo) { - super(context, iconDpi, activityInfo.applicationInfo); + Provider iconfactoryProvider, + PackageManager packageManager, + int iconDpi, + ActivityInfo activityInfo) { + super(iconfactoryProvider, packageManager, iconDpi, activityInfo.applicationInfo); mActivityInfo = activityInfo; } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index e80a4a7c..1ff1ddfa 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -16,7 +16,6 @@ package com.android.intentresolver.icons -import android.app.ActivityManager import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable @@ -30,6 +29,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.android.intentresolver.Flags.targetHoverAndKeyboardFocusStates import com.android.intentresolver.R +import com.android.intentresolver.SimpleIconFactory import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo @@ -40,6 +40,7 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ActivityContext import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer +import javax.inject.Provider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -50,14 +51,10 @@ class DefaultTargetDataLoader constructor( @ActivityContext private val context: Context, @ActivityOwned private val lifecycle: Lifecycle, + private val iconFactoryProvider: Provider, + private val presentationFactory: TargetPresentationGetter.Factory, @Assisted private val isAudioCaptureDevice: Boolean, ) : TargetDataLoader { - private val presentationFactory = - TargetPresentationGetter.Factory( - context, - context.getSystemService(ActivityManager::class.java)?.launcherLargeIconDensity - ?: error("Unable to access ActivityManager"), - ) private val nextTaskId = AtomicInteger(0) @GuardedBy("self") private val activeTasks = SparseArray>() private val executor = Dispatchers.IO.asExecutor() @@ -98,6 +95,7 @@ constructor( context.createContextAsUser(userHandle, 0), info, presentationFactory, + iconFactoryProvider, ) { bitmap -> removeTask(taskId) callback.accept(bitmap?.toDrawable() ?: loadIconPlaceholder()) diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index 641a0d6a..01f9330e 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -38,19 +38,24 @@ import com.android.intentresolver.util.UriFilters; import java.util.function.Consumer; +import javax.inject.Provider; + /** * Loads direct share targets icons. */ class LoadDirectShareIconTask extends BaseLoadIconTask { private static final String TAG = "DirectShareIconTask"; private final SelectableTargetInfo mTargetInfo; + private final Provider mIconFactoryProvider; LoadDirectShareIconTask( Context context, SelectableTargetInfo targetInfo, TargetPresentationGetter.Factory presentationFactory, + Provider iconFactoryProvider, Consumer callback) { super(context, presentationFactory, callback); + mIconFactoryProvider = iconFactoryProvider; mTargetInfo = targetInfo; } @@ -121,9 +126,10 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { Bitmap appIcon = mPresentationFactory.makePresentationGetter(info).getIconBitmap(null); // Raster target drawable with appIcon as a badge - SimpleIconFactory sif = SimpleIconFactory.obtain(context); - Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); - sif.recycle(); + Bitmap directShareBadgedIcon; + try (SimpleIconFactory sif = mIconFactoryProvider.get()) { + directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); + } return directShareBadgedIcon; } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt index 21ff654f..d6d4aae1 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -16,17 +16,38 @@ package com.android.intentresolver.icons +import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager +import com.android.intentresolver.SimpleIconFactory +import com.android.intentresolver.TargetPresentationGetter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Provider @Module @InstallIn(ActivityComponent::class) object TargetDataLoaderModule { + @Provides + fun simpleIconFactory(@ActivityContext context: Context): SimpleIconFactory = + SimpleIconFactory.obtain(context) + + @Provides + fun presentationGetterFactory( + iconFactoryProvider: Provider, + packageManager: PackageManager, + activityManager: ActivityManager, + ): TargetPresentationGetter.Factory = + TargetPresentationGetter.Factory( + iconFactoryProvider, + packageManager, + activityManager.launcherLargeIconDensity, + ) + @Provides @ActivityScoped @Caching diff --git a/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt index 92848b2c..b5b05eb9 100644 --- a/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt +++ b/tests/unit/src/com/android/intentresolver/TargetPresentationGetterTest.kt @@ -32,32 +32,42 @@ class TargetPresentationGetterTest { withSubstitutePermission: Boolean, appLabel: String, activityLabel: String, - resolveInfoLabel: String + resolveInfoLabel: String, ): TargetPresentationGetter { val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( withSubstitutePermission, appLabel, activityLabel, - resolveInfoLabel + resolveInfoLabel, + ) + val factory = + TargetPresentationGetter.Factory( + { SimpleIconFactory.obtain(testPackageInfo.ctx) }, + testPackageInfo.ctx.packageManager, + 100, ) - val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) return factory.makePresentationGetter(testPackageInfo.resolveInfo) } fun makeActivityInfoPresentationGetter( withSubstitutePermission: Boolean, appLabel: String?, - activityLabel: String? + activityLabel: String?, ): TargetPresentationGetter { val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( withSubstitutePermission, appLabel, activityLabel, - "" + "", + ) + val factory = + TargetPresentationGetter.Factory( + { SimpleIconFactory.obtain(testPackageInfo.ctx) }, + testPackageInfo.ctx.packageManager, + 100, ) - val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) return factory.makePresentationGetter(testPackageInfo.activityInfo) } @@ -158,7 +168,7 @@ class TargetPresentationGetterTest { false, "app_label", "activity_label", - "resolve_info_label" + "resolve_info_label", ) assertThat(presentationGetter.getLabel()).isEqualTo("app_label") assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") @@ -192,7 +202,7 @@ class TargetPresentationGetterTest { true, "app_label", "activity_label", - "resolve_info_label" + "resolve_info_label", ) assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") -- cgit v1.2.3-59-g8ed1b From 34f3b5d5992bf07674abb007a76f2b469e4e6ad8 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Tue, 5 Nov 2024 15:11:35 -0800 Subject: Reload targets by recreating adapters in response to target pinning. Use Shareousel's target list updating logic in response to target pinning and application changed broadcasts. Create new adapters and attach them to the views after data is loaded, resulting in a cleaner visual update. Fix: 230703572 Test: manual testing Test: atest IntentResolver-tests-unit Flag: com.android.intentresolver.rebuild_adapters_on_target_pinning Change-Id: I1e7e2fd820195134ec4940f2e76bf7b3eac9e086 --- aconfig/FeatureFlags.aconfig | 10 + .../android/intentresolver/ChooserActivity.java | 11 +- .../intentresolver/ShortcutSelectionLogic.java | 13 +- .../intentresolver/ShortcutSelectionLogicTest.kt | 203 ++++++++++++++------- 4 files changed, 160 insertions(+), 77 deletions(-) (limited to 'java/src') diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index c23b51ae..e2b2f57b 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -116,6 +116,16 @@ flag { } } +flag { + name: "rebuild_adapters_on_target_pinning" + namespace: "intentresolver" + description: "Rebuild and swap adapters when a target gets (un)pinned to avoid flickering." + bug: "230703572" + metadata { + purpose: PURPOSE_BUGFIX + } +} + flag { name: "target_hover_and_keyboard_focus_states" namespace: "intentresolver" diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 250edaf2..f7cc9291 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.fixShortcutsFlashing; import static com.android.intentresolver.Flags.keyboardNavigationFix; +import static com.android.intentresolver.Flags.rebuildAdaptersOnTargetPinning; import static com.android.intentresolver.Flags.shareouselUpdateExcludeComponentsExtra; import static com.android.intentresolver.Flags.unselectFinalItem; import static com.android.intentresolver.ext.CreationExtrasExtKt.replaceDefaultArgs; @@ -1545,10 +1546,14 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); - if (listAdapter == null) { - mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); + if (rebuildAdaptersOnTargetPinning()) { + recreatePagerAdapter(); } else { - listAdapter.handlePackagesChanged(); + if (listAdapter == null) { + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); + } else { + listAdapter.handlePackagesChanged(); + } } } diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 2d5ec451..3a1a51e3 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static com.android.intentresolver.Flags.rebuildAdaptersOnTargetPinning; + import android.app.prediction.AppTarget; import android.content.Context; import android.content.Intent; @@ -171,16 +173,21 @@ public class ShortcutSelectionLogic { List serviceTargets) { // Check for duplicates and abort if found - for (TargetInfo otherTargetInfo : serviceTargets) { + for (int i = 0; i < serviceTargets.size(); i++) { + TargetInfo otherTargetInfo = serviceTargets.get(i); if (chooserTargetInfo.isSimilar(otherTargetInfo)) { + if (rebuildAdaptersOnTargetPinning() + && chooserTargetInfo.isPinned() != otherTargetInfo.isPinned()) { + serviceTargets.set(i, chooserTargetInfo); + return true; + } return false; } } int currentSize = serviceTargets.size(); final float newScore = chooserTargetInfo.getModifiedScore(); - for (int i = 0; i < Math.min(currentSize, maxRankedTargets); - i++) { + for (int i = 0; i < Math.min(currentSize, maxRankedTargets); i++) { final TargetInfo serviceTarget = serviceTargets.get(i); if (serviceTarget == null) { serviceTargets.set(i, chooserTargetInfo); diff --git a/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt index e26dffb8..d591d928 100644 --- a/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ b/tests/unit/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt @@ -21,13 +21,18 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutInfo import android.os.UserHandle +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule import android.service.chooser.ChooserTarget import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry +import com.android.intentresolver.Flags.FLAG_REBUILD_ADAPTERS_ON_TARGET_PINNING import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.TargetInfo -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -36,10 +41,12 @@ private const val PACKAGE_A = "package.a" private const val PACKAGE_B = "package.b" private const val CLASS_NAME = "./MainActivity" +private val PERSONAL_USER_HANDLE: UserHandle = + InstrumentationRegistry.getInstrumentation().targetContext.user + @SmallTest class ShortcutSelectionLogicTest { - private val PERSONAL_USER_HANDLE: UserHandle = - InstrumentationRegistry.getInstrumentation().getTargetContext().getUser() + @get:Rule val flagRule = SetFlagsRule() private val packageTargets = HashMap>().apply { @@ -57,6 +64,14 @@ class ShortcutSelectionLogicTest { this[pkg] = targets } } + private val targetInfoChooserTargetCorrespondence = + Correspondence.from( + { actual, expected -> + actual.chooserTargetComponentName == expected.componentName && + actual.displayLabel == expected.title + }, + "", + ) private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( @@ -64,7 +79,7 @@ class ShortcutSelectionLogicTest { ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), "label", "extended info", - Intent() + Intent(), ) private val otherBaseDisplayInfo = @@ -73,7 +88,7 @@ class ShortcutSelectionLogicTest { ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE), "label 2", "extended info 2", - Intent() + Intent(), ) private operator fun Map>.get(pkg: String, idx: Int) = @@ -87,7 +102,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false + /* applySharingAppLimits = */ false, ) val isUpdated = @@ -102,15 +117,15 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2, sc1), - serviceResults, - "Two shortcuts are expected as we do not apply per-app shortcut limit" - ) + assertWithMessage("Updates are expected").that(isUpdated).isTrue() + assertWithMessage("Two shortcuts are expected as we do not apply per-app shortcut limit") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc2, sc1) + .inOrder() } @Test @@ -121,7 +136,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true + /* applySharingAppLimits = */ true, ) val isUpdated = @@ -136,15 +151,15 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2), - serviceResults, - "One shortcut is expected as we apply per-app shortcut limit" - ) + assertWithMessage("Updates are expected").that(isUpdated).isTrue() + assertWithMessage("One shortcut is expected as we apply per-app shortcut limit") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc2) + .inOrder() } @Test @@ -155,7 +170,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false + /* applySharingAppLimits = */ false, ) val isUpdated = @@ -170,15 +185,15 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 1, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2), - serviceResults, - "One shortcut is expected as we apply overall shortcut limit" - ) + assertWithMessage("Updates are expected").that(isUpdated).isTrue() + assertWithMessage("One shortcut is expected as we apply overall shortcut limit") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc2) + .inOrder() } @Test @@ -191,7 +206,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true + /* applySharingAppLimits = */ true, ) testSubject.addServiceResults( @@ -205,7 +220,7 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) testSubject.addServiceResults( /* origTarget = */ otherBaseDisplayInfo, @@ -218,14 +233,14 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertShortcutsInOrder( - listOf(pkgBsc2, pkgAsc2), - serviceResults, - "Two shortcuts are expected as we apply per-app shortcut limit" - ) + assertWithMessage("Two shortcuts are expected as we apply per-app shortcut limit") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(pkgBsc2, pkgAsc2) + .inOrder() } @Test @@ -236,7 +251,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false + /* applySharingAppLimits = */ false, ) val isUpdated = @@ -256,15 +271,15 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc1, sc2), - serviceResults, - "Two shortcuts are expected as we do not apply per-app shortcut limit" - ) + assertWithMessage("Updates are expected").that(isUpdated).isTrue() + assertWithMessage("Two shortcuts are expected as we do not apply per-app shortcut limit") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc1, sc2) + .inOrder() } @Test @@ -276,7 +291,7 @@ class ShortcutSelectionLogicTest { val testSubject = ShortcutSelectionLogic( /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true + /* applySharingAppLimits = */ true, ) val context = mock { on { packageManager } doReturn (mock()) } @@ -291,36 +306,82 @@ class ShortcutSelectionLogicTest { /* targetIntent = */ mock(), /* refererFillInIntent = */ mock(), /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults + /* serviceTargets = */ serviceResults, ) - assertShortcutsInOrder( - listOf(sc3, sc2), - serviceResults, - "At most two caller-provided shortcuts are allowed" - ) + assertWithMessage("At most two caller-provided shortcuts are allowed") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc3, sc2) + .inOrder() } - // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases - // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`. - private fun assertShortcutsInOrder( - expected: List, - actual: List, - msg: String? = "" - ) { - assertEquals(msg, expected.size, actual.size) - for (i in expected.indices) { - assertEquals( - "Unexpected item at position $i", - expected[i].componentName, - actual[i].chooserTargetComponentName + @Test + @EnableFlags(FLAG_REBUILD_ADAPTERS_ON_TARGET_PINNING) + fun addServiceResults_sameShortcutWithDifferentPinnedStatus_shortcutUpdated() { + val serviceResults = ArrayList() + val sc1 = + createChooserTarget( + title = "Shortcut", + score = 1f, + ComponentName(PACKAGE_A, CLASS_NAME), + PACKAGE_A.shortcutId(0), ) - assertEquals( - "Unexpected item at position $i", - expected[i].title, - actual[i].displayLabel + val sc2 = + createChooserTarget( + title = "Shortcut", + score = 1f, + ComponentName(PACKAGE_A, CLASS_NAME), + PACKAGE_A.shortcutId(0), ) - } + val testSubject = + ShortcutSelectionLogic( + /* maxShortcutTargetsPerApp = */ 1, + /* applySharingAppLimits = */ false, + ) + + testSubject.addServiceResults( + /* origTarget = */ baseDisplayInfo, + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ mapOf( + sc1 to createShortcutInfo(PACKAGE_A.shortcutId(1), sc1.componentName, 1) + ), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults, + ) + val isUpdated = + testSubject.addServiceResults( + /* origTarget = */ baseDisplayInfo, + /* origTargetScore = */ 0.1f, + /* targets = */ listOf(sc1), + /* isShortcutResult = */ true, + /* directShareToShortcutInfos = */ mapOf( + sc1 to + createShortcutInfo(PACKAGE_A.shortcutId(1), sc1.componentName, 1).apply { + addFlags(ShortcutInfo.FLAG_PINNED) + } + ), + /* directShareToAppTargets = */ emptyMap(), + /* userContext = */ mock(), + /* targetIntent = */ mock(), + /* refererFillInIntent = */ mock(), + /* maxRankedTargets = */ 4, + /* serviceTargets = */ serviceResults, + ) + + assertWithMessage("Updates are expected").that(isUpdated).isTrue() + assertWithMessage("Updated shortcut is expected") + .that(serviceResults) + .comparingElementsUsing(targetInfoChooserTargetCorrespondence) + .containsExactly(sc2) + .inOrder() + assertThat(serviceResults[0].isPinned).isTrue() } private fun String.shortcutId(id: Int) = "$this.$id" -- cgit v1.2.3-59-g8ed1b From a216f474176a1fb1157601cc0acdc11905af6b0c Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Thu, 7 Nov 2024 11:19:45 -0800 Subject: Apply window inset specing for new adapters Fix: 377897948 Test: manual Shareousel testing with 3-button system naviagation Flag: EXEMPT bug fix Change-Id: I74f20d91113bcc9ad038fa157e807f2354dc8b15 --- java/src/com/android/intentresolver/ChooserActivity.java | 3 +++ 1 file changed, 3 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index f7cc9291..54f575d7 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -839,6 +839,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } setTabsViewEnabled(false); + if (mSystemWindowInsets != null) { + applyFooterView(mSystemWindowInsets.bottom); + } } private void setTabsViewEnabled(boolean isEnabled) { -- cgit v1.2.3-59-g8ed1b From 83b40ed1e7031f59f57f20fbaca6561404118df3 Mon Sep 17 00:00:00 2001 From: Miranda Kephart Date: Mon, 21 Oct 2024 09:52:59 -0400 Subject: Send content URI from sharesheet Leaving out the content URIs means that the quick actions component is no longer able to match sharesheet actions with the screenshots that were taking, breaking screenshot quickshare functionality. Method used is the same as in the old version of the sharesheet. Bug: 340867497 Test: manual (verify that quick share option shows up in screenshots) Flag: EXEMPT (minor change, restoring previous functionality) Change-Id: Id0e6b0e02071aa8c6e0c969d3e5bd81bbc200552 --- .../ui/viewmodel/ChooserRequestReader.kt | 12 +- .../intentresolver/ui/viewmodel/IntentExt.kt | 58 +++++++ .../intentresolver/ui/viewmodel/IntentExtTest.kt | 174 +++++++++++++++++++++ 3 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt create mode 100644 tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt (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 1644e409..13de84b2 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -36,7 +36,6 @@ import android.content.Intent.EXTRA_TEXT import android.content.Intent.EXTRA_TITLE import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT -import android.content.IntentFilter import android.content.IntentSender import android.net.Uri import android.os.Bundle @@ -164,7 +163,7 @@ fun readChooserRequest( refinementIntentSender = refinementIntentSender, sharedText = sharedText, sharedTextTitle = sharedTextTitle, - shareTargetFilter = targetIntent.toShareTargetFilter(), + shareTargetFilter = targetIntent.createIntentFilter(), additionalContentUri = additionalContentUri, focusedItemPosition = focusedItemPos, contentTypeHint = contentTypeHint, @@ -180,12 +179,3 @@ fun Validation.readChooserActions(): List? = optional(array(EXTRA_CHOOSER_CUSTOM_ACTIONS)) ?.filter { hasValidIcon(it) } ?.take(MAX_CHOOSER_ACTIONS) - -private fun Intent.toShareTargetFilter(): IntentFilter? { - return type?.let { - IntentFilter().apply { - action?.also { addAction(it) } - addDataType(it) - } - } -} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt b/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt new file mode 100644 index 00000000..30f16d20 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/IntentExt.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 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.ui.viewmodel + +import android.content.Intent +import android.content.IntentFilter +import android.content.IntentFilter.MalformedMimeTypeException +import android.net.Uri +import android.os.PatternMatcher + +/** Collects Uris from standard locations within the Intent. */ +fun Intent.collectUris(): Set = buildSet { + data?.also { add(it) } + @Suppress("DEPRECATION") + when (val stream = extras?.get(Intent.EXTRA_STREAM)) { + is Uri -> add(stream) + is ArrayList<*> -> addAll(stream.mapNotNull { it as? Uri }) + else -> Unit + } + clipData?.apply { (0.. + type?.also { + try { + filter.addDataType(it) + } catch (_: MalformedMimeTypeException) { // ignore malformed type + } + } + action?.also { filter.addAction(it) } + uris.forEach(filter::addUri) + } +} diff --git a/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt b/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt new file mode 100644 index 00000000..8fc162ca --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/IntentExtTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (C) 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.ui.viewmodel + +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IntentExtTest { + + @Test + fun noActionOrUris() { + val intent = Intent() + + assertThat(intent.createIntentFilter()).isNull() + } + + @Test + fun uriInData() { + val intent = Intent(ACTION_SEND) + intent.setDataAndType( + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + "image/png", + ) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND) + assertThat(filter.schemesIterator().next()).isEqualTo("scheme1") + assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + } + + @Test + fun noAction() { + val intent = Intent() + intent.setDataAndType( + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + "image/png", + ) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.countActions()).isEqualTo(0) + assertThat(filter.schemesIterator().next()).isEqualTo("scheme1") + assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + } + + @Test + fun singleUriInExtraStream() { + val intent = Intent(ACTION_SEND) + intent.type = "image/png" + intent.putExtra( + EXTRA_STREAM, + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + ) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND) + assertThat(filter.schemesIterator().next()).isEqualTo("scheme1") + assertThat(filter.authoritiesIterator().next().host).isEqualTo("auth1") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + } + + @Test + fun uriInDataAndStream() { + val intent = Intent(ACTION_SEND) + intent.setDataAndType( + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + "image/png", + ) + + intent.putExtra( + EXTRA_STREAM, + Uri.Builder().scheme("scheme2").encodedAuthority("auth2").path("path2").build(), + ) + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND) + assertThat(filter.getDataScheme(0)).isEqualTo("scheme1") + assertThat(filter.getDataScheme(1)).isEqualTo("scheme2") + assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1") + assertThat(filter.getDataAuthority(1).host).isEqualTo("auth2") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + assertThat(filter.getDataPath(1).path).isEqualTo("/path2") + } + + @Test + fun multipleUris() { + val intent = Intent(ACTION_SEND) + intent.type = "image/png" + val uris = + arrayListOf( + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + Uri.Builder().scheme("scheme2").encodedAuthority("auth2").path("path2").build(), + ) + intent.putExtra(EXTRA_STREAM, uris) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND) + assertThat(filter.getDataScheme(0)).isEqualTo("scheme1") + assertThat(filter.getDataScheme(1)).isEqualTo("scheme2") + assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1") + assertThat(filter.getDataAuthority(1).host).isEqualTo("auth2") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + assertThat(filter.getDataPath(1).path).isEqualTo("/path2") + } + + @Test + fun multipleUrisWithNullValues() { + val intent = Intent(ACTION_SEND) + intent.type = "image/png" + val uris = + arrayListOf( + null, + Uri.Builder().scheme("scheme1").encodedAuthority("auth1").path("path1").build(), + null, + ) + intent.putExtra(EXTRA_STREAM, uris) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.dataTypes()[0]).isEqualTo("image/png") + assertThat(filter.actionsIterator().next()).isEqualTo(ACTION_SEND) + assertThat(filter.getDataScheme(0)).isEqualTo("scheme1") + assertThat(filter.getDataAuthority(0).host).isEqualTo("auth1") + assertThat(filter.getDataPath(0).path).isEqualTo("/path1") + } + + @Test + fun badMimeType() { + val intent = Intent(ACTION_SEND) + intent.type = "badType" + intent.putExtra( + EXTRA_STREAM, + Uri.Builder().scheme("scheme1").encodedAuthority("authority1").path("path1").build(), + ) + + val filter = intent.createIntentFilter() + + assertThat(filter).isNotNull() + assertThat(filter!!.countDataTypes()).isEqualTo(0) + } +} -- cgit v1.2.3-59-g8ed1b From 23f9e99c8929739d404427a783b38e4834d68c47 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Wed, 25 Sep 2024 14:53:20 -0400 Subject: Remove deduplication from Shareousel Test: atest com.android.intentresolver BUG: 351911089 Flag: EXEMPT bugfix Change-Id: I5eb5d93b61891e8f91928361f375b1ca6562f724 --- .../domain/interactor/CursorPreviewsInteractor.kt | 89 ++++++++++++------ .../domain/interactor/FetchPreviewsInteractor.kt | 18 +++- .../payloadtoggle/shared/model/PreviewKey.kt | 49 ++++++++++ .../payloadtoggle/shared/model/PreviewModel.kt | 5 +- .../ui/composable/ShareouselComposable.kt | 28 +++--- .../interactor/CursorPreviewsInteractorTest.kt | 39 ++++++-- .../interactor/FetchPreviewsInteractorTest.kt | 104 +++++++++++++-------- .../interactor/SelectablePreviewInteractorTest.kt | 5 + .../interactor/SelectablePreviewsInteractorTest.kt | 14 +++ .../domain/interactor/SelectionInteractorTest.kt | 15 ++- .../interactor/SetCursorPreviewsInteractorTest.kt | 6 +- .../ui/viewmodel/ShareouselViewModelTest.kt | 26 ++++-- 12 files changed, 291 insertions(+), 107 deletions(-) create mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt (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 7d658209..0e198f43 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 @@ -30,6 +30,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expa import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.inject.FocusedItemIndex import com.android.intentresolver.util.cursor.CursorView @@ -82,16 +83,19 @@ constructor( .toMap(ConcurrentHashMap()) val pagedCursor: PagedCursor = uriCursor.paged(pageSize) val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 + val state = loadToMaxPages( - initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords), + startPosition = startPosition, + initialState = readInitialState(startPosition, pagedCursor, unclaimedRecords), pagedCursor = pagedCursor, unclaimedRecords = unclaimedRecords, ) - processLoadRequests(state, pagedCursor, unclaimedRecords) + processLoadRequests(startPosition, state, pagedCursor, unclaimedRecords) } private suspend fun loadToMaxPages( + startPosition: Int, initialState: CursorWindow, pagedCursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, @@ -113,9 +117,10 @@ constructor( state = when { state.hasMoreLeft && loadedLeft < loadedRight -> - state.loadMoreLeft(pagedCursor, unclaimedRecords) - state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords) - else -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords) + state.hasMoreRight -> + state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords) + else -> state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords) } } return state @@ -123,6 +128,7 @@ constructor( /** Loop forever, processing any loading requests from the UI and updating local cache. */ private suspend fun processLoadRequests( + startPosition: Int, initialState: CursorWindow, pagedCursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, @@ -144,7 +150,13 @@ constructor( leftTriggerIndex = leftTriggerIndex, rightTriggerIndex = rightTriggerIndex, ) - state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) + state = + loadingState.handleOneLoadRequest( + startPosition, + state, + pagedCursor, + unclaimedRecords, + ) } } @@ -153,6 +165,7 @@ constructor( * with the loaded data incorporated. */ private suspend fun Flow.handleOneLoadRequest( + startPosition: Int, state: CursorWindow, pagedCursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, @@ -160,8 +173,10 @@ constructor( mapLatest { loadDirection -> loadDirection?.let { when (loadDirection) { - LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords) - LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords) + LoadDirection.Left -> + state.loadMoreLeft(startPosition, pagedCursor, unclaimedRecords) + LoadDirection.Right -> + state.loadMoreRight(startPosition, pagedCursor, unclaimedRecords) } } } @@ -169,12 +184,12 @@ constructor( .first() /** - * Returns the initial [CursorWindow], with a single page loaded that contains the given + * Returns the initial [CursorWindow], with a single page loaded that contains the * [startPosition]. */ private suspend fun readInitialState( - cursor: PagedCursor, startPosition: Int, + cursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { val startPageIdx = startPosition / pageSize @@ -184,13 +199,15 @@ constructor( if (!hasMoreLeft) { // First read the initial page; this might claim some unclaimed Uris val page = - cursor.getPageRows(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + cursor + .getPageRows(startPageIdx) + ?.toPage(startPosition, mutableMapOf(), unclaimedRecords) // Now that unclaimed Uris are up-to-date, add them first. putAllUnclaimedLeft(unclaimedRecords) // Then add the loaded page page?.let(::putAll) } else { - cursor.getPageRows(startPageIdx)?.toPage(this, unclaimedRecords) + cursor.getPageRows(startPageIdx)?.toPage(startPosition, this, unclaimedRecords) } // Finally, add the remainder of the unclaimed Uris. if (!hasMoreRight) { @@ -208,13 +225,14 @@ constructor( } private suspend fun CursorWindow.loadMoreRight( + startPosition: Int, cursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { val pageNum = lastLoadedPageNum + 1 val hasMoreRight = pageNum < cursor.count - 1 val newPage: PreviewMap = buildMap { - readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords) + readAndPutPage(startPosition, this@loadMoreRight, cursor, pageNum, unclaimedRecords) if (!hasMoreRight) { putAllUnclaimedRight(unclaimedRecords) } @@ -227,6 +245,7 @@ constructor( } private suspend fun CursorWindow.loadMoreLeft( + startPosition: Int, cursor: PagedCursor, unclaimedRecords: MutableUnclaimedMap, ): CursorWindow { @@ -235,13 +254,14 @@ constructor( val newPage: PreviewMap = buildMap { if (!hasMoreLeft) { // First read the page; this might claim some unclaimed Uris - val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + val page = + readPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords) // Now that unclaimed URIs are up-to-date, add them first putAllUnclaimedLeft(unclaimedRecords) // Then add the loaded page putAll(page) } else { - readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + readAndPutPage(startPosition, this@loadMoreLeft, cursor, pageNum, unclaimedRecords) } } return if (numLoadedPages < maxLoadedPages) { @@ -259,15 +279,17 @@ constructor( } private suspend fun readPage( + startPosition: Int, state: CursorWindow, pagedCursor: PagedCursor, pageNum: Int, unclaimedRecords: MutableUnclaimedMap, ): PreviewMap = - mutableMapOf() - .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + mutableMapOf() + .readAndPutPage(startPosition, state, pagedCursor, pageNum, unclaimedRecords) private suspend fun M.readAndPutPage( + startPosition: Int, state: CursorWindow, pagedCursor: PagedCursor, pageNum: Int, @@ -275,19 +297,23 @@ constructor( ): M = pagedCursor .getPageRows(pageNum) // TODO: what do we do if the load fails? - ?.filter { it.uri !in state.merged } - ?.toPage(this, unclaimedRecords) ?: this + ?.filter { PreviewKey.final(it.position - startPosition) !in state.merged } + ?.toPage(startPosition, this, unclaimedRecords) ?: this private suspend fun Sequence.toPage( + startPosition: Int, destination: M, unclaimedRecords: MutableUnclaimedMap, ): M = // Restrict parallelism so as to not overload the metadata reader; anecdotally, too // many parallel queries causes failures. - mapParallel(parallelism = 4) { row -> createPreviewModel(row, unclaimedRecords) } - .associateByTo(destination) { it.uri } + mapParallel(parallelism = 4) { row -> + createPreviewModel(startPosition, row, unclaimedRecords) + } + .associateByTo(destination) { it.key } private fun createPreviewModel( + startPosition: Int, row: CursorRow, unclaimedRecords: MutableUnclaimedMap, ): PreviewModel = @@ -298,6 +324,7 @@ constructor( row.previewSize ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } PreviewModel( + key = PreviewKey.final(row.position - startPosition), uri = row.uri, previewUri = metadata.previewUri, mimeType = metadata.mimeType, @@ -308,11 +335,9 @@ constructor( .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. + // 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) } } @@ -324,7 +349,7 @@ constructor( putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } } -private typealias CursorWindow = LoadedWindow +private typealias CursorWindow = LoadedWindow /** * Values from the initial selection set that have not yet appeared within the Cursor. These values @@ -336,9 +361,13 @@ private typealias UnclaimedMap = Map> /** Mutable version of [UnclaimedMap]. */ private typealias MutableUnclaimedMap = MutableMap> -private typealias MutablePreviewMap = MutableMap +private typealias UnkeyedMap = Map + +private typealias MutableUnkeyedMap = MutableMap + +private typealias MutablePreviewMap = MutableMap -private typealias PreviewMap = Map +private typealias PreviewMap = Map private fun M.putAllUnclaimedWhere( unclaimedRecords: UnclaimedMap, @@ -347,7 +376,7 @@ private fun M.putAllUnclaimedWhere( unclaimedRecords .asSequence() .filter { predicate(it.value.first) } - .map { it.key to it.value.second } + .map { (_, value) -> value.second.key to value.second } .toMap(this) private fun PagedCursor.getPageRows(pageNum: Int): Sequence? = diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt index 50086a23..1fd69351 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -22,6 +22,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.P import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.inject.ContentUris import com.android.intentresolver.inject.FocusedItemIndex @@ -64,6 +65,12 @@ constructor( .mapParallelIndexed(parallelism = 4) { index, uri -> val metadata = uriMetadataReader.getMetadata(uri) PreviewModel( + key = + if (index == focusedItemIdx) { + PreviewKey.final(0) + } else { + PreviewKey.temp(index) + }, uri = uri, previewUri = metadata.previewUri, mimeType = metadata.mimeType, @@ -71,11 +78,12 @@ constructor( metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f) } ?: 1f, - order = when { - index < focusedItemIdx -> Int.MIN_VALUE + index - index == focusedItemIdx -> 0 - else -> Int.MAX_VALUE - selectedItems.size + index + 1 - } + order = + when { + index < focusedItemIdx -> Int.MIN_VALUE + index + index == focusedItemIdx -> 0 + else -> Int.MAX_VALUE - selectedItems.size + index + 1 + }, ) } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt new file mode 100644 index 00000000..6b42133e --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewKey.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 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.payloadtoggle.shared.model + +/** Unique identifier for preview items. */ +sealed interface PreviewKey { + + private data class Temp(override val key: Int, override val isFinal: Boolean = false) : + PreviewKey + + private data class Final(override val key: Int, override val isFinal: Boolean = true) : + PreviewKey + + /** The identifier, must be unique among like keys types */ + val key: Int + /** Whether this key is final or temporary. */ + val isFinal: Boolean + + companion object { + /** + * Creates a temporary key. + * + * This is used for the initial preview items until final keys can be generated, at which + * point it is replaced with a final key. + */ + fun temp(key: Int): PreviewKey = Temp(key) + + /** + * Creates a final key. + * + * This is used for all preview items other than the initial preview items. + */ + fun final(key: Int): PreviewKey = Final(key) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt index 8a479156..d4df8a3a 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -20,6 +20,8 @@ import android.net.Uri /** An individual preview presented in Shareousel. */ data class PreviewModel( + /** Unique identifier for this model. */ + val key: PreviewKey, /** Uri for this item; if this preview is selected, this will be shared with the target app. */ val uri: Uri, /** Uri for the preview image. */ @@ -28,7 +30,8 @@ data class PreviewModel( val mimeType: String?, val aspectRatio: Float = 1f, /** - * Relative item position in the list that is used to determine items order in the target intent + * Relative item position in the list that is used to determine items order in the target + * intent. */ val order: Int, ) 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 4b87d227..eab04aab 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 @@ -96,7 +96,7 @@ private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - .padding(vertical = 16.dp), + .padding(vertical = 16.dp) ) { PreviewCarousel(keySet, viewModel) ActionCarousel(viewModel) @@ -105,10 +105,7 @@ private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { @OptIn(ExperimentalFoundationApi::class) @Composable -private fun PreviewCarousel( - previews: PreviewsModel, - viewModel: ShareouselViewModel, -) { +private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewModel) { var maxAspectRatio by remember { mutableStateOf(0f) } var viewportHeight by remember { mutableStateOf(0) } var viewportCenter by remember { mutableStateOf(0) } @@ -128,7 +125,7 @@ private fun PreviewCarousel( val maxAR = (maxItemWidth.toFloat() / placeable.height).coerceIn( 0f, - MAX_ASPECT_RATIO + MAX_ASPECT_RATIO, ) minItemWidth to maxAR } @@ -137,7 +134,7 @@ private fun PreviewCarousel( viewportHeight = placeable.height horizontalPadding = ((placeable.width - minItemWidth) / 2).toDp() layout(placeable.width, placeable.height) { placeable.place(0, 0) } - }, + } ) { if (maxAspectRatio <= 0 && previews.previewModels.isNotEmpty()) { // Do not compose the list until we know the viewport size @@ -148,7 +145,7 @@ private fun PreviewCarousel( val carouselState = rememberLazyListState( - prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }, + prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } ) LazyRow( @@ -157,7 +154,10 @@ private fun PreviewCarousel( contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding), modifier = Modifier.fillMaxSize().systemGestureExclusion(), ) { - itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + itemsIndexed( + items = previews.previewModels, + key = { _, model -> model.key.key to model.key.isFinal }, + ) { index, model -> val visibleItem by remember { derivedStateOf { carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } @@ -234,7 +234,7 @@ private fun PreviewCarousel( model, viewportHeight, previewIndex, - rememberCoroutineScope() + rememberCoroutineScope(), ), maxAspectRatio, ) @@ -279,7 +279,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio .toggleable( value = selected, onValueChange = { scope.launch { viewModel.setSelected(it) } }, - ) + ), ) { state -> val aspectRatio = minOf(maxAspectRatio, maxOf(MIN_ASPECT_RATIO, viewModel.aspectRatio)) if (state is ValueUpdate.Value) { @@ -304,7 +304,7 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio color = borderColor, shape = RoundedCornerShape(size = 12.dp), ) - } + }, ) } } else { @@ -355,7 +355,7 @@ private fun ActionCarousel(viewModel: ShareouselViewModel) { Image( icon = it, modifier = Modifier.size(16.dp), - colorFilter = ColorFilter.tint(LocalContentColor.current) + colorFilter = ColorFilter.tint(LocalContentColor.current), ) } } @@ -389,7 +389,7 @@ private fun ShareouselAction( AssistChipDefaults.assistChipColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, labelColor = MaterialTheme.colorScheme.onSurface, - leadingIconContentColor = MaterialTheme.colorScheme.onSurface + leadingIconContentColor = MaterialTheme.colorScheme.onSurface, ), modifier = modifier, ) 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 c4ba8105..f43f1467 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 @@ -34,6 +34,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.p import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.readSize import com.android.intentresolver.contentpreview.uriMetadataReader @@ -51,10 +52,10 @@ import org.junit.Test class CursorPreviewsInteractorTest { private fun runTestWithDeps( - initialSelection: Iterable = (1..2), - focusedItemIndex: Int = initialSelection.count() / 2, - cursor: Iterable = (0 until 4), - cursorStartPosition: Int = cursor.count() / 2, + initialSelection: Iterable, + focusedItemIndex: Int, + cursor: Iterable, + cursorStartPosition: Int, pageSize: Int = 16, maxLoadedPages: Int = 3, cursorSizes: Map = emptyMap(), @@ -81,6 +82,7 @@ class CursorPreviewsInteractorTest { block( TestDeps( initialSelection, + focusedItemIndex, cursor, cursorStartPosition, cursorSizes, @@ -92,6 +94,7 @@ class CursorPreviewsInteractorTest { private class TestDeps( initialSelectionRange: Iterable, + focusedItemIndex: Int, private val cursorRange: Iterable, private val cursorStartPosition: Int, private val cursorSizes: Map, @@ -117,14 +120,26 @@ class CursorPreviewsInteractorTest { } } val initialPreviews: List = - initialSelectionRange.map { i -> - PreviewModel(uri = uri(i), mimeType = "image/bitmap", order = i) + initialSelectionRange.mapIndexed { index, i -> + PreviewModel( + key = + if (index == focusedItemIndex) { + PreviewKey.final(0) + } else { + PreviewKey.temp(index) + }, + uri = uri(i), + mimeType = "image/bitmap", + order = i, + ) } } @Test fun initialCursorLoad() = runTestWithDeps( + initialSelection = (1..2), + focusedItemIndex = 1, cursor = (0 until 10), cursorStartPosition = 2, cursorSizes = mapOf(0 to (200 x 100)), @@ -143,6 +158,7 @@ class CursorPreviewsInteractorTest { .containsExactlyElementsIn( List(6) { PreviewModel( + key = PreviewKey.final((it - 2)), uri = Uri.fromParts("scheme$it", "ssp$it", "fragment$it"), mimeType = "image/bitmap", aspectRatio = @@ -168,7 +184,9 @@ class CursorPreviewsInteractorTest { fun loadMoreLeft_evictRight() = runTestWithDeps( initialSelection = listOf(24), + focusedItemIndex = 0, cursor = (0 until 48), + cursorStartPosition = 24, pageSize = 16, maxLoadedPages = 1, ) { deps -> @@ -201,7 +219,9 @@ class CursorPreviewsInteractorTest { fun loadMoreRight_evictLeft() = runTestWithDeps( initialSelection = listOf(24), + focusedItemIndex = 0, cursor = (0 until 48), + cursorStartPosition = 24, pageSize = 16, maxLoadedPages = 1, ) { deps -> @@ -233,7 +253,9 @@ class CursorPreviewsInteractorTest { fun noMoreRight_appendUnclaimedFromInitialSelection() = runTestWithDeps( initialSelection = listOf(24, 50), + focusedItemIndex = 0, cursor = listOf(24), + cursorStartPosition = 0, pageSize = 16, maxLoadedPages = 2, ) { deps -> @@ -255,7 +277,9 @@ class CursorPreviewsInteractorTest { fun noMoreLeft_appendUnclaimedFromInitialSelection() = runTestWithDeps( initialSelection = listOf(0, 24), + focusedItemIndex = 1, cursor = listOf(24), + cursorStartPosition = 0, pageSize = 16, maxLoadedPages = 2, ) { deps -> @@ -283,6 +307,7 @@ class CursorPreviewsInteractorTest { ) { deps -> previewSelectionsRepository.selections.value = PreviewModel( + key = PreviewKey.final(0), uri = uri(1), mimeType = "image/png", order = 0, @@ -296,6 +321,7 @@ class CursorPreviewsInteractorTest { assertThat(previewSelectionsRepository.selections.value.values) .containsExactly( PreviewModel( + key = PreviewKey.final(0), uri = uri(1), mimeType = "image/bitmap", order = 1, @@ -307,6 +333,7 @@ class CursorPreviewsInteractorTest { fun testReadFailedPages() = runTestWithDeps( initialSelection = listOf(4), + focusedItemIndex = 0, cursor = emptyList(), cursorStartPosition = 0, pageSize = 2, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt index 27c98dc0..09d254f3 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt @@ -30,6 +30,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.pay import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.contentpreview.uriMetadataReader @@ -50,10 +51,10 @@ import org.junit.Test class FetchPreviewsInteractorTest { private fun runTest( - initialSelection: Iterable = (1..2), - focusedItemIndex: Int = initialSelection.count() / 2, - cursor: Iterable = (0 until 4), - cursorStartPosition: Int = cursor.count() / 2, + initialSelection: Iterable, + focusedItemIndex: Int, + cursor: Iterable, + cursorStartPosition: Int, pageSize: Int = 16, maxLoadedPages: Int = 8, previewSizes: Map = emptyMap(), @@ -110,7 +111,11 @@ class FetchPreviewsInteractorTest { fun setsInitialPreviews() = runTest( initialSelection = (1..3), - previewSizes = mapOf(1 to Size(100, 50))) { + focusedItemIndex = 1, + cursor = (0 until 4), + cursorStartPosition = 1, + previewSizes = mapOf(1 to Size(100, 50)), + ) { backgroundScope.launch { fetchPreviewsInteractor.activate() } runCurrent() @@ -120,17 +125,20 @@ class FetchPreviewsInteractorTest { previewModels = listOf( PreviewModel( + key = PreviewKey.temp(0), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "image/bitmap", aspectRatio = 2f, order = Int.MIN_VALUE, ), PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", order = 0, ), PreviewModel( + key = PreviewKey.temp(2), uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), mimeType = "image/bitmap", order = Int.MAX_VALUE, @@ -146,48 +154,60 @@ class FetchPreviewsInteractorTest { } @Test - fun lookupCursorFromContentResolver() = runTest { - backgroundScope.launch { fetchPreviewsInteractor.activate() } - fakeCursorResolver.complete() - runCurrent() + fun lookupCursorFromContentResolver() = + runTest( + initialSelection = (1..2), + focusedItemIndex = 1, + cursor = (0 until 4), + cursorStartPosition = 2, + ) { + backgroundScope.launch { fetchPreviewsInteractor.activate() } + fakeCursorResolver.complete() + runCurrent() - with(cursorPreviewsRepository) { - assertThat(previewsModel.value).isNotNull() - assertThat(previewsModel.value!!.startIdx).isEqualTo(0) - assertThat(previewsModel.value!!.loadMoreLeft).isNull() - assertThat(previewsModel.value!!.loadMoreRight).isNull() - assertThat(previewsModel.value!!.previewModels) - .containsExactly( - PreviewModel( - uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), - mimeType = "image/bitmap", - order = 0, - ), - PreviewModel( - uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), - mimeType = "image/bitmap", - order = 1, - ), - PreviewModel( - uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), - mimeType = "image/bitmap", - order = 2, - ), - PreviewModel( - uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), - mimeType = "image/bitmap", - order = 3, - ), - ) - .inOrder() + with(cursorPreviewsRepository) { + assertThat(previewsModel.value).isNotNull() + assertThat(previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(previewsModel.value!!.loadMoreLeft).isNull() + assertThat(previewsModel.value!!.loadMoreRight).isNull() + assertThat(previewsModel.value!!.previewModels) + .containsExactly( + PreviewModel( + key = PreviewKey.final(-2), + uri = Uri.fromParts("scheme0", "ssp0", "fragment0"), + mimeType = "image/bitmap", + order = 0, + ), + PreviewModel( + key = PreviewKey.final(-1), + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "image/bitmap", + order = 1, + ), + PreviewModel( + key = PreviewKey.final(0), + uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), + mimeType = "image/bitmap", + order = 2, + ), + PreviewModel( + key = PreviewKey.final(1), + uri = Uri.fromParts("scheme3", "ssp3", "fragment3"), + mimeType = "image/bitmap", + order = 3, + ), + ) + .inOrder() + } } - } @Test fun loadMoreLeft_evictRight() = runTest( initialSelection = listOf(24), + focusedItemIndex = 0, cursor = (0 until 48), + cursorStartPosition = 24, pageSize = 16, maxLoadedPages = 1, ) { @@ -223,7 +243,9 @@ class FetchPreviewsInteractorTest { fun loadMoreRight_evictLeft() = runTest( initialSelection = listOf(24), + focusedItemIndex = 0, cursor = (0 until 48), + cursorStartPosition = 24, pageSize = 16, maxLoadedPages = 1, ) { @@ -254,7 +276,9 @@ class FetchPreviewsInteractorTest { fun noMoreRight_appendUnclaimedFromInitialSelection() = runTest( initialSelection = listOf(24, 50), + focusedItemIndex = 0, cursor = listOf(24), + cursorStartPosition = 0, pageSize = 16, maxLoadedPages = 2, ) { @@ -275,7 +299,9 @@ class FetchPreviewsInteractorTest { fun noMoreLeft_appendUnclaimedFromInitialSelection() = runTest( initialSelection = listOf(0, 24), + focusedItemIndex = 1, cursor = listOf(24), + cursorStartPosition = 0, pageSize = 16, maxLoadedPages = 2, ) { 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 5d9ddbb6..0268a4d5 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 @@ -24,6 +24,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.p import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.data.repository.chooserRequestRepository import com.android.intentresolver.logging.FakeEventLog @@ -44,6 +45,7 @@ class SelectablePreviewInteractorTest { SelectablePreviewInteractor( key = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -63,6 +65,7 @@ class SelectablePreviewInteractorTest { SelectablePreviewInteractor( key = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, @@ -75,6 +78,7 @@ class SelectablePreviewInteractorTest { previewSelectionsRepository.selections.value = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, @@ -93,6 +97,7 @@ class SelectablePreviewInteractorTest { SelectablePreviewInteractor( key = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt index c50d2d3f..c90a3091 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractorTest.kt @@ -23,6 +23,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.c import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.previewSelectionsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.targetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.util.runKosmosTest @@ -41,11 +42,13 @@ class SelectablePreviewsInteractorTest { previewModels = listOf( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, ), PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", order = 1, @@ -59,6 +62,7 @@ class SelectablePreviewsInteractorTest { ) previewSelectionsRepository.selections.value = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -72,11 +76,13 @@ class SelectablePreviewsInteractorTest { assertThat(keySet.value!!.previewModels) .containsExactly( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, ), PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", order = 1, @@ -90,6 +96,7 @@ class SelectablePreviewsInteractorTest { val firstModel = underTest.preview( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -100,6 +107,7 @@ class SelectablePreviewsInteractorTest { val secondModel = underTest.preview( PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null, order = 1, @@ -112,6 +120,7 @@ class SelectablePreviewsInteractorTest { fun keySet_reflectsRepositoryUpdate() = runKosmosTest { previewSelectionsRepository.selections.value = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -124,6 +133,7 @@ class SelectablePreviewsInteractorTest { val firstModel = underTest.preview( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -140,11 +150,13 @@ class SelectablePreviewsInteractorTest { previewModels = listOf( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, ), PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", order = 1, @@ -163,11 +175,13 @@ class SelectablePreviewsInteractorTest { assertThat(previews.value!!.previewModels) .containsExactly( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/bitmap", order = 0, ), PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = "image/bitmap", order = 1, 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 c8242333..c24138b8 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 @@ -24,6 +24,7 @@ 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.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.util.runKosmosTest import com.google.common.truth.Truth.assertThat @@ -39,9 +40,10 @@ class SelectionInteractorTest { fun singleSelection_removalPrevented() = runKosmosTest { val initialPreview = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, - order = 0 + order = 0, ) previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview) @@ -66,9 +68,10 @@ class SelectionInteractorTest { fun singleSelection_itemRemovedNoPendingIntentUpdates() = runKosmosTest { val initialPreview = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, - order = 0 + order = 0, ) previewSelectionsRepository.selections.value = mapOf(initialPreview.uri to initialPreview) @@ -92,15 +95,17 @@ class SelectionInteractorTest { fun multipleSelections_removalAllowed() = runKosmosTest { val first = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, - order = 0 + order = 0, ) val second = PreviewModel( + key = PreviewKey.final(2), uri = Uri.fromParts("scheme2", "ssp2", "fragment2"), mimeType = null, - order = 1 + order = 1, ) previewSelectionsRepository.selections.value = listOf(first, second).associateBy { it.uri } @@ -109,7 +114,7 @@ class SelectionInteractorTest { previewSelectionsRepository, { Intent() }, updateTargetIntentInteractor, - mimetypeClassifier + mimetypeClassifier, ) underTest.unselect(first) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt index 748459cb..42f1a1b2 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractorTest.kt @@ -21,6 +21,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.interacto import android.net.Uri import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.cursorPreviewsRepository import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.util.runKosmosTest import com.google.common.truth.Truth.assertThat @@ -37,6 +38,7 @@ class SetCursorPreviewsInteractorTest { previews = listOf( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, @@ -59,9 +61,10 @@ class SetCursorPreviewsInteractorTest { assertThat(it.previewModels) .containsExactly( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, - order = 0 + order = 0, ) ) .inOrder() @@ -76,6 +79,7 @@ class SetCursorPreviewsInteractorTest { previews = listOf( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, 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 fc7ac751..6dd96040 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 @@ -42,6 +42,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewKey import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.data.model.ChooserRequest @@ -84,15 +85,17 @@ class ShareouselViewModelTest { previewSelectionsRepository.selections.value = listOf( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", order = 0, ), PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "image/jpeg", order = 1, - ) + ), ) .associateBy { it.uri } runCurrent() @@ -104,15 +107,17 @@ class ShareouselViewModelTest { previewSelectionsRepository.selections.value = listOf( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "video/mpeg", order = 0, ), PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", order = 1, - ) + ), ) .associateBy { it.uri } runCurrent() @@ -124,15 +129,17 @@ class ShareouselViewModelTest { previewSelectionsRepository.selections.value = listOf( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/jpeg", order = 0, ), PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", order = 1, - ) + ), ) .associateBy { it.uri } runCurrent() @@ -145,7 +152,7 @@ class ShareouselViewModelTest { ChooserRequest( targetIntent = Intent(), launchedFromPackage = "", - metadataText = "Hello" + metadataText = "Hello", ) chooserRequestRepository.chooserRequest.value = request @@ -162,15 +169,17 @@ class ShareouselViewModelTest { previewModels = listOf( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", order = 0, ), PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", order = 1, - ) + ), ), startIdx = 1, loadMoreLeft = null, @@ -194,6 +203,7 @@ class ShareouselViewModelTest { val previewVm = shareouselViewModel.preview.invoke( PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", order = 0, @@ -225,15 +235,17 @@ class ShareouselViewModelTest { previewModels = listOf( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "image/png", order = 0, ), PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), mimeType = "video/mpeg", order = 1, - ) + ), ), startIdx = 1, loadMoreLeft = null, @@ -246,6 +258,7 @@ class ShareouselViewModelTest { val previewVm = shareouselViewModel.preview.invoke( PreviewModel( + key = PreviewKey.final(0), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = "video/mpeg", order = 1, @@ -314,6 +327,7 @@ class ShareouselViewModelTest { this.targetIntentModifier = targetIntentModifier previewSelectionsRepository.selections.value = PreviewModel( + key = PreviewKey.final(1), uri = Uri.fromParts("scheme", "ssp", "fragment"), mimeType = null, order = 0, -- cgit v1.2.3-59-g8ed1b From b24a2c4f0110afec2b6f1ba6ff06dc8a6b82a716 Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Mon, 4 Nov 2024 15:02:38 -0500 Subject: Fix Shareousel not always centering initial selection Makes starting index correctly update as pages load/unload. This allows us to correctly initialize the scroll without using a LaunchedEffect. Also consolidates measurement-related logic into a helper class for consistency. Test: atest com.android.intentresolver Test: manual test using Sharetest with 5 images and selected index 3 or 4 BUG: 351911089 FIX: 351911089 flag: EXEMPT Bugfix Change-Id: I5a10b5d50f393958b3574bf0a7742b5af93d4a67 --- .../domain/interactor/CursorPreviewsInteractor.kt | 5 +- .../payloadtoggle/domain/model/LoadedWindow.kt | 6 + .../ui/composable/ShareouselComposable.kt | 151 ++++++++++++--------- .../interactor/CursorPreviewsInteractorTest.kt | 2 +- .../interactor/FetchPreviewsInteractorTest.kt | 2 +- 5 files changed, 99 insertions(+), 67 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 0e198f43..59e7e15e 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 @@ -106,7 +106,7 @@ constructor( val (leftTriggerIndex, rightTriggerIndex) = state.triggerIndices() interactor.setPreviews( previews = state.merged.values.toList(), - startIndex = startPageNum, + startIndex = state.startIndex, hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, leftTriggerIndex = leftTriggerIndex, @@ -144,7 +144,7 @@ constructor( val loadingState: Flow = interactor.setPreviews( previews = state.merged.values.toList(), - startIndex = 0, // TODO: actually track this as the window changes? + startIndex = state.startIndex, hasMoreLeft = state.hasMoreLeft, hasMoreRight = state.hasMoreRight, leftTriggerIndex = leftTriggerIndex, @@ -215,6 +215,7 @@ constructor( } } return CursorWindow( + startIndex = startPosition % pageSize, firstLoadedPageNum = startPageIdx, lastLoadedPageNum = startPageIdx, pages = listOf(page.keys), diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt index e2e69852..5e34b178 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt @@ -18,6 +18,8 @@ package com.android.intentresolver.contentpreview.payloadtoggle.domain.model /** A window of data loaded from a cursor. */ data class LoadedWindow( + /** The index position of the item that should be displayed initially. */ + val startIndex: Int, /** First cursor page index loaded within this window. */ val firstLoadedPageNum: Int, /** Last cursor page index loaded within this window. */ @@ -42,6 +44,7 @@ fun LoadedWindow.shiftWindowRight( hasMore: Boolean, ): LoadedWindow = LoadedWindow( + startIndex = startIndex - newPage.size, firstLoadedPageNum = firstLoadedPageNum + 1, lastLoadedPageNum = lastLoadedPageNum + 1, pages = pages.drop(1) + listOf(newPage.keys), @@ -61,6 +64,7 @@ fun LoadedWindow.expandWindowRight( hasMore: Boolean, ): LoadedWindow = LoadedWindow( + startIndex = startIndex, firstLoadedPageNum = firstLoadedPageNum, lastLoadedPageNum = lastLoadedPageNum + 1, pages = pages + listOf(newPage.keys), @@ -75,6 +79,7 @@ fun LoadedWindow.shiftWindowLeft( hasMore: Boolean, ): LoadedWindow = LoadedWindow( + startIndex = startIndex + newPage.size, firstLoadedPageNum = firstLoadedPageNum - 1, lastLoadedPageNum = lastLoadedPageNum - 1, pages = listOf(newPage.keys) + pages.dropLast(1), @@ -93,6 +98,7 @@ fun LoadedWindow.expandWindowLeft( hasMore: Boolean, ): LoadedWindow = LoadedWindow( + startIndex = startIndex + newPage.size, firstLoadedPageNum = firstLoadedPageNum - 1, lastLoadedPageNum = lastLoadedPageNum, pages = listOf(newPage.keys) + pages, 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 eab04aab..5b368084 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 @@ -57,11 +57,14 @@ 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.MeasureScope +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layout import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections @@ -70,11 +73,13 @@ import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel 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 kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -106,52 +111,43 @@ private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewModel) { - var maxAspectRatio by remember { mutableStateOf(0f) } - var viewportHeight by remember { mutableStateOf(0) } - var viewportCenter by remember { mutableStateOf(0) } - var horizontalPadding by remember { mutableStateOf(0.dp) } + var measurements by remember { mutableStateOf(PreviewCarouselMeasurements.UNMEASURED) } Box( modifier = Modifier.fillMaxWidth() .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - val (minItemWidth, maxAR) = + measurements = if (placeable.height <= 0) { - 0f to 0f + PreviewCarouselMeasurements.UNMEASURED } else { - 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 + PreviewCarouselMeasurements(placeable, measureScope = this) } - 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 && previews.previewModels.isNotEmpty()) { - // Do not compose the list until we know the viewport size - return@Box - } - - var firstSelectedIndex by remember { mutableStateOf(null as Int?) } + // Do not compose the list until we have measured values + if (measurements == PreviewCarouselMeasurements.UNMEASURED) return@Box val carouselState = rememberLazyListState( - prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } + prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }, + initialFirstVisibleItemIndex = previews.startIdx, + initialFirstVisibleItemScrollOffset = + measurements.scrollOffsetToCenter( + previewModel = previews.previewModels[previews.startIdx] + ), ) LazyRow( state = carouselState, horizontalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding), + contentPadding = + PaddingValues( + start = measurements.horizontalPaddingDp, + end = measurements.horizontalPaddingDp, + ), modifier = Modifier.fillMaxSize().systemGestureExclusion(), ) { itemsIndexed( @@ -171,7 +167,7 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo val halfPreviewWidth = it.size / 2 val previewCenter = it.offset + halfPreviewWidth val previewDistanceToViewportCenter = - abs(previewCenter - viewportCenter) + abs(previewCenter - measurements.viewportCenterPx) if (previewDistanceToViewportCenter <= halfPreviewWidth) { index } else { @@ -182,13 +178,12 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo } 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) - } + viewModel.preview( + /* key = */ model, + /* previewHeight = */ measurements.viewportHeightPx, + /* index = */ previewIndex, + /* scope = */ rememberCoroutineScope(), + ) if (shareouselScrollOffscreenSelections()) { LaunchedEffect(index, model.uri) { @@ -209,10 +204,10 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo when { // Item is partially past start of viewport item.offset < viewportStartOffset -> - -viewportStartOffset + measurements.scrollOffsetToStartEdge() // Item is partially past end of viewport (item.offset + item.size) > viewportEndOffset -> - item.size - viewportEndOffset + measurements.scrollOffsetToEndEdge(model) // Item is fully within viewport else -> null }?.let { scrollOffset -> @@ -230,29 +225,8 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo } ShareouselCard( - viewModel.preview( - model, - viewportHeight, - previewIndex, - rememberCoroutineScope(), - ), - maxAspectRatio, - ) - } - } - - 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, + viewModel = previewModel, + aspectRatio = measurements.coerceAspectRatio(previewModel.aspectRatio), ) } } @@ -260,7 +234,7 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo } @Composable -private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio: Float) { +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, aspectRatio: Float) { val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary @@ -281,7 +255,6 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, maxAspectRatio onValueChange = { scope.launch { viewModel.setSelected(it) } }, ), ) { state -> - val aspectRatio = minOf(maxAspectRatio, maxOf(MIN_ASPECT_RATIO, viewModel.aspectRatio)) if (state is ValueUpdate.Value) { state.getOrDefault(null).let { bitmap -> ShareouselCard( @@ -398,5 +371,57 @@ private fun ShareouselAction( inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = if (condition) this.then(factory()) else this -private const val MIN_ASPECT_RATIO = 0.4f -private const val MAX_ASPECT_RATIO = 2.5f +private data class PreviewCarouselMeasurements( + val viewportHeightPx: Int, + val viewportWidthPx: Int, + val viewportCenterPx: Int = viewportWidthPx / 2, + val maxAspectRatio: Float, + val horizontalPaddingPx: Int, + val horizontalPaddingDp: Dp, +) { + constructor( + placeable: Placeable, + measureScope: MeasureScope, + horizontalPadding: Float = (placeable.width - (MIN_ASPECT_RATIO * placeable.height)) / 2, + ) : this( + viewportHeightPx = placeable.height, + viewportWidthPx = placeable.width, + maxAspectRatio = + with(measureScope) { + min( + (placeable.width - 32.dp.roundToPx()).toFloat() / placeable.height, + MAX_ASPECT_RATIO, + ) + }, + horizontalPaddingPx = horizontalPadding.roundToInt(), + horizontalPaddingDp = with(measureScope) { horizontalPadding.toDp() }, + ) + + fun coerceAspectRatio(ratio: Float): Float = ratio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio) + + fun scrollOffsetToCenter(previewModel: PreviewModel): Int = + horizontalPaddingPx + (aspectRatioToWidthPx(previewModel.aspectRatio) / 2) - + viewportCenterPx + + fun scrollOffsetToStartEdge(): Int = horizontalPaddingPx + + fun scrollOffsetToEndEdge(previewModel: PreviewModel): Int = + horizontalPaddingPx + aspectRatioToWidthPx(previewModel.aspectRatio) - viewportWidthPx + + private fun aspectRatioToWidthPx(ratio: Float): Int = + (coerceAspectRatio(ratio) * viewportHeightPx).roundToInt() + + companion object { + private const val MIN_ASPECT_RATIO = 0.4f + private const val MAX_ASPECT_RATIO = 2.5f + + val UNMEASURED = + PreviewCarouselMeasurements( + viewportHeightPx = 0, + viewportWidthPx = 0, + maxAspectRatio = 0f, + horizontalPaddingPx = 0, + horizontalPaddingDp = 0.dp, + ) + } +} 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 f43f1467..5d29b4f3 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 @@ -172,7 +172,7 @@ class CursorPreviewsInteractorTest { } ) .inOrder() - assertThat(startIdx).isEqualTo(0) + assertThat(startIdx).isEqualTo(2) assertThat(loadMoreLeft).isNull() assertThat(loadMoreRight).isNotNull() assertThat(leftTriggerIndex).isEqualTo(2) diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt index 09d254f3..0a56a2d0 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractorTest.kt @@ -167,7 +167,7 @@ class FetchPreviewsInteractorTest { with(cursorPreviewsRepository) { assertThat(previewsModel.value).isNotNull() - assertThat(previewsModel.value!!.startIdx).isEqualTo(0) + assertThat(previewsModel.value!!.startIdx).isEqualTo(2) assertThat(previewsModel.value!!.loadMoreLeft).isNull() assertThat(previewsModel.value!!.loadMoreRight).isNull() assertThat(previewsModel.value!!.previewModels) -- cgit v1.2.3-59-g8ed1b From 7cdcdaae7627e4708076b759800132435dbfcc6e Mon Sep 17 00:00:00 2001 From: Nan Wu Date: Thu, 26 Sep 2024 18:11:48 +0000 Subject: ChooserActivity refreshes creator token ChooserActivity has to refresh creator tokens for the intent it launches. Bug: 369856138 Test: Manual test Flag: android.security.prevent_intent_redirect Change-Id: I1874c2197577939ee60c4c058366ba8c7aac23ca --- .../intentresolver/chooser/DisplayResolveInfo.java | 2 ++ .../intentresolver/chooser/SelectableTargetInfo.java | 1 + .../android/intentresolver/chooser/TargetInfo.java | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index f0674a27..e641944e 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -205,6 +205,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public boolean startAsCaller(Activity activity, Bundle options, int userId) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); + TargetInfo.refreshIntentCreatorToken(mResolvedIntent); activity.startActivityAsCaller(mResolvedIntent, options, false, userId); return true; } @@ -212,6 +213,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { TargetInfo.prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + TargetInfo.refreshIntentCreatorToken(mResolvedIntent); // TODO: is this equivalent to `startActivityAsCaller` with `ignoreTargetSecurity=true`? If // so, we can consolidate on the one API method to show that this flag is the only // distinction between `startAsCaller` and `startAsUser`. We can even bake that flag into diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index c4aa9021..2658f3e5 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -229,6 +229,7 @@ public final class SelectableTargetInfo extends ChooserTargetInfo { intent.setComponent(getChooserTargetComponentName()); intent.putExtras(mChooserTargetIntentExtras); TargetInfo.prepareIntentForCrossProfileLaunch(intent, userId); + TargetInfo.refreshIntentCreatorToken(intent); // Important: we will ignore the target security checks in ActivityManager if and // only if the ChooserTarget's target package is the same package where we got the diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index e5f40001..0935c6e8 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,7 +17,10 @@ package com.android.intentresolver.chooser; +import static android.security.Flags.preventIntentRedirect; + import android.app.Activity; +import android.app.ActivityManager; import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; @@ -28,6 +31,7 @@ import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.os.RemoteException; import android.os.UserHandle; import android.service.chooser.ChooserTarget; import android.text.TextUtils; @@ -462,6 +466,22 @@ public interface TargetInfo { } } + /** + * refreshes intent's creatorToken with its current intent key fields. This allows + * ChooserActivity to still keep original creatorToken's creator uid after making changes to + * the intent and still keep it valid. + * @param intent the intent's creatorToken needs to up refreshed. + */ + static void refreshIntentCreatorToken(Intent intent) { + if (!preventIntentRedirect()) return; + try { + intent.setCreatorToken(ActivityManager.getService().refreshIntentCreatorToken( + intent.cloneForCreatorToken())); + } catch (RemoteException e) { + throw new RuntimeException("Failure from system", e); + } + } + /** * Derive a "complete" intent from a proposed `refinement` intent by merging it into a matching * `base` intent, without modifying the filter-equality properties of the `base` intent, while -- cgit v1.2.3-59-g8ed1b From 8046148e83998422fc4555757e6e03f4659027c3 Mon Sep 17 00:00:00 2001 From: Joshua Trask Date: Fri, 22 Nov 2024 18:19:08 +0000 Subject: Skip "last-chosen" query for Chooser `getLastChosen()` is invoked in the course of the generic/"base-class" `ResolverListAdapter::rebuildList()`, and the result is cached as `mLastChosen`. By inspection, `mLastChosen` is *only* ever read by `ResolverListAdapter` methods that are gated on `mFilterLastUsed` (except when it's used internally to derive `mLastChosenPosition`, but that field is *also* only used in conjunction with `mFilterLastUsed`). `mFilterLastUsed` is final and false in all instances of the `ChooserListAdapter` subclass (i.e., any `ChooserActivity` configuration): go/chooser-mfilterlastused-false. In some cases (for unknown reason, b/335196436), the call to `getLastChosen()` crashes in Chooser; this CL should safely workaround that problem. Even if it wasn't failing, we could expect some performance boost by omitting the unnecessary system call. Note that Chooser configurations will still call `setLastChosen()` (via the base `ResolverActivity::onTargetSelected()`) to *write* this setting in the system, so it'll still be reflected in subsequent *ResolverActivity* sessions -- it's just never *read* in Chooser. Writing from Chooser probably has only "marginal" user value, but there's no need to change that legacy behavior at this time. Test: existing `IntentForwarder` & CTS tests. Bug: 335196436 Flag: EXEMPT bugfix Change-Id: Ieaf4bc713d51494a6a3ada89810f232b8875108f --- .../android/intentresolver/ResolverListAdapter.java | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index fc5514b6..f29553eb 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -404,14 +404,18 @@ public class ResolverListAdapter extends BaseAdapter { ); } else { mOtherProfile = null; - try { - mLastChosen = mResolverListController.getLastChosen(); - // TODO: does this also somehow need to update mLastChosenPosition? If so, maybe - // the current method should also take responsibility for re-initializing - // mLastChosenPosition, where it's currently done at the start of rebuildList()? - // (Why is this related to the presence of mOtherProfile in fhe first place?) - } catch (RemoteException re) { - Log.d(TAG, "Error calling getLastChosenActivity\n" + re); + // If `mFilterLastUsed` is (`final`) false, we'll never read `mLastChosen`, so don't + // bother making the system query. + if (mFilterLastUsed) { + try { + mLastChosen = mResolverListController.getLastChosen(); + // TODO: does this also somehow need to update mLastChosenPosition? If so, maybe + // the current method should also take responsibility for re-initializing + // mLastChosenPosition, where it's currently done at the start of rebuildList()? + // (Why is this related to the presence of mOtherProfile in fhe first place?) + } catch (RemoteException re) { + Log.d(TAG, "Error calling getLastChosenActivity\n" + re); + } } } } -- cgit v1.2.3-59-g8ed1b From 509339c75e905dd0bb2eb52cdd85fa54d2bca098 Mon Sep 17 00:00:00 2001 From: Andrey Yepin Date: Wed, 27 Nov 2024 17:10:23 -0800 Subject: Add a11y role to the text preview copy action widget Fix: 377643104 Test: manual testing Flag: EXEMPT bug fix Change-Id: Id8454df01e4ca891f85ba8e1ff3979156673ed3d --- .../contentpreview/TextContentPreviewUi.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index b12eb8cf..45a0130d 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -30,10 +30,12 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; +import com.android.intentresolver.widget.ViewRoleDescriptionAccessibilityDelegate; import kotlinx.coroutines.CoroutineScope; @@ -138,10 +140,17 @@ class TextContentPreviewUi extends ContentPreviewUi { Runnable onCopy = mActionFactory.getCopyButtonRunnable(); View copyButton = contentPreviewLayout.findViewById(R.id.copy); - if (onCopy != null) { - copyButton.setOnClickListener((v) -> onCopy.run()); - } else { - copyButton.setVisibility(View.GONE); + if (copyButton != null) { + if (onCopy != null) { + copyButton.setOnClickListener((v) -> onCopy.run()); + ViewCompat.setAccessibilityDelegate( + copyButton, + new ViewRoleDescriptionAccessibilityDelegate( + layoutInflater.getContext() + .getString(R.string.role_description_button))); + } else { + copyButton.setVisibility(View.GONE); + } } String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM) -- cgit v1.2.3-59-g8ed1b From 3826f464817b94d988e37ee81912665fd93d8bed Mon Sep 17 00:00:00 2001 From: Govinda Wasserman Date: Fri, 13 Dec 2024 19:34:20 -0500 Subject: Fix Shareousel crash when recomposing Recomposition could cause a crash when initial position was no longer in the LazyRow. This fixes it by encapsulating the problematic array indexing within the remember block so that it is only performed on the first composition when it is guaranteed to be a valid index position. Test: Manual test using ShareTest BUG: 383852288 Flag: EXEMPT Bugfix (cherry picked from https://googleplex-android-review.googlesource.com/q/commit:5fd6cf8ddc2025504e322b3bb1174556a200d33b) Merged-In: Ie6b322d449ddc5c83d9d4e481967c2511b165290 Change-Id: Ie6b322d449ddc5c83d9d4e481967c2511b165290 --- .../payloadtoggle/ui/composable/ShareouselComposable.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 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 5b368084..c51021a8 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 @@ -33,9 +33,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.systemGestureExclusion @@ -130,15 +130,17 @@ private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewMo // Do not compose the list until we have measured values if (measurements == PreviewCarouselMeasurements.UNMEASURED) return@Box - val carouselState = - rememberLazyListState( - prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }, - initialFirstVisibleItemIndex = previews.startIdx, - initialFirstVisibleItemScrollOffset = + val prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() } + val carouselState = remember { + LazyListState( + prefetchStrategy = prefetchStrategy, + firstVisibleItemIndex = previews.startIdx, + firstVisibleItemScrollOffset = measurements.scrollOffsetToCenter( previewModel = previews.previewModels[previews.startIdx] ), ) + } LazyRow( state = carouselState, -- cgit v1.2.3-59-g8ed1b