From 118a77b69fe8ffa8db0dbda7a636b7d7046f4f54 Mon Sep 17 00:00:00 2001 From: Andrey Epin Date: Mon, 11 Mar 2024 08:01:12 -0700 Subject: Update app targets on payload selection change ShareouselContentPreviewViewModel is merged into ChooserViewModel (to make all shareousel injectable available in ChooserViewModel without changing their scope). The shareousel machinery is initialized lazily to match the legacy behavior. PreviewSelectionRepository now publish selections only after the initial value has been read (vs. starting with the empty selection) to avoid triggering unnecessary targets resolution. A new interactor is added that updates ChooserRequest upon changes in the target request repository. Bug: 302691505 Test: manual functinality test with and without payload toggling flag Test: atest IntentResolver-tests-unit Test: atest IntentResolver-tests-activity Change-Id: I2f3d4f636e8ebe4a003e955cb5f089c28f2a7f1d --- .../contentpreview/ShareouselContentPreviewUi.kt | 6 +- .../viewmodel/ShareouselContentPreviewViewModel.kt | 46 ------------ .../data/repository/PreviewSelectionsRepository.kt | 34 ++++++++- .../domain/interactor/FetchPreviewsInteractor.kt | 2 +- .../interactor/SelectablePreviewInteractor.kt | 5 +- .../interactor/UpdateTargetIntentInteractor.kt | 1 - .../android/intentresolver/v2/ChooserActivity.java | 70 +++++++++++++++++- .../com/android/intentresolver/v2/ChooserHelper.kt | 10 +++ .../interactor/ChooserRequestUpdateInteractor.kt | 82 ++++++++++++++++++++++ .../v2/profiles/MultiProfilePagerAdapter.java | 1 + .../v2/ui/viewmodel/ChooserViewModel.kt | 29 +++++++- 11 files changed, 229 insertions(+), 57 deletions(-) delete mode 100644 java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt create mode 100644 java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt index 80f7c25a..463da5fa 100644 --- a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -31,9 +31,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.android.intentresolver.R import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.contentpreview.payloadtoggle.app.viewmodel.ShareouselContentPreviewViewModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class ShareouselContentPreviewUi( @@ -58,8 +58,8 @@ class ShareouselContentPreviewUi( } return ComposeView(parent.context).apply { setContent { - val vm: ShareouselContentPreviewViewModel = viewModel() - val viewModel: ShareouselViewModel = vm.viewModel + val vm: ChooserViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.shareouselViewModel headlineViewParent?.let { LaunchedEffect(viewModel) { bindHeadline(viewModel, headlineViewParent) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt deleted file mode 100644 index 479f0ec8..00000000 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/app/viewmodel/ShareouselContentPreviewViewModel.kt +++ /dev/null @@ -1,46 +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.contentpreview.payloadtoggle.app.viewmodel - -import androidx.lifecycle.ViewModel -import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor -import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor -import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ViewModelOwned -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** View-model for [com.android.intentresolver.contentpreview.ShareouselContentPreviewUi]. */ -@HiltViewModel -class ShareouselContentPreviewViewModel -@Inject -constructor( - val viewModel: ShareouselViewModel, - updateTargetIntentInteractor: UpdateTargetIntentInteractor, - fetchPreviewsInteractor: FetchPreviewsInteractor, - @Background private val bgDispatcher: CoroutineDispatcher, - @ViewModelOwned private val scope: CoroutineScope, -) : ViewModel() { - init { - scope.launch(bgDispatcher) { updateTargetIntentInteractor.launch() } - scope.launch(bgDispatcher) { fetchPreviewsInteractor.launch() } - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt index 8035580d..2d849d14 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -16,14 +16,46 @@ package com.android.intentresolver.contentpreview.payloadtoggle.data.repository +import android.util.Log import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update + +private const val TAG = "PreviewSelectionsRep" /** Stores set of selected previews. */ @ViewModelScoped class PreviewSelectionsRepository @Inject constructor() { /** Set of selected previews. */ - val selections = MutableStateFlow>(emptySet()) + private val _selections = MutableStateFlow?>(null) + + val selections: Flow> = _selections.filterNotNull() + + fun setSelection(selection: Set) { + _selections.value = selection + } + + fun select(item: PreviewModel) { + _selections.update { selection -> + selection?.let { it + item } + ?: run { + Log.w(TAG, "Changing selection before it is initialized") + null + } + } + } + + fun unselect(item: PreviewModel) { + _selections.update { selection -> + selection?.let { it - item } + ?: run { + Log.w(TAG, "Changing selection before it is initialized") + null + } + } + } } 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 032692cd..a7749c92 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 @@ -44,7 +44,7 @@ constructor( suspend fun launch() = coroutineScope { val cursor = async { cursorResolver.getCursor() } val initialPreviewMap: Set = getInitialPreviews() - selectionRepository.selections.value = initialPreviewMap + selectionRepository.setSelection(initialPreviewMap) setCursorPreviews.setPreviews( previewsByKey = initialPreviewMap, startIndex = focusedItemIdx, diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt index d94b1078..0b1038f5 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -21,7 +21,6 @@ import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.P import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update /** An individual preview in Shareousel. */ class SelectablePreviewInteractor( @@ -37,9 +36,9 @@ class SelectablePreviewInteractor( /** Sets whether this preview is selected by the user. */ fun setSelected(isSelected: Boolean) { if (isSelected) { - selectionRepo.selections.update { it + key } + selectionRepo.select(key) } else { - selectionRepo.selections.update { it - key } + selectionRepo.unselect(key) } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt index e7bdafbc..4619e478 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -30,7 +30,6 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index 9a5ec173..da9eb750 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -352,6 +352,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // Initializer is invoked when this function returns, via Lifecycle. mChooserHelper.setInitializer(this::initializeWith); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + } } @Override @@ -513,7 +516,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( /* context = */ this, mProfilePagerResources, - mViewModel.getRequest().getValue(), + mRequest, mProfiles, mProfileAvailability, mRequest.getInitialIntents(), @@ -657,6 +660,71 @@ public class ChooserActivity extends Hilt_ChooserActivity implements Tracer.INSTANCE.markLaunched(); } + private void onChooserRequestChanged(ChooserRequest chooserRequest) { + if (mRequest == chooserRequest) { + return; + } + mRequest = chooserRequest; + recreatePagerAdapter(); + } + + private void recreatePagerAdapter() { + if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) { + return; + } + destroyProfileRecords(); + createProfileRecords( + new AppPredictorFactory( + this, + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); + + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow, + mFeatureFlags); + mChooserMultiProfilePagerAdapter.setupViewPager( + requireViewById(com.android.internal.R.id.profile_pager)); + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + postRebuildList( + mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent() + || mProfiles.getPrivateProfilePresent())); + } + @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); diff --git a/java/src/com/android/intentresolver/v2/ChooserHelper.kt b/java/src/com/android/intentresolver/v2/ChooserHelper.kt index d34e0b36..f2a2726a 100644 --- a/java/src/com/android/intentresolver/v2/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/v2/ChooserHelper.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository import com.android.intentresolver.inject.Background import com.android.intentresolver.v2.annotation.JavaInterop @@ -36,6 +37,7 @@ import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.Valid import com.android.intentresolver.v2.validation.log import dagger.hilt.android.scopes.ActivityScoped +import java.util.function.Consumer import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.filterNotNull @@ -113,6 +115,8 @@ constructor( private lateinit var activityInitializer: ChooserInitializer + var onChooserRequestChanged: Consumer = Consumer {} + init { activity.lifecycle.addObserver(this) } @@ -150,6 +154,12 @@ constructor( activity.setResult(activityResultRepo.activityResult.filterNotNull().first()) activity.finish() } + + activity.lifecycleScope.launch { + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.request.collect { onChooserRequestChanged.accept(it) } + } + } } override fun onStart(owner: LifecycleOwner) { diff --git a/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt new file mode 100644 index 00000000..99da5c81 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/domain/interactor/ChooserRequestUpdateInteractor.kt @@ -0,0 +1,82 @@ +/* + * 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.v2.domain.interactor + +import android.content.Intent +import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.TargetIntentRepository +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.inject.TargetIntent +import com.android.intentresolver.v2.ui.model.ActivityModel +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.ui.viewmodel.readChooserRequest +import com.android.intentresolver.v2.validation.Invalid +import com.android.intentresolver.v2.validation.Valid +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter + +private const val TAG = "ChooserRequestUpdate" + +/** Updates updates ChooserRequest with a new target intent */ +// TODO: make fully injectable +class ChooserRequestUpdateInteractor +@AssistedInject +constructor( + private val activityModel: ActivityModel, + @TargetIntent private val initialIntent: Intent, + private val targetIntentRepository: TargetIntentRepository, + // TODO: replace with a proper repository, when available + @Assisted private val chooserRequestRepository: MutableStateFlow, + private val flags: ChooserServiceFlags, +) { + + suspend fun launch() { + targetIntentRepository.targetIntent + // TODO: maybe find a better way to exclude the initial intent (as here it's compared by + // reference) + .filter { it !== initialIntent } + .collect(::updateTargetIntent) + } + + private fun updateTargetIntent(targetIntent: Intent) { + val updatedActivityModel = activityModel.updateWithTargetIntent(targetIntent) + when (val updatedChooserRequest = readChooserRequest(updatedActivityModel, flags)) { + is Valid -> chooserRequestRepository.value = updatedChooserRequest.value + is Invalid -> Log.w(TAG, "Failed to apply payload selection changes") + } + } + + private fun ActivityModel.updateWithTargetIntent(targetIntent: Intent) = + ActivityModel( + Intent(intent).apply { putExtra(Intent.EXTRA_INTENT, targetIntent) }, + launchedFromUid, + launchedFromPackage, + referrer, + ) +} + +@AssistedFactory +@ViewModelScoped +interface ChooserRequestUpdateInteractorFactory { + fun create( + chooserRequestRepository: MutableStateFlow + ): ChooserRequestUpdateInteractor +} diff --git a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java index 5d7cf26e..341e7043 100644 --- a/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/v2/profiles/MultiProfilePagerAdapter.java @@ -245,6 +245,7 @@ public class MultiProfilePagerAdapter< Runnable onTabChangeListener, OnProfileSelectedListener clientOnProfileSelectedListener) { tabHost.setup(); + tabHost.getTabWidget().removeAllViews(); viewPager.setSaveEnabled(false); for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt index 4d87b2cb..4431a545 100644 --- a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -18,17 +18,26 @@ package com.android.intentresolver.v2.ui.viewmodel import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.UpdateTargetIntentInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.v2.domain.interactor.ChooserRequestUpdateInteractorFactory import com.android.intentresolver.v2.ui.model.ActivityModel import com.android.intentresolver.v2.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY import com.android.intentresolver.v2.ui.model.ChooserRequest import com.android.intentresolver.v2.validation.Invalid import com.android.intentresolver.v2.validation.Valid +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch private const val TAG = "ChooserViewModel" @@ -37,7 +46,12 @@ class ChooserViewModel @Inject constructor( args: SavedStateHandle, - flags: ChooserServiceFlags, + private val shareouselViewModelProvider: Lazy, + private val updateTargetIntentInteractor: Lazy, + private val fetchPreviewsInteractor: Lazy, + @Background private val bgDispatcher: CoroutineDispatcher, + private val chooserRequestUpdateInteractorFactory: ChooserRequestUpdateInteractorFactory, + private val flags: ChooserServiceFlags, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -46,6 +60,19 @@ constructor( "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" } + val 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) { updateTargetIntentInteractor.get().launch() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().launch() } + viewModelScope.launch { chooserRequestUpdateInteractorFactory.create(_request).launch() } + shareouselViewModelProvider.get() + } + /** * Provided only for the express purpose of early exit in the event of an invalid request. * -- cgit v1.2.3-59-g8ed1b