diff options
| author | 2023-12-01 13:48:32 -0500 | |
|---|---|---|
| committer | 2024-01-18 21:10:00 -0500 | |
| commit | 5e5dd511a3031df38dfe35ca741e31ca9f0eec65 (patch) | |
| tree | d07a6d2a468abc339630081ffb6a654ca4dde8a8 /java/src | |
| parent | 99c9828d732ff25c87e8b41e386131dae70b4652 (diff) | |
Refactor ChooserRequestParameters usage
Creates ChooserRequest data class
Uses validation lib to implement parsing of source data
Introduces ChooserViewModel as a new target to begin migration of
control flow, data and dependencies out of ChooserActivity and into
smaller testable units.
Test: atest IntentResolver-tests-activity:com.android.intentresolver.v2
Bug: 309960444
Change-Id: I39b3517ec9e17525441d349b3da139ad5956c600
Diffstat (limited to 'java/src')
13 files changed, 577 insertions, 104 deletions
diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 10ee5af1..4c781a46 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -19,14 +19,10 @@ package com.android.intentresolver.contentpreview import android.content.Intent import androidx.annotation.MainThread import androidx.lifecycle.ViewModel -import com.android.intentresolver.ChooserRequestParameters /** A contract for the preview view model. Added for testing. */ abstract class BasePreviewViewModel : ViewModel() { - @MainThread - abstract fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider + @MainThread abstract fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider @MainThread abstract fun createOrReuseImageLoader(): ImageLoader } diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 6350756e..9acc4689 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -24,7 +24,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R import com.android.intentresolver.inject.Background import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,9 +44,7 @@ constructor( private var imageLoader: ImagePreviewImageLoader? = null @MainThread - override fun createOrReuseProvider( - targetIntent: Intent - ): PreviewDataProvider = + override fun createOrReuseProvider(targetIntent: Intent): PreviewDataProvider = previewDataProvider ?: PreviewDataProvider( viewModelScope + dispatcher, diff --git a/java/src/com/android/intentresolver/v2/ActivityLogic.kt b/java/src/com/android/intentresolver/v2/ActivityLogic.kt index 7062da33..b9686418 100644 --- a/java/src/com/android/intentresolver/v2/ActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ActivityLogic.kt @@ -1,3 +1,18 @@ +/* + * 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.v2 import android.content.Intent @@ -18,8 +33,6 @@ import com.android.intentresolver.WorkProfileAvailabilityManager interface ActivityLogic : CommonActivityLogic { /** The intent for the target. This will always come before additional targets, if any. */ val targetIntent: Intent - /** Whether the intent is for home. */ - val resolvingHome: Boolean /** Custom title to display. */ val title: CharSequence? /** Resource ID for the title to display when there is no custom title. */ diff --git a/java/src/com/android/intentresolver/v2/ChooserActivity.java b/java/src/com/android/intentresolver/v2/ChooserActivity.java index e093058a..a71de19d 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivity.java +++ b/java/src/com/android/intentresolver/v2/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * 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. @@ -95,7 +95,9 @@ import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.SavedStateHandleSupport; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; @@ -104,7 +106,6 @@ import com.android.intentresolver.AnnotatedUserHandles; import com.android.intentresolver.ChooserGridLayoutManager; import com.android.intentresolver.ChooserListAdapter; import com.android.intentresolver.ChooserRefinementManager; -import com.android.intentresolver.ChooserRequestParameters; import com.android.intentresolver.ChooserStackedAppDialogFragment; import com.android.intentresolver.ChooserTargetActionsDialogFragment; import com.android.intentresolver.EnterTransitionAnimationDelegate; @@ -147,6 +148,9 @@ import com.android.intentresolver.v2.platform.AppPredictionAvailable; import com.android.intentresolver.v2.platform.ImageEditor; import com.android.intentresolver.v2.platform.NearbyShare; import com.android.intentresolver.v2.ui.ActionTitle; +import com.android.intentresolver.v2.ui.model.CallerInfo; +import com.android.intentresolver.v2.ui.model.ChooserRequest; +import com.android.intentresolver.v2.ui.viewmodel.ChooserViewModel; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -311,31 +315,46 @@ public class ChooserActivity extends Hilt_ChooserActivity implements private boolean mFinishWhenStopped = false; private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + private ChooserViewModel mViewModel; @VisibleForTesting - protected ActivityLogic createActivityLogic() { + protected ChooserActivityLogic createActivityLogic(ChooserRequest chooserRequest) { return new ChooserActivityLogic( TAG, /* activity = */ this, - this::onWorkProfileStatusUpdated); + this::onWorkProfileStatusUpdated, + chooserRequest); + } + + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + CreationExtras extras = super.getDefaultViewModelCreationExtras(); + // Inserts a CallerInfo into the Bundle at stored at DEFAULT_ARGS_KEY + Bundle defaultArgs = requireNonNull(extras.get(SavedStateHandleSupport.DEFAULT_ARGS_KEY)); + defaultArgs.putParcelable(CallerInfo.SAVED_STATE_HANDLE_KEY, + new CallerInfo(getLaunchedFromUid(), + getLaunchedFromPackage(), + requireNonNull(getReferrer()))); + return extras; } @Override protected final void onCreate(Bundle savedInstanceState) { + Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); - if (isFinishing()) { - // Performing a clean exit: - // Skip initializing any additional resources. - return; - } setTheme(R.style.Theme_DeviceDefault_Chooser); - mLogic = createActivityLogic(); Tracer.INSTANCE.markLaunched(); + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + if (!mViewModel.init()) { + finish(); + return; + } + mLogic = createActivityLogic(mViewModel.getChooserRequest()); + init(); } - @Override - protected final void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); + private void init() { mIntentReceivedTime.set(System.currentTimeMillis()); mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); @@ -345,21 +364,16 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - finish(); - return; - } - + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); setRetainInOnStop(chooserRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( this, - chooserRequest.getSharedText(), - chooserRequest.getTargetIntentFilter(), + Objects.toString(chooserRequest.getSharedText(), null), + chooserRequest.getShareTargetFilter(), mAppPredictionAvailable ), - chooserRequest.getTargetIntentFilter() + chooserRequest.getShareTargetFilter() ); Intent intent = mLogic.getTargetIntent(); @@ -493,8 +507,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mLogic.getReferrerPackageName(), chooserRequest.getTargetType(), chooserRequest.getCallerChooserTargets().size(), - (chooserRequest.getInitialIntents() == null) - ? 0 : chooserRequest.getInitialIntents().length, + chooserRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), chooserRequest.getTargetAction(), @@ -502,8 +515,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements chooserRequest.getModifyShareAction() != null ); mEnterTransitionAnimationDelegate.postponeTransition(); - - restore(savedInstanceState); } private void restore(@Nullable Bundle savedInstanceState) { @@ -1151,15 +1162,6 @@ public class ChooserActivity extends Hilt_ChooserActivity implements ////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////// - @Nullable - private ChooserRequestParameters getChooserRequest() { - return ((ChooserActivityLogic) mLogic).getChooserRequestParameters(); - } - - private ChooserRequestParameters requireChooserRequest() { - return requireNonNull(getChooserRequest()); - } - private AnnotatedUserHandles requireAnnotatedUserHandles() { return requireNonNull(mLogic.getAnnotatedUserHandles()); } @@ -1234,7 +1236,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = requireChooserRequest().isSendActionTarget(); + final boolean isSendAction = mViewModel.getChooserRequest().isSendActionTarget(); final EmptyState noWorkToPersonalEmptyState = new DevicePolicyBlockerEmptyState( @@ -1504,7 +1506,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome() && !mRetainInOnStop) { + && !mRetainInOnStop) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -1550,10 +1552,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - ChooserRequestParameters chooserRequest = getChooserRequest(); - if (chooserRequest == null) { - return defIntent; - } + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); Intent result = defIntent; if (chooserRequest.getReplacementExtras() != null) { @@ -1578,7 +1577,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } public void onActivityStarted(TargetInfo cti) { - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); if (chooserRequest.getChosenComponentSender() != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { @@ -1595,7 +1594,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private void addCallerChooserTargets() { - ChooserRequestParameters chooserRequest = requireChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); if (!chooserRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { @@ -1637,8 +1636,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements // TODO: implement these type-conditioned behaviors polymorphically, and consider moving // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() - ? requireChooserRequest().getTargetIntentFilter() : null; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mViewModel.getChooserRequest().getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -1658,7 +1658,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - requireChooserRequest().getRefinementIntentSender(), + mViewModel.getChooserRequest().getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; @@ -1732,7 +1732,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - requireChooserRequest().getCallerChooserTargets().size(), + mViewModel.getChooserRequest().getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -1839,7 +1839,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(requireChooserRequest().getTargetIntent()); + Intent originalTargetIntent = new Intent(mViewModel.getChooserRequest().getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1938,7 +1938,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public boolean isComponentFiltered(ComponentName name) { - return requireChooserRequest().getFilteredComponentNames().contains(name); + return mViewModel.getChooserRequest().getFilteredComponentNames().contains(name); } @Override @@ -1955,7 +1955,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements List<ResolveInfo> rList, boolean filterLastUsed, UserHandle userHandle) { - ChooserRequestParameters parameters = requireChooserRequest(); + ChooserRequest parameters = mViewModel.getChooserRequest(); ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, @@ -2104,11 +2104,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private ChooserActionFactory createChooserActionFactory() { - ChooserRequestParameters request = requireChooserRequest(); + ChooserRequest request = mViewModel.getChooserRequest(); return new ChooserActionFactory( this, request.getTargetIntent(), - request.getReferrerPackageName(), + request.getLaunchedFromPackage(), request.getChooserActions(), request.getModifyShareAction(), mImageEditor, @@ -2473,7 +2473,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - ChooserRequestParameters chooserRequest = getChooserRequest(); + ChooserRequest chooserRequest = mViewModel.getChooserRequest(); return (chooserRequest != null) && chooserRequest.isSendActionTarget(); } diff --git a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt index a8150f52..f6054885 100644 --- a/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ChooserActivityLogic.kt @@ -1,11 +1,9 @@ package com.android.intentresolver.v2 -import android.app.Activity import android.content.Intent -import android.util.Log import androidx.activity.ComponentActivity import androidx.annotation.OpenForTesting -import com.android.intentresolver.ChooserRequestParameters +import com.android.intentresolver.v2.ui.model.ChooserRequest private const val TAG = "ChooserActivityLogic" @@ -13,14 +11,14 @@ private const val TAG = "ChooserActivityLogic" * Activity logic for [ChooserActivity]. * * TODO: Make this class no longer open once [ChooserActivity] no longer needs to cast to access - * [chooserRequestParameters]. For now, this class being open is better than using reflection - * there. + * [chooserRequest]. For now, this class being open is better than using reflection there. */ @OpenForTesting open class ChooserActivityLogic( tag: String, activity: ComponentActivity, - onWorkProfileStatusUpdated: () -> Unit + onWorkProfileStatusUpdated: () -> Unit, + private val chooserRequest: ChooserRequest? = null, ) : ActivityLogic, CommonActivityLogic by CommonActivityLogicImpl( @@ -29,30 +27,16 @@ open class ChooserActivityLogic( onWorkProfileStatusUpdated, ) { - val chooserRequestParameters: ChooserRequestParameters? = - try { - ChooserRequestParameters( - (activity as Activity).intent, - referrerPackageName, - (activity as Activity).referrer, - ) - } catch (e: IllegalArgumentException) { - Log.e(tag, "Caller provided invalid Chooser request parameters", e) - null - } + override val targetIntent: Intent = chooserRequest?.targetIntent ?: Intent() - override val targetIntent: Intent = chooserRequestParameters?.targetIntent ?: Intent() + override val title: CharSequence? = chooserRequest?.title - override val resolvingHome: Boolean = false + override val defaultTitleResId: Int = chooserRequest?.defaultTitleResource ?: 0 - override val title: CharSequence? = chooserRequestParameters?.title - - override val defaultTitleResId: Int = chooserRequestParameters?.defaultTitleResource ?: 0 - - override val initialIntents: List<Intent>? = chooserRequestParameters?.initialIntents?.toList() + override val initialIntents: List<Intent>? = chooserRequest?.initialIntents?.toList() override val payloadIntents: List<Intent> = buildList { add(targetIntent) - chooserRequestParameters?.additionalTargets?.let { addAll(it) } + chooserRequest?.additionalTargets?.let { addAll(it) } } } diff --git a/java/src/com/android/intentresolver/v2/ResolverActivity.java b/java/src/com/android/intentresolver/v2/ResolverActivity.java index 9672e9d6..0e526b4c 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivity.java +++ b/java/src/com/android/intentresolver/v2/ResolverActivity.java @@ -106,6 +106,7 @@ import com.android.intentresolver.v2.emptystate.NoAppsAvailableEmptyStateProvide import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider; import com.android.intentresolver.v2.emptystate.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.v2.emptystate.WorkProfilePausedEmptyStateProvider; +import com.android.intentresolver.v2.ext.IntentExtKt; import com.android.intentresolver.v2.ui.ActionTitle; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; @@ -141,6 +142,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements protected ActivityLogic mLogic; protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; private Button mAlwaysButton; private Button mOnceButton; @@ -223,7 +225,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } @VisibleForTesting - protected ActivityLogic createActivityLogic() { + protected ResolverActivityLogic createActivityLogic() { return new ResolverActivityLogic( TAG, /* activity = */ this, @@ -235,6 +237,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements super.onCreate(savedInstanceState); setTheme(R.style.Theme_DeviceDefault_Resolver); mLogic = createActivityLogic(); + mResolvingHome = IntentExtKt.isHomeIntent(getIntent()); mTargetDataLoader = new DefaultTargetDataLoader( this, getLifecycle(), @@ -242,11 +245,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, /* defaultValue = */ false) ); - } - - @Override - protected final void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); init(); restore(savedInstanceState); } @@ -486,7 +484,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } final Intent intent = getIntent(); if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mLogic.getResolvingHome()) { + && !mResolvingHome) { // This resolver is in the unusual situation where it has been // launched at the top of a new task. We don't let it be added // to the recent tasks shown to the user, and we need to make sure @@ -532,7 +530,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); - if (mLogic.getResolvingHome() && hasManagedProfile() && !supportsManagedProfiles(ri)) { + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { String launcherName = ri.activityInfo.loadLabel(getPackageManager()).toString(); Toast.makeText(this, mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), @@ -1133,7 +1131,7 @@ public class ResolverActivity extends Hilt_ResolverActivity implements } protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { - final ActionTitle title = mLogic.getResolvingHome() + final ActionTitle title = mResolvingHome ? ActionTitle.HOME : ActionTitle.forAction(intent.getAction()); @@ -1198,7 +1196,6 @@ public class ResolverActivity extends Hilt_ResolverActivity implements @Override protected final void onStart() { super.onStart(); - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); if (hasWorkProfile()) { mLogic.getWorkProfileAvailabilityManager().registerWorkProfileStateReceiver(this); diff --git a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt index cf843043..13353041 100644 --- a/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt +++ b/java/src/com/android/intentresolver/v2/ResolverActivityLogic.kt @@ -36,10 +36,6 @@ open class ResolverActivityLogic( intent } - override val resolvingHome: Boolean = - targetIntent.action == Intent.ACTION_MAIN && - targetIntent.categories.singleOrNull() == Intent.CATEGORY_HOME - override val title: CharSequence? = null override val defaultTitleResId: Int = 0 diff --git a/java/src/com/android/intentresolver/v2/ext/IntentExt.kt b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt new file mode 100644 index 00000000..7aa8e036 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ext/IntentExt.kt @@ -0,0 +1,39 @@ +/* + * 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.v2.ext + +import android.content.Intent +import java.util.function.Predicate + +/** Applies an operation on this Intent if matches the given filter. */ +inline fun Intent.ifMatch( + predicate: Predicate<Intent>, + crossinline block: Intent.() -> Unit +): Intent { + if (predicate.test(this)) { + apply(block) + } + return this +} + +/** True if the Intent has one of the specified actions. */ +fun Intent.hasAction(vararg actions: String): Boolean = action in actions + +/** True if the Intent has a single matching category. */ +fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category + +/** True if the Intent resolves to the special Home (Launcher) component */ +fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt b/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt new file mode 100644 index 00000000..9addeef2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/CallerInfo.kt @@ -0,0 +1,59 @@ +/* + * 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.v2.ui.model + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +data class CallerInfo( + val launchedFromUid: Int, + val launchedFomPackage: String?, + /* logged to metrics, forwarded to outgoing intent */ + val referrer: Uri +) : Parcelable { + constructor( + source: Parcel + ) : this( + launchedFromUid = source.readInt(), + launchedFomPackage = source.readString(), + checkNotNull(source.readParcelable()) + ) + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(launchedFromUid) + dest.writeString(launchedFomPackage) + dest.writeParcelable(referrer, 0) + } + + companion object { + const val SAVED_STATE_HANDLE_KEY = "com.android.intentresolver.CALLER_INFO" + + @JvmStatic + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator<CallerInfo> { + override fun newArray(size: Int) = arrayOfNulls<CallerInfo>(size) + override fun createFromParcel(source: Parcel) = CallerInfo(source) + } + } +} + +inline fun <reified T> Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt new file mode 100644 index 00000000..2fbf94a2 --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/model/ChooserRequest.kt @@ -0,0 +1,180 @@ +/* + * 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.v2.ui.model + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.IntentFilter +import android.content.IntentSender +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import androidx.annotation.StringRes +import com.android.intentresolver.v2.ext.hasAction + +const val MAX_CHOOSER_ACTIONS = 5 +const val MAX_INITIAL_INTENTS = 2 + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String?, + + /** + * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the + * canonical "Share" actions. When handling other actions, this flag controls behavioral and + * visual changes. + */ + val isSendActionTarget: Boolean, + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String?, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * An empty intent which carries an extra of [Intent.EXTRA_REFERRER]. To be merged with outgoing + * intents. This provides the original referrer value to the target. + */ + val referrerFillInIntent: Intent, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List<ComponentName> = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List<ChooserTarget> = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List<ChooserAction> = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List<Intent> = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map<String, Bundle> + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List<Intent> = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null +) { + + /** Constructs an instance from only the required values. */ + constructor( + targetIntent: Intent, + referrerPackageName: String + ) : this( + targetIntent, + targetIntent.action, + targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + targetIntent.type, + referrerPackageName, + referrerFillInIntent = + Intent().apply { putExtra(Intent.EXTRA_REFERRER, referrerPackageName) } + ) +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt new file mode 100644 index 00000000..6878be5f --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserRequestReader.kt @@ -0,0 +1,157 @@ +/* + * 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.v2.ui.viewmodel + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +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_TARGETS +import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_REFERRER +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +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.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.R +import com.android.intentresolver.util.hasValidIcon +import com.android.intentresolver.v2.ext.hasAction +import com.android.intentresolver.v2.ext.ifMatch +import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.ui.model.MAX_CHOOSER_ACTIONS +import com.android.intentresolver.v2.ui.model.MAX_INITIAL_INTENTS +import com.android.intentresolver.v2.validation.types.IntentOrUri +import com.android.intentresolver.v2.validation.types.array +import com.android.intentresolver.v2.validation.types.value +import com.android.intentresolver.v2.validation.validateFrom + +private fun Intent.hasSendAction() = hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) + +internal fun Intent.maybeAddSendActionFlags() = + ifMatch(Intent::hasSendAction) { + addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) + addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) + } + +fun readChooserRequest(callerInfo: CallerInfo, source: (String) -> Any?) = + validateFrom(source) { + val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() + + val isSendAction = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE) + + val additionalTargets = + optional(array<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + ?: emptyList() + + val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS)) + + val (customTitle, defaultTitleResource) = + if (isSendAction) { + ignored( + value<CharSequence>(EXTRA_TITLE), + "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + + "property of the wrapped EXTRA_INTENT." + ) + null to R.string.chooseActivity + } else { + val custom = optional(value<CharSequence>(EXTRA_TITLE)) + custom to (custom?.let { 0 } ?: R.string.chooseActivity) + } + + val initialIntents = + optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { + it.maybeAddSendActionFlags() + } + ?: emptyList() + + val chosenComponentSender = + optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + + val refinementIntentSender = + optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + val filteredComponents = + optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() + + @Suppress("DEPRECATION") + val callerChooserTargets = + optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList() + + val retainInOnStop = + optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false + + val sharedText = optional(value<CharSequence>(EXTRA_TEXT)) + + val chooserActions = + optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + ?: emptyList() + + val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + + val referrerFillIn = Intent().putExtra(EXTRA_REFERRER, callerInfo.referrer) + + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = isSendAction, + targetType = targetIntent.type, + launchedFromPackage = + requireNotNull(callerInfo.launchedFomPackage) { + "launchedFromPackage was null, See Activity.getLaunchedFromPackage()" + }, + title = customTitle, + defaultTitleResource = defaultTitleResource, + referrerFillInIntent = referrerFillIn, + filteredComponentNames = filteredComponents, + callerChooserTargets = callerChooserTargets, + chooserActions = chooserActions, + modifyShareAction = modifyShareAction, + shouldRetainInOnStop = retainInOnStop, + additionalTargets = additionalTargets, + replacementExtras = replacementExtras, + initialIntents = initialIntents, + chosenComponentSender = chosenComponentSender, + refinementIntentSender = refinementIntentSender, + sharedText = sharedText, + shareTargetFilter = targetIntent.toShareTargetFilter() + ) + } + +private fun Intent.toShareTargetFilter(): IntentFilter? { + return type?.let { + IntentFilter().apply { + action?.also { addAction(it) } + addDataType(it) + } + } +} diff --git a/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..663235ca --- /dev/null +++ b/java/src/com/android/intentresolver/v2/ui/viewmodel/ChooserViewModel.kt @@ -0,0 +1,55 @@ +/* + * 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.v2.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.intentresolver.v2.ui.model.CallerInfo +import com.android.intentresolver.v2.ui.model.ChooserRequest +import com.android.intentresolver.v2.validation.ValidationResult +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val TAG = "ChooserViewModel" + +@HiltViewModel +class ChooserViewModel +@Inject +constructor( + private val args: SavedStateHandle, +) : ViewModel() { + + private val callerInfo: CallerInfo = + requireNotNull(args[CallerInfo.SAVED_STATE_HANDLE_KEY]) { + "CallerInfo missing in SavedStateHandle! (${CallerInfo.SAVED_STATE_HANDLE_KEY})" + } + + /** The result of reading and validating the inputs provided in savedState. */ + private val status: ValidationResult<ChooserRequest> = readChooserRequest(callerInfo, args::get) + + val chooserRequest: ChooserRequest by lazy { status.getOrThrow() } + + fun init(): Boolean { + Log.i(TAG, "viewModel init") + if (!status.isSuccess()) { + status.reportToLogcat(TAG) + return false + } + Log.i(TAG, "request = $chooserRequest") + return true + } +} diff --git a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt index 092cabe8..856a521e 100644 --- a/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt +++ b/java/src/com/android/intentresolver/v2/validation/ValidationResult.kt @@ -26,7 +26,7 @@ sealed interface ValidationResult<T> { fun getOrThrow(): T = checkNotNull(value) { "The result was invalid: " + findings.joinToString(separator = "\n") } - fun <T> reportToLogcat(tag: String) { + fun reportToLogcat(tag: String) { findings.forEach { Log.println(it.logcatPriority, tag, it.toString()) } } } |