diff options
40 files changed, 1217 insertions, 1107 deletions
@@ -24,6 +24,7 @@ java_defaults { srcs: [ "java/src/**/*.java", "java/src/**/*.kt", + "java/aidl/**/I*.aidl", ], resource_dirs: [ "java/res", @@ -52,6 +53,7 @@ android_library { "androidx.lifecycle_lifecycle-runtime-ktx", "androidx.lifecycle_lifecycle-viewmodel-ktx", "dagger2", + "//frameworks/libs/systemui:com_android_systemui_shared_flags_lib", "hilt_android", "IntentResolverFlagsLib", "iconloader", @@ -76,6 +78,9 @@ android_library { "-Adagger.explicitBindingConflictsWithInject=ERROR", "-Adagger.strictMultibindingValidation=enabled", ], + aidl: { + local_include_dirs: ["java/aidl"], + }, } java_defaults { diff --git a/AndroidManifest-app.xml b/AndroidManifest-app.xml index 7338dd08..f5d2ff8e 100644 --- a/AndroidManifest-app.xml +++ b/AndroidManifest-app.xml @@ -23,6 +23,8 @@ android:versionName="2021-11" coreApp="true"> + <uses-permission android:name="android.permission.INTERNAL_SYSTEM_WINDOW" /> + <application android:name=".MainApplication" android:hardwareAccelerated="true" diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index eabdabfd..f77c014a 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -36,23 +36,20 @@ flag { } flag { - name: "fix_shortcut_loader_job_leak" + name: "fix_shortcuts_flashing" namespace: "intentresolver" - description: "User a nested coroutine scope for shortcut loader instances" - bug: "358135601" + description: "Do not flash shortcuts on payload selection change" + bug: "343300158" metadata { purpose: PURPOSE_BUGFIX } } flag { - name: "fix_shortcuts_flashing" + name: "interactive_session" namespace: "intentresolver" - description: "Do not flash shortcuts on payload selection change" - bug: "343300158" - metadata { - purpose: PURPOSE_BUGFIX - } + description: "Enables interactive chooser session (a.k.a 'Splitti') feature." + bug: "358166090" } flag { @@ -83,13 +80,6 @@ flag { } flag { - name: "preview_image_loader" - namespace: "intentresolver" - description: "Use the unified preview image loader for all preview variations; support variable preview sizes." - bug: "348665058" -} - -flag { name: "save_shareousel_state" namespace: "intentresolver" description: "Preserve Shareousel state over a system-initiated process death." @@ -119,3 +109,10 @@ flag { description: "Whether to scroll items onscreen when they are partially offscreen and selected/unselected." bug: "351883537" } + +flag { + name: "shareousel_selection_shrink" + namespace: "intentresolver" + description: "Whether to shrink Shareousel items when they are selected." + bug: "361792274" +} diff --git a/java/aidl/com/android/intentresolver/IChooserController.aidl b/java/aidl/com/android/intentresolver/IChooserController.aidl new file mode 100644 index 00000000..a4ce718d --- /dev/null +++ b/java/aidl/com/android/intentresolver/IChooserController.aidl @@ -0,0 +1,8 @@ + +package com.android.intentresolver; + +import android.content.Intent; + +interface IChooserController { + oneway void updateIntent(in Intent intent); +} diff --git a/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl b/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl new file mode 100644 index 00000000..4a6179d9 --- /dev/null +++ b/java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl @@ -0,0 +1,9 @@ + +package com.android.intentresolver; + +import com.android.intentresolver.IChooserController; + +interface IChooserInteractiveSessionCallback { + oneway void registerChooserController(in IChooserController updater); + oneway void onDrawerVerticalOffsetChanged(in int offset); +} diff --git a/java/res/color/resolver_profile_tab_text.xml b/java/res/color/resolver_profile_tab_text.xml index ffeba854..f6a4eadf 100644 --- a/java/res/color/resolver_profile_tab_text.xml +++ b/java/res/color/resolver_profile_tab_text.xml @@ -16,5 +16,5 @@ <selector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> <item android:color="@androidprv:color/materialColorOnPrimary" android:state_selected="true"/> - <item android:color="@androidprv:color/materialColorOnSurfaceVariant"/> + <item android:color="@androidprv:color/materialColorOnSurface"/> </selector> diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml index 20f0be92..392f7e30 100644 --- a/java/res/drawable/resolver_profile_tab_bg.xml +++ b/java/res/drawable/resolver_profile_tab_bg.xml @@ -29,7 +29,7 @@ <item android:state_selected="false"> <shape android:shape="rectangle"> <corners android:radius="12dp" /> - <solid android:color="@androidprv:color/materialColorSurfaceContainerHighest" /> + <solid android:color="@androidprv:color/materialColorSurfaceBright" /> </shape> </item> diff --git a/java/res/layout/resolver_profile_tab_button.xml b/java/res/layout/resolver_profile_tab_button.xml index 52a1aacf..7404dc33 100644 --- a/java/res/layout/resolver_profile_tab_button.xml +++ b/java/res/layout/resolver_profile_tab_button.xml @@ -17,7 +17,6 @@ <Button xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1" diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml index 3c1be527..43921c78 100644 --- a/java/res/values-iw/strings.xml +++ b/java/res/values-iw/strings.xml @@ -106,5 +106,5 @@ <string name="selectable_image" msgid="3157858923437182271">"תמונה שניתן לבחור"</string> <string name="selectable_video" msgid="1271768647699300826">"סרטון שניתן לבחור"</string> <string name="selectable_item" msgid="7557320816744205280">"פריט שניתן לבחור"</string> - <string name="role_description_button" msgid="4537198530568333649">"לחצן"</string> + <string name="role_description_button" msgid="4537198530568333649">"כפתור"</string> </resources> diff --git a/java/src/android/service/chooser/ChooserSession.kt b/java/src/android/service/chooser/ChooserSession.kt new file mode 100644 index 00000000..3bbe23a4 --- /dev/null +++ b/java/src/android/service/chooser/ChooserSession.kt @@ -0,0 +1,39 @@ +/* + * 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 android.service.chooser + +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.IChooserInteractiveSessionCallback + +/** A stub for the potential future API class. */ +class ChooserSession(val sessionCallbackBinder: IChooserInteractiveSessionCallback) : Parcelable { + override fun describeContents() = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + TODO("Not yet implemented") + } + + companion object CREATOR : Parcelable.Creator<ChooserSession> { + override fun createFromParcel(source: Parcel): ChooserSession? = + ChooserSession( + IChooserInteractiveSessionCallback.Stub.asInterface(source.readStrongBinder()) + ) + + override fun newArray(size: Int): Array<out ChooserSession?> = arrayOfNulls(size) + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 92f366ea..d81adfba 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -24,6 +24,7 @@ import static androidx.lifecycle.LifecycleKt.getCoroutineScope; import static com.android.intentresolver.ChooserActionFactory.EDIT_SOURCE; import static com.android.intentresolver.Flags.fixShortcutsFlashing; +import static com.android.intentresolver.Flags.interactiveSession; import static com.android.intentresolver.Flags.keyboardNavigationFix; import static com.android.intentresolver.Flags.rebuildAdaptersOnTargetPinning; import static com.android.intentresolver.Flags.refineSystemActions; @@ -60,6 +61,7 @@ import android.content.pm.ShortcutInfo; import android.content.res.Configuration; import android.database.Cursor; import android.graphics.Insets; +import android.graphics.Rect; import android.net.Uri; import android.os.Bundle; import android.os.StrictMode; @@ -143,6 +145,7 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ChooserNestedScrollView; import com.android.intentresolver.widget.ImagePreviewView; import com.android.intentresolver.widget.ResolverDrawerLayout; +import com.android.intentresolver.widget.ResolverDrawerLayoutExt; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; @@ -463,6 +466,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements if (isFinishing()) { mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + if (interactiveSession() && mViewModel != null) { + mViewModel.getInteractiveSessionInteractor().endSession(); + } } mBackgroundThreadPoolExecutor.shutdownNow(); @@ -682,6 +688,11 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mEnterTransitionAnimationDelegate.postponeTransition(); mInitialProfile = findSelectedProfile(); Tracer.INSTANCE.markLaunched(); + + if (isInteractiveSession()) { + configureInteractiveSessionWindow(); + updateInteractiveArea(); + } } private void maybeDisableRecentsScreenshot( @@ -721,6 +732,45 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.setTargetsEnabled(hasSelections); } + private void configureInteractiveSessionWindow() { + if (!isInteractiveSession()) { + Log.wtf(TAG, "Unexpected user of the method; should be an interactive session"); + return; + } + final Window window = getWindow(); + if (window == null) { + return; + } + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + window.addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY); + } + + private void updateInteractiveArea() { + if (!isInteractiveSession()) { + Log.wtf(TAG, "Unexpected user of the method; should be an interactive session"); + return; + } + final View contentView = findViewById(android.R.id.content); + final ResolverDrawerLayout rdl = mResolverDrawerLayout; + if (contentView == null || rdl == null) { + return; + } + final Rect rect = new Rect(); + contentView.getViewTreeObserver().addOnComputeInternalInsetsListener((info) -> { + int oldTop = rect.top; + rdl.getBoundsInWindow(rect, true); + int left = rect.left; + int top = rect.top; + ResolverDrawerLayoutExt.getVisibleDrawerRect(rdl, rect); + rect.offset(left, top); + if (oldTop != rect.top) { + mViewModel.getInteractiveSessionInteractor().sendTopDrawerTopOffsetChange(rect.top); + } + info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); + info.touchableRegion.set(new Rect(rect)); + }); + } + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { Log.d(TAG, "onAppTargetsLoaded(" + "listAdapter.userHandle=" + listAdapter.getUserHandle() + ")"); @@ -964,6 +1014,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} */ private boolean maybeAutolaunchActivity() { + if (isInteractiveSession()) { + return false; + } int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the // correct intent-picker UIs (e.g., mini-resolver) if it was launched without @@ -1562,8 +1615,12 @@ public class ChooserActivity extends Hilt_ChooserActivity implements mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); if (mSystemWindowInsets != null) { - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); + int topSpacing = isInteractiveSession() ? getInteractiveSessionTopSpacing() : 0; + mResolverDrawerLayout.setPadding( + mSystemWindowInsets.left, + mSystemWindowInsets.top + topSpacing, + mSystemWindowInsets.right, + 0); } if (mViewPager.isLayoutRtl()) { mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); @@ -2574,7 +2631,7 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - if (!shouldShowContentPreview()) { + if (isInteractiveSession() || !shouldShowContentPreview()) { return false; } ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( @@ -2667,13 +2724,26 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } } + private int getInteractiveSessionTopSpacing() { + return getResources().getDimensionPixelSize(R.dimen.chooser_preview_image_height_tall); + } + + private boolean isInteractiveSession() { + return interactiveSession() && mRequest.getInteractiveSessionCallback() != null + && !isTaskRoot(); + } + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars()); mChooserMultiProfilePagerAdapter .setEmptyStateBottomOffset(mSystemWindowInsets.bottom); - mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, - mSystemWindowInsets.right, 0); + final int topSpacing = isInteractiveSession() ? getInteractiveSessionTopSpacing() : 0; + mResolverDrawerLayout.setPadding( + mSystemWindowInsets.left, + mSystemWindowInsets.top + topSpacing, + mSystemWindowInsets.right, + 0); // Need extra padding so the list can fully scroll up // To accommodate for window insets diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt index c26dd77c..2d015128 100644 --- a/java/src/com/android/intentresolver/ChooserHelper.kt +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.Flags.interactiveSession import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.annotation.JavaInterop import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION @@ -188,6 +189,14 @@ constructor( .collect { onChooserRequestChanged.accept(it) } } } + + if (interactiveSession()) { + activity.lifecycleScope.launch { + viewModel.interactiveSessionInteractor.isSessionActive + .filter { !it } + .collect { activity.finish() } + } + } } override fun onStart(owner: LifecycleOwner) { diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt deleted file mode 100644 index 847fcc82..00000000 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ /dev/null @@ -1,110 +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 - -import android.graphics.Bitmap -import android.net.Uri -import android.util.Log -import android.util.Size -import androidx.core.util.lruCache -import com.android.intentresolver.inject.Background -import com.android.intentresolver.inject.ViewModelOwned -import javax.inject.Inject -import javax.inject.Qualifier -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext - -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.BINARY) -annotation class PreviewMaxConcurrency - -/** - * Implementation of [ImageLoader]. - * - * Allows for cached or uncached loading of images and limits the number of concurrent requests. - * Requests are automatically cancelled when they are evicted from the cache. If image loading fails - * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null. - */ -class CachingImagePreviewImageLoader -@Inject -constructor( - @ViewModelOwned private val scope: CoroutineScope, - @Background private val bgDispatcher: CoroutineDispatcher, - private val thumbnailLoader: ThumbnailLoader, - @PreviewCacheSize cacheSize: Int, - @PreviewMaxConcurrency maxConcurrency: Int, -) : ImageLoader { - - private val semaphore = Semaphore(maxConcurrency) - - private val cache = - lruCache( - maxSize = cacheSize, - create = { uri: Uri -> scope.async { loadUncachedImage(uri) } }, - onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ -> - // If removed due to eviction, cancel the coroutine, otherwise it is the - // responsibility - // of the caller of [cache.remove] to cancel the removed entry when done with it. - if (evicted) { - oldValue.cancel() - } - } - ) - - override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { - uriSizePairs.take(cache.maxSize()).map { cache[it.first] } - } - - override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? { - return if (caching) { - loadCachedImage(uri) - } else { - loadUncachedImage(uri) - } - } - - private suspend fun loadUncachedImage(uri: Uri): Bitmap? = - withContext(bgDispatcher) { - runCatching { semaphore.withPermit { thumbnailLoader.loadThumbnail(uri) } } - .onFailure { - ensureActive() - Log.d(TAG, "Failed to load preview for $uri", it) - } - .getOrNull() - } - - private suspend fun loadCachedImage(uri: Uri): Bitmap? = - // [Deferred#await] is called in a [runCatching] block to catch - // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. - runCatching { cache[uri].await() }.getOrNull() - - @OptIn(ExperimentalCoroutinesApi::class) - override fun getCachedBitmap(uri: Uri): Bitmap? = - kotlin.runCatching { cache[uri].getCompleted() }.getOrNull() - - companion object { - private const val TAG = "CachingImgPrevLoader" - } -} diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index 4166e5ae..2af5881f 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -184,7 +184,8 @@ public final class ChooserContentPreviewUi { imageLoader, typeClassifier, headlineGenerator, - metadata + metadata, + chooserRequest.getCallerAllowsTextToggle() ); if (previewData.getUriCount() > 0) { JavaFlowHelper.collectToList( diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 30161cfb..da701ec4 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -62,6 +62,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private final CharSequence mMetadata; private final boolean mIsSingleImage; private final int mFileCount; + private final boolean mAllowTextToggle; private ViewGroup mContentPreviewView; private View mHeadliveView; private boolean mIsMetadataUpdated = false; @@ -70,8 +71,6 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private boolean mAllImages; private boolean mAllVideos; private int mPreviewSize; - // TODO(b/285309527): make this a flag - private static final boolean SHOW_TOGGLE_CHECKMARK = false; FilesPlusTextContentPreviewUi( CoroutineScope scope, @@ -83,7 +82,8 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { ImageLoader imageLoader, MimeTypeClassifier typeClassifier, HeadlineGenerator headlineGenerator, - @Nullable CharSequence metadata) { + @Nullable CharSequence metadata, + boolean allowTextToggle) { if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); @@ -98,6 +98,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mTypeClassifier = typeClassifier; mHeadlineGenerator = headlineGenerator; mMetadata = metadata; + mAllowTextToggle = allowTextToggle; } @Override @@ -234,7 +235,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { shareTextAction.accept(!isChecked); updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); }); - if (SHOW_TOGGLE_CHECKMARK) { + if (mAllowTextToggle) { includeText.setVisibility(View.VISIBLE); } } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt index 7df98cd2..7cc4458f 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -17,7 +17,6 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources -import com.android.intentresolver.Flags.previewImageLoader import com.android.intentresolver.R import com.android.intentresolver.inject.ApplicationOwned import dagger.Binds @@ -25,25 +24,15 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent -import javax.inject.Provider @Module @InstallIn(ViewModelComponent::class) interface ImageLoaderModule { @Binds fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader - companion object { - @Provides - fun imageLoader( - imagePreviewImageLoader: Provider<ImagePreviewImageLoader>, - previewImageLoader: Provider<PreviewImageLoader>, - ): ImageLoader = - if (previewImageLoader()) { - previewImageLoader.get() - } else { - imagePreviewImageLoader.get() - } + @Binds fun imageLoader(previewImageLoader: PreviewImageLoader): ImageLoader + companion object { @Provides @ThumbnailSize fun thumbnailSize(@ApplicationOwned resources: Resources): Int = diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt deleted file mode 100644 index 379bdb37..00000000 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentResolver -import android.graphics.Bitmap -import android.net.Uri -import android.util.Log -import android.util.Size -import androidx.annotation.GuardedBy -import androidx.annotation.VisibleForTesting -import androidx.collection.LruCache -import com.android.intentresolver.inject.Background -import javax.inject.Inject -import javax.inject.Qualifier -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore - -private const val TAG = "ImagePreviewImageLoader" - -@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize - -@Qualifier -@MustBeDocumented -@Retention(AnnotationRetention.BINARY) -annotation class PreviewCacheSize - -/** - * Implements preview image loading for the content preview UI. Provides requests deduplication, - * image caching, and a limit on the number of parallel loadings. - */ -@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) -class ImagePreviewImageLoader -@VisibleForTesting -constructor( - private val scope: CoroutineScope, - thumbnailSize: Int, - private val contentResolver: ContentResolver, - cacheSize: Int, - // TODO: consider providing a scope with the dispatcher configured with - // [CoroutineDispatcher#limitedParallelism] instead - private val contentResolverSemaphore: Semaphore, -) : ImageLoader { - - @Inject - constructor( - @Background dispatcher: CoroutineDispatcher, - @ThumbnailSize thumbnailSize: Int, - contentResolver: ContentResolver, - @PreviewCacheSize cacheSize: Int, - ) : this( - CoroutineScope( - SupervisorJob() + - dispatcher + - CoroutineExceptionHandler { _, exception -> - Log.w(TAG, "Uncaught exception in ImageLoader", exception) - } + - CoroutineName("ImageLoader") - ), - thumbnailSize, - contentResolver, - cacheSize, - ) - - constructor( - scope: CoroutineScope, - thumbnailSize: Int, - contentResolver: ContentResolver, - cacheSize: Int, - maxSimultaneousRequests: Int = 4 - ) : this(scope, thumbnailSize, contentResolver, cacheSize, Semaphore(maxSimultaneousRequests)) - - private val thumbnailSize: Size = Size(thumbnailSize, thumbnailSize) - - private val lock = Any() - @GuardedBy("lock") private val cache = LruCache<Uri, RequestRecord>(cacheSize) - @GuardedBy("lock") private val runningRequests = HashMap<Uri, RequestRecord>() - - override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = - loadImageAsync(uri, caching) - - override fun prePopulate(uriSizePairs: List<Pair<Uri, Size>>) { - uriSizePairs.asSequence().take(cache.maxSize()).forEach { (uri, _) -> - scope.launch { loadImageAsync(uri, caching = true) } - } - } - - private suspend fun loadImageAsync(uri: Uri, caching: Boolean): Bitmap? { - return getRequestDeferred(uri, caching).await() - } - - private fun getRequestDeferred(uri: Uri, caching: Boolean): Deferred<Bitmap?> { - var shouldLaunchImageLoading = false - val request = - synchronized(lock) { - cache[uri] - ?: runningRequests - .getOrPut(uri) { - shouldLaunchImageLoading = true - RequestRecord(uri, CompletableDeferred(), caching) - } - .apply { this.caching = this.caching || caching } - } - if (shouldLaunchImageLoading) { - request.loadBitmapAsync() - } - return request.deferred - } - - private fun RequestRecord.loadBitmapAsync() { - scope - .launch { loadBitmap() } - .invokeOnCompletion { cause -> - if (cause is CancellationException) { - cancel() - } - } - } - - private suspend fun RequestRecord.loadBitmap() { - contentResolverSemaphore.acquire() - val bitmap = - try { - contentResolver.loadThumbnail(uri, thumbnailSize, null) - } catch (t: Throwable) { - Log.d(TAG, "failed to load $uri preview", t) - null - } finally { - contentResolverSemaphore.release() - } - complete(bitmap) - } - - private fun RequestRecord.cancel() { - synchronized(lock) { - runningRequests.remove(uri) - deferred.cancel() - } - } - - private fun RequestRecord.complete(bitmap: Bitmap?) { - deferred.complete(bitmap) - synchronized(lock) { - runningRequests.remove(uri) - if (bitmap != null && caching) { - cache.put(uri, this) - } - } - } - - private class RequestRecord( - val uri: Uri, - val deferred: CompletableDeferred<Bitmap?>, - @GuardedBy("lock") var caching: Boolean - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt index b10f7ef9..1dc497b3 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt @@ -25,6 +25,7 @@ import com.android.intentresolver.inject.Background import com.android.intentresolver.inject.ViewModelOwned import javax.annotation.concurrent.GuardedBy import javax.inject.Inject +import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -41,6 +42,18 @@ import kotlinx.coroutines.sync.withPermit private const val TAG = "PayloadSelImageLoader" +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewCacheSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + /** * Implements preview image loading for the payload selection UI. Cancels preview loading for items * that has been evicted from the cache at the expense of a possible request duplication (deemed @@ -69,7 +82,7 @@ constructor( if (oldRec !== newRec) { onRecordEvictedFromCache(oldRec) } - } + }, ) override suspend fun invoke(uri: Uri, size: Size, caching: Boolean): Bitmap? = @@ -104,7 +117,7 @@ constructor( private suspend fun withRequestRecord( uri: Uri, caching: Boolean, - block: suspend (RequestRecord) -> Bitmap? + block: suspend (RequestRecord) -> Bitmap?, ): Bitmap? { val record = trackRecordRunning(uri, caching) return try { 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..9bc8d3e2 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 @@ -16,6 +16,7 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -33,9 +34,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 @@ -52,8 +53,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -68,6 +71,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections +import com.android.intentresolver.Flags.shareouselSelectionShrink import com.android.intentresolver.Flags.unselectFinalItem import com.android.intentresolver.R import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate @@ -130,15 +134,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, @@ -245,43 +251,52 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, aspectRatio: F ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) } - Crossfade( - targetState = bitmapLoadState, - modifier = - Modifier.semantics { this.contentDescription = contentDescription } - .clip(RoundedCornerShape(size = 12.dp)) - .toggleable( - value = selected, - onValueChange = { scope.launch { viewModel.setSelected(it) } }, - ), - ) { state -> - if (state is ValueUpdate.Value) { - state.getOrDefault(null).let { bitmap -> - ShareouselCard( - image = { - bitmap?.let { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } ?: PlaceholderBox(aspectRatio) - }, - contentType = viewModel.contentType, - selected = selected, - modifier = - Modifier.thenIf(selected) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp), - ) + Box( + modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio), + contentAlignment = Alignment.Center, + ) { + Crossfade( + targetState = bitmapLoadState, + modifier = + Modifier.semantics { this.contentDescription = contentDescription } + .toggleable( + value = selected, + onValueChange = { scope.launch { viewModel.setSelected(it) } }, + ) + .conditional(shareouselSelectionShrink()) { + val selectionScale by animateFloatAsState(if (selected) 0.95f else 1f) + scale(selectionScale) + } + .clip(RoundedCornerShape(size = 12.dp)), + ) { state -> + if (state is ValueUpdate.Value) { + state.getOrDefault(null).let { bitmap -> + ShareouselCard( + image = { + bitmap?.let { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } ?: PlaceholderBox(aspectRatio) }, - ) + contentType = viewModel.contentType, + selected = selected, + modifier = + Modifier.conditional(selected) { + border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp), + ) + }, + ) + } + } else { + PlaceholderBox(aspectRatio) } - } else { - PlaceholderBox(aspectRatio) } } } @@ -368,8 +383,11 @@ private fun ShareouselAction( ) } -inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = - if (condition) this.then(factory()) else this +@Composable +private inline fun Modifier.conditional( + condition: Boolean, + crossinline whenTrue: @Composable Modifier.() -> Modifier, +): Modifier = if (condition) this.whenTrue() else this private data class PreviewCarouselMeasurements( val viewportHeightPx: Int, diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 7f363949..6baf5935 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -16,14 +16,10 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.util.Size -import com.android.intentresolver.Flags.previewImageLoader import com.android.intentresolver.Flags.unselectFinalItem -import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader import com.android.intentresolver.contentpreview.HeadlineGenerator import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.MimeTypeClassifier -import com.android.intentresolver.contentpreview.PreviewImageLoader -import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor @@ -37,7 +33,6 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent -import javax.inject.Provider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -74,21 +69,9 @@ data class ShareouselViewModel( object ShareouselViewModelModule { @Provides - @PayloadToggle - fun imageLoader( - cachingImageLoader: Provider<CachingImagePreviewImageLoader>, - previewImageLoader: Provider<PreviewImageLoader>, - ): ImageLoader = - if (previewImageLoader()) { - previewImageLoader.get() - } else { - cachingImageLoader.get() - } - - @Provides fun create( interactor: SelectablePreviewsInteractor, - @PayloadToggle imageLoader: ImageLoader, + imageLoader: ImageLoader, actionsInteractor: CustomActionsInteractor, headlineGenerator: HeadlineGenerator, selectionInteractor: SelectionInteractor, diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt index c4aa2b98..ad338103 100644 --- a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -28,7 +28,9 @@ import android.service.chooser.ChooserAction import android.service.chooser.ChooserTarget import androidx.annotation.StringRes import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.IChooserInteractiveSessionCallback import com.android.intentresolver.ext.hasAction +import com.android.systemui.shared.Flags.screenshotContextUrl const val ANDROID_APP_SCHEME = "android-app" @@ -182,6 +184,7 @@ data class ChooserRequest( * Specified by the [Intent.EXTRA_METADATA_TEXT] */ val metadataText: CharSequence? = null, + val interactiveSessionCallback: IChooserInteractiveSessionCallback? = null, ) { val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority @@ -194,4 +197,7 @@ data class ChooserRequest( } val payloadIntents = listOf(targetIntent) + additionalTargets + + val callerAllowsTextToggle = + screenshotContextUrl() && "com.android.systemui".equals(referrerPackage) } diff --git a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt index 14177b1b..8b7885c9 100644 --- a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt +++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt @@ -25,10 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @ViewModelScoped class ChooserRequestRepository @Inject -constructor( - initialRequest: ChooserRequest, - initialActions: List<CustomActionModel>, -) { +constructor(val initialRequest: ChooserRequest, initialActions: List<CustomActionModel>) { /** All information from the sharing application pertaining to the chooser. */ val chooserRequest: MutableStateFlow<ChooserRequest> = MutableStateFlow(initialRequest) diff --git a/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt b/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt new file mode 100644 index 00000000..f8894de5 --- /dev/null +++ b/java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt @@ -0,0 +1,54 @@ +/* + * 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.interactive.data.repository + +import android.os.Bundle +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.IChooserController +import com.android.intentresolver.interactive.domain.model.ChooserIntentUpdater +import dagger.hilt.android.scopes.ViewModelScoped +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject + +private const val INTERACTIVE_SESSION_CALLBACK_KEY = "interactive-session-callback" + +@ViewModelScoped +class InteractiveSessionCallbackRepository @Inject constructor(savedStateHandle: SavedStateHandle) { + private val intentUpdaterRef = + AtomicReference<ChooserIntentUpdater?>( + savedStateHandle + .get<Bundle>(INTERACTIVE_SESSION_CALLBACK_KEY) + ?.let { it.getBinder(INTERACTIVE_SESSION_CALLBACK_KEY) } + ?.let { binder -> + binder.queryLocalInterface(IChooserController.DESCRIPTOR) + as? ChooserIntentUpdater + } + ) + + val intentUpdater: ChooserIntentUpdater? + get() = intentUpdaterRef.get() + + init { + savedStateHandle.setSavedStateProvider(INTERACTIVE_SESSION_CALLBACK_KEY) { + Bundle().apply { putBinder(INTERACTIVE_SESSION_CALLBACK_KEY, intentUpdater) } + } + } + + fun setChooserIntentUpdater(intentUpdater: ChooserIntentUpdater) { + intentUpdaterRef.compareAndSet(null, intentUpdater) + } +} diff --git a/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt b/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt new file mode 100644 index 00000000..09b79985 --- /dev/null +++ b/java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt @@ -0,0 +1,139 @@ +/* + * 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.interactive.domain.interactor + +import android.content.Intent +import android.os.Bundle +import android.os.IBinder +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +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.interactive.data.repository.InteractiveSessionCallbackRepository +import com.android.intentresolver.interactive.domain.model.ChooserIntentUpdater +import com.android.intentresolver.ui.viewmodel.readChooserRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val TAG = "ChooserSession" + +@ViewModelScoped +class InteractiveSessionInteractor +@Inject +constructor( + activityModelRepo: ActivityModelRepository, + private val chooserRequestRepository: ChooserRequestRepository, + private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, + private val interactiveCallbackRepo: InteractiveSessionCallbackRepository, +) { + private val activityModel = activityModelRepo.value + private val sessionCallback = + chooserRequestRepository.initialRequest.interactiveSessionCallback?.let { + SafeChooserInteractiveSessionCallback(it) + } + val isSessionActive = MutableStateFlow(true) + + suspend fun activate() = coroutineScope { + if (sessionCallback == null || activityModel.isTaskRoot) { + sessionCallback?.registerChooserController(null) + return@coroutineScope + } + launch { + val callbackBinder: IBinder = sessionCallback.asBinder() + if (callbackBinder.isBinderAlive) { + val deathRecipient = IBinder.DeathRecipient { isSessionActive.value = false } + callbackBinder.linkToDeath(deathRecipient, 0) + try { + awaitCancellation() + } finally { + runCatching { sessionCallback.asBinder().unlinkToDeath(deathRecipient, 0) } + } + } else { + isSessionActive.value = false + } + } + val chooserIntentUpdater = + interactiveCallbackRepo.intentUpdater + ?: ChooserIntentUpdater().also { + interactiveCallbackRepo.setChooserIntentUpdater(it) + sessionCallback.registerChooserController(it) + } + chooserIntentUpdater.chooserIntent.collect { onIntentUpdated(it) } + } + + fun sendTopDrawerTopOffsetChange(offset: Int) { + sessionCallback?.onDrawerVerticalOffsetChanged(offset) + } + + fun endSession() { + sessionCallback?.registerChooserController(null) + } + + private fun onIntentUpdated(chooserIntent: Intent?) { + if (chooserIntent == null) { + isSessionActive.value = false + return + } + + val result = + readChooserRequest( + chooserIntent.extras ?: Bundle(), + activityModel.launchedFromPackage, + activityModel.referrer, + ) + when (result) { + is Valid<ChooserRequest> -> { + val newRequest = result.value + pendingSelectionCallbackRepo.pendingTargetIntent.compareAndSet( + null, + result.value.targetIntent, + ) + chooserRequestRepository.chooserRequest.update { + it.copy( + targetIntent = newRequest.targetIntent, + targetAction = newRequest.targetAction, + isSendActionTarget = newRequest.isSendActionTarget, + targetType = newRequest.targetType, + filteredComponentNames = newRequest.filteredComponentNames, + callerChooserTargets = newRequest.callerChooserTargets, + additionalTargets = newRequest.additionalTargets, + replacementExtras = newRequest.replacementExtras, + initialIntents = newRequest.initialIntents, + shareTargetFilter = newRequest.shareTargetFilter, + chosenComponentSender = newRequest.chosenComponentSender, + refinementIntentSender = newRequest.refinementIntentSender, + ) + } + pendingSelectionCallbackRepo.pendingTargetIntent.compareAndSet( + result.value.targetIntent, + null, + ) + } + is Invalid -> { + result.errors.forEach { it.log(TAG) } + } + } + } +} diff --git a/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt b/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt new file mode 100644 index 00000000..d746a3b5 --- /dev/null +++ b/java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt @@ -0,0 +1,43 @@ +/* + * 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.interactive.domain.interactor + +import android.util.Log +import com.android.intentresolver.IChooserController +import com.android.intentresolver.IChooserInteractiveSessionCallback + +private const val TAG = "SessionCallback" + +class SafeChooserInteractiveSessionCallback( + private val delegate: IChooserInteractiveSessionCallback +) : IChooserInteractiveSessionCallback by delegate { + + override fun registerChooserController(updater: IChooserController?) { + if (!isAlive) return + runCatching { delegate.registerChooserController(updater) } + .onFailure { Log.e(TAG, "Failed to invoke registerChooserController", it) } + } + + override fun onDrawerVerticalOffsetChanged(offset: Int) { + if (!isAlive) return + runCatching { delegate.onDrawerVerticalOffsetChanged(offset) } + .onFailure { Log.e(TAG, "Failed to invoke onDrawerVerticalOffsetChanged", it) } + } + + private val isAlive: Boolean + get() = delegate.asBinder().isBinderAlive +} diff --git a/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt b/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt new file mode 100644 index 00000000..5466a95d --- /dev/null +++ b/java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt @@ -0,0 +1,36 @@ +/* + * 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.interactive.domain.model + +import android.content.Intent +import com.android.intentresolver.IChooserController +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter + +private val NotSet = Intent() + +class ChooserIntentUpdater : IChooserController.Stub() { + private val updates = MutableStateFlow<Intent?>(NotSet) + + val chooserIntent: Flow<Intent?> + get() = updates.filter { it !== NotSet } + + override fun updateIntent(chooserIntent: Intent?) { + updates.value = chooserIntent + } +} diff --git a/java/src/com/android/intentresolver/shared/model/ActivityModel.kt b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt index c5efdeba..1a57759d 100644 --- a/java/src/com/android/intentresolver/shared/model/ActivityModel.kt +++ b/java/src/com/android/intentresolver/shared/model/ActivityModel.kt @@ -35,6 +35,8 @@ data class ActivityModel( val launchedFromPackage: String, /** The referrer as supplied to the activity. */ val referrer: Uri?, + /** True if the activity is the first activity in the task */ + val isTaskRoot: Boolean, ) : Parcelable { constructor( source: Parcel @@ -43,6 +45,7 @@ data class ActivityModel( launchedFromUid = source.readInt(), launchedFromPackage = requireNotNull(source.readString()), referrer = source.readParcelable(), + isTaskRoot = source.readBoolean(), ) /** A package name from referrer, if it is an android-app URI */ @@ -55,6 +58,7 @@ data class ActivityModel( dest.writeInt(launchedFromUid) dest.writeString(launchedFromPackage) dest.writeParcelable(referrer, flags) + dest.writeBoolean(isTaskRoot) } companion object { @@ -74,6 +78,7 @@ data class ActivityModel( activity.launchedFromUid, Objects.requireNonNull<String>(activity.launchedFromPackage), activity.referrer, + activity.isTaskRoot, ) } } diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 828d8561..41f838ee 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,7 +35,6 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread -import com.android.intentresolver.Flags.fixShortcutLoaderJobLeak import com.android.intentresolver.Flags.fixShortcutsFlashing import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.measurements.Tracer @@ -80,8 +79,7 @@ constructor( private val dispatcher: CoroutineDispatcher, private val callback: Consumer<Result>, ) { - private val scope = - if (fixShortcutLoaderJobLeak()) parentScope.createChildScope() else parentScope + private val scope = parentScope.createChildScope() private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager private val appPredictorWatchdog = AtomicReference<Job?>(null) @@ -170,9 +168,7 @@ constructor( @OpenForTesting open fun destroy() { - if (fixShortcutLoaderJobLeak()) { - scope.cancel() - } + scope.cancel() } @WorkerThread diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt index 13de84b2..cb4bdcc1 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -40,9 +40,11 @@ import android.content.IntentSender import android.net.Uri import android.os.Bundle import android.service.chooser.ChooserAction +import android.service.chooser.ChooserSession import android.service.chooser.ChooserTarget import com.android.intentresolver.ChooserActivity import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.Flags.interactiveSession import com.android.intentresolver.R import com.android.intentresolver.data.model.ChooserRequest import com.android.intentresolver.ext.hasSendAction @@ -58,6 +60,8 @@ import com.android.intentresolver.validation.validateFrom private const val MAX_CHOOSER_ACTIONS = 5 private const val MAX_INITIAL_INTENTS = 2 +private const val EXTRA_CHOOSER_INTERACTIVE_CALLBACK = + "com.android.extra.EXTRA_CHOOSER_INTERACTIVE_CALLBACK" internal fun Intent.maybeAddSendActionFlags() = ifMatch(Intent::hasSendAction) { @@ -69,6 +73,14 @@ fun readChooserRequest( model: ActivityModel, savedState: Bundle = model.intent.extras ?: Bundle(), ): ValidationResult<ChooserRequest> { + return readChooserRequest(savedState, model.launchedFromPackage, model.referrer) +} + +fun readChooserRequest( + savedState: Bundle, + launchedFromPackage: String, + referrer: Uri?, +): ValidationResult<ChooserRequest> { @Suppress("DEPRECATION") return validateFrom(savedState::get) { val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() @@ -139,18 +151,26 @@ fun readChooserRequest( val metadataText = optional(value<CharSequence>(EXTRA_METADATA_TEXT)) + val interactiveSessionCallback = + if (interactiveSession()) { + optional(value<ChooserSession>(EXTRA_CHOOSER_INTERACTIVE_CALLBACK)) + ?.sessionCallbackBinder + } else { + null + } + ChooserRequest( targetIntent = targetIntent, targetAction = targetIntent.action, isSendActionTarget = isSendAction, targetType = targetIntent.type, launchedFromPackage = - requireNotNull(model.launchedFromPackage) { + requireNotNull(launchedFromPackage) { "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" }, title = customTitle, defaultTitleResource = defaultTitleResource, - referrer = model.referrer, + referrer = referrer, filteredComponentNames = filteredComponents, callerChooserTargets = callerChooserTargets, chooserActions = chooserActions, @@ -168,6 +188,7 @@ fun readChooserRequest( focusedItemPosition = focusedItemPos, contentTypeHint = contentTypeHint, metadataText = metadataText, + interactiveSessionCallback = interactiveSessionCallback, ) } } diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt index 8597d802..7bc811c0 100644 --- a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -21,6 +21,7 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.intentresolver.Flags.interactiveSession import com.android.intentresolver.Flags.saveShareouselState import com.android.intentresolver.contentpreview.ImageLoader import com.android.intentresolver.contentpreview.PreviewDataProvider @@ -32,6 +33,7 @@ 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.interactive.domain.interactor.InteractiveSessionInteractor import com.android.intentresolver.shared.model.ActivityModel import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.Valid @@ -67,6 +69,7 @@ constructor( private val chooserRequestRepository: Lazy<ChooserRequestRepository>, private val contentResolver: ContentInterface, val imageLoader: ImageLoader, + private val interactiveSessionInteractorLazy: Lazy<InteractiveSessionInteractor>, ) : ViewModel() { /** Parcelable-only references provided from the creating Activity */ @@ -98,6 +101,9 @@ constructor( ) } + val interactiveSessionInteractor: InteractiveSessionInteractor + get() = interactiveSessionInteractorLazy.get() + init { when (initialRequest) { is Invalid -> { @@ -116,6 +122,9 @@ constructor( } } } + if (interactiveSession()) { + viewModelScope.launch(bgDispatcher) { interactiveSessionInteractor.activate() } + } } } } diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index 07693b25..4895a2cd 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -278,6 +278,10 @@ public class ResolverDrawerLayout extends ViewGroup { mDismissLocked = locked; } + int getTopOffset() { + return mTopOffset; + } + private boolean isMoving() { return mIsDragging || !mScroller.isFinished(); } diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt b/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt new file mode 100644 index 00000000..0c537a12 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt @@ -0,0 +1,51 @@ +/* + * 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. + */ + +@file:JvmName("ResolverDrawerLayoutExt") + +package com.android.intentresolver.widget + +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup.MarginLayoutParams + +fun ResolverDrawerLayout.getVisibleDrawerRect(outRect: Rect) { + if (!isLaidOut) { + outRect.set(0, 0, 0, 0) + return + } + val firstChild = firstNonGoneChild() + val lp = firstChild?.layoutParams as? MarginLayoutParams + val margin = lp?.topMargin ?: 0 + val top = maxOf(paddingTop, topOffset + margin) + val leftEdge = paddingLeft + val rightEdge = width - paddingRight + val widthAvailable = rightEdge - leftEdge + val childWidth = firstChild?.width ?: 0 + val left = leftEdge + (widthAvailable - childWidth) / 2 + val right = left + childWidth + outRect.set(left, top, right, height - paddingBottom) +} + +fun ResolverDrawerLayout.firstNonGoneChild(): View? { + for (i in 0 until childCount) { + val view = getChildAt(i) + if (view.visibility != View.GONE) { + return view + } + } + return null +} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt deleted file mode 100644 index d5a569aa..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,280 +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 - -import android.graphics.Bitmap -import android.net.Uri -import android.util.Size -import com.google.common.truth.Truth.assertThat -import kotlin.math.ceil -import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class CachingImagePreviewImageLoaderTest { - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - private val testJobTime = 100.milliseconds - private val testCacheSize = 4 - private val testMaxConcurrency = 2 - private val testTimeToFillCache = - testJobTime * ceil((testCacheSize).toFloat() / testMaxConcurrency.toFloat()).roundToInt() - private val testUris = - List(5) { Uri.fromParts("TestScheme$it", "TestSsp$it", "TestFragment$it") } - private val previewSize = Size(500, 500) - private val testTimeToLoadAllUris = - testJobTime * ceil((testUris.size).toFloat() / testMaxConcurrency.toFloat()).roundToInt() - private val testBitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8) - private val fakeThumbnailLoader = - FakeThumbnailLoader().apply { - testUris.forEach { - fakeInvoke[it] = { - delay(testJobTime) - testBitmap - } - } - } - - private val imageLoader = - CachingImagePreviewImageLoader( - scope = testScope.backgroundScope, - bgDispatcher = testDispatcher, - thumbnailLoader = fakeThumbnailLoader, - cacheSize = testCacheSize, - maxConcurrency = testMaxConcurrency, - ) - - @Test - fun loadImage_notCached_callsThumbnailLoader() = - testScope.runTest { - // Arrange - var result: Bitmap? = null - - // Act - imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) - assertThat(result).isSameInstanceAs(testBitmap) - } - - @Test - fun loadImage_cached_usesCachedValue() = - testScope.runTest { - // Arrange - imageLoader.loadImage(testScope, testUris[0], previewSize) {} - advanceTimeBy(testJobTime) - runCurrent() - fakeThumbnailLoader.invokeCalls.clear() - var result: Bitmap? = null - - // Act - imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() - assertThat(result).isSameInstanceAs(testBitmap) - } - - @Test - fun loadImage_error_returnsNull() = - testScope.runTest { - // Arrange - fakeThumbnailLoader.fakeInvoke[testUris[0]] = { - delay(testJobTime) - throw RuntimeException("Test exception") - } - var result: Bitmap? = testBitmap - - // Act - imageLoader.loadImage(testScope, testUris[0], previewSize) { result = it } - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) - assertThat(result).isNull() - } - - @Test - fun loadImage_uncached_limitsConcurrency() = - testScope.runTest { - // Arrange - val results = mutableListOf<Bitmap?>() - assertThat(testUris.size).isGreaterThan(testMaxConcurrency) - - // Act - testUris.take(testMaxConcurrency + 1).forEach { uri -> - imageLoader.loadImage(testScope, uri, previewSize) { results.add(it) } - } - - // Assert - assertThat(results).isEmpty() - advanceTimeBy(testJobTime) - runCurrent() - assertThat(results).hasSize(testMaxConcurrency) - advanceTimeBy(testJobTime) - runCurrent() - assertThat(results).hasSize(testMaxConcurrency + 1) - assertThat(results) - .containsExactlyElementsIn(List(testMaxConcurrency + 1) { testBitmap }) - } - - @Test - fun loadImage_cacheEvicted_cancelsLoadAndReturnsNull() = - testScope.runTest { - // Arrange - val results = MutableList<Bitmap?>(testUris.size) { null } - assertThat(testUris.size).isGreaterThan(testCacheSize) - - // Act - imageLoader.loadImage(testScope, testUris[0], previewSize) { results[0] = it } - runCurrent() - testUris.indices.drop(1).take(testCacheSize).forEach { i -> - imageLoader.loadImage(testScope, testUris[i], previewSize) { results[i] = it } - } - advanceTimeBy(testTimeToFillCache) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(testUris) - assertThat(results) - .containsExactlyElementsIn( - List(testUris.size) { index -> if (index == 0) null else testBitmap } - ) - .inOrder() - assertThat(fakeThumbnailLoader.unfinishedInvokeCount).isEqualTo(1) - } - - @Test - fun prePopulate_fillsCache() = - testScope.runTest { - // Arrange - val fullCacheUris = testUris.take(testCacheSize) - assertThat(fullCacheUris).hasSize(testCacheSize) - - // Act - imageLoader.prePopulate(fullCacheUris.map { it to previewSize }) - advanceTimeBy(testTimeToFillCache) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(fullCacheUris) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(fullCacheUris.map { it to previewSize }) - advanceTimeBy(testTimeToFillCache) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() - } - - @Test - fun prePopulate_greaterThanCacheSize_fillsCacheThenDropsRemaining() = - testScope.runTest { - // Arrange - assertThat(testUris.size).isGreaterThan(testCacheSize) - - // Act - imageLoader.prePopulate(testUris.map { it to previewSize }) - advanceTimeBy(testTimeToLoadAllUris) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls) - .containsExactlyElementsIn(testUris.take(testCacheSize)) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(testUris.map { it to previewSize }) - advanceTimeBy(testTimeToLoadAllUris) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() - } - - @Test - fun prePopulate_fewerThatCacheSize_loadsTheGiven() = - testScope.runTest { - // Arrange - val unfilledCacheUris = testUris.take(testMaxConcurrency) - assertThat(unfilledCacheUris.size).isLessThan(testCacheSize) - - // Act - imageLoader.prePopulate(unfilledCacheUris.map { it to previewSize }) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactlyElementsIn(unfilledCacheUris) - - // Act - fakeThumbnailLoader.invokeCalls.clear() - imageLoader.prePopulate(unfilledCacheUris.map { it to previewSize }) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).isEmpty() - } - - @Test - fun invoke_uncached_alwaysCallsTheThumbnailLoader() = - testScope.runTest { - // Arrange - - // Act - imageLoader.invoke(testUris[0], previewSize, caching = false) - imageLoader.invoke(testUris[0], previewSize, caching = false) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0], testUris[0]) - } - - @Test - fun invoke_cached_usesTheCacheWhenPossible() = - testScope.runTest { - // Arrange - - // Act - imageLoader.invoke(testUris[0], previewSize, caching = true) - imageLoader.invoke(testUris[0], previewSize, caching = true) - advanceTimeBy(testJobTime) - runCurrent() - - // Assert - assertThat(fakeThumbnailLoader.invokeCalls).containsExactly(testUris[0]) - } -} diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 1d85c61b..a944beee 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CheckBox import android.widget.TextView import androidx.annotation.IdRes import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -192,6 +193,7 @@ class FilesPlusTextContentPreviewUiTest { DefaultMimeTypeClassifier, headlineGenerator, testMetadataText, + /* allowTextToggle=*/ false, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -203,7 +205,7 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - headlineRow + headlineRow, ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) @@ -234,6 +236,7 @@ class FilesPlusTextContentPreviewUiTest { DefaultMimeTypeClassifier, headlineGenerator, testMetadataText, + /* allowTextToggle=*/ false, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -253,7 +256,7 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - headlineRow + headlineRow, ) verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) @@ -270,6 +273,73 @@ class FilesPlusTextContentPreviewUiTest { verifyPreviewMetadata(headlineRow, testMetadataText) } + @Test + fun test_allowToggle() { + val testSubject = + FilesPlusTextContentPreviewUi( + testScope, + /*isSingleImage=*/ false, + /* fileCount=*/ 1, + SHARED_TEXT, + /*intentMimeType=*/ "*/*", + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator, + testMetadataText, + /* allowTextToggle=*/ true, + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow, + ) + + val checkbox = headlineRow.requireViewById<CheckBox>(R.id.include_text_action) + assertThat(checkbox.visibility).isEqualTo(View.VISIBLE) + assertThat(checkbox.isChecked).isTrue() + } + + @Test + fun test_hideTextToggle() { + val testSubject = + FilesPlusTextContentPreviewUi( + testScope, + /*isSingleImage=*/ false, + /* fileCount=*/ 1, + SHARED_TEXT, + /*intentMimeType=*/ "*/*", + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator, + testMetadataText, + /* allowTextToggle=*/ false, + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val headlineRow = gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + headlineRow, + ) + + val checkbox = headlineRow.requireViewById<CheckBox>(R.id.include_text_action) + assertThat(checkbox.visibility).isNotEqualTo(View.VISIBLE) + } + private fun testLoadingHeadline( intentMimeType: String, sharedFileCount: Int, @@ -287,6 +357,7 @@ class FilesPlusTextContentPreviewUiTest { DefaultMimeTypeClassifier, headlineGenerator, testMetadataText, + /* allowTextToggle=*/ false, ) val layoutInflater = LayoutInflater.from(context) val gridLayout = @@ -307,7 +378,7 @@ class FilesPlusTextContentPreviewUiTest { context.resources, LayoutInflater.from(context), gridLayout, - headlineRow + headlineRow, ) to headlineRow } diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt deleted file mode 100644 index d78e6665..00000000 --- a/tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import android.content.ContentResolver -import android.graphics.Bitmap -import android.net.Uri -import android.util.Size -import com.google.common.truth.Truth.assertThat -import java.util.ArrayDeque -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit.MILLISECONDS -import java.util.concurrent.TimeUnit.SECONDS -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow -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 - -@OptIn(ExperimentalCoroutinesApi::class) -class ImagePreviewImageLoaderTest { - private val imageSize = Size(300, 300) - private val uriOne = Uri.parse("content://org.package.app/image-1.png") - private val uriTwo = Uri.parse("content://org.package.app/image-2.png") - private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - private val contentResolver = - mock<ContentResolver> { on { loadThumbnail(any(), any(), anyOrNull()) } doReturn bitmap } - private val scheduler = TestCoroutineScheduler() - private val dispatcher = UnconfinedTestDispatcher(scheduler) - private val scope = TestScope(dispatcher) - private val testSubject = - ImagePreviewImageLoader( - dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - private val previewSize = Size(500, 500) - - @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = - scope.runTest { - testSubject.prePopulate(listOf(uriOne to previewSize, uriTwo to previewSize)) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - - testSubject(uriOne, previewSize) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_returnCachedImageWhenCalledTwice() = - scope.runTest { - testSubject(uriOne, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_whenInstructed_doesNotCache() = - scope.runTest { - testSubject(uriOne, previewSize, false) - testSubject(uriOne, previewSize, false) - - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_overlappedRequests_Deduplicate() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - scheduler.advanceUntilIdle() - } - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_oldRecordsEvictedFromTheCache() = - scope.runTest { - testSubject(uriOne, previewSize) - testSubject(uriTwo, previewSize) - testSubject(uriTwo, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } - - @Test - fun invoke_doNotCacheNulls() = - scope.runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne, previewSize) - testSubject(uriOne, previewSize) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } - - @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = - scope.runTest { - val imageLoaderScope = CoroutineScope(coroutineContext) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - imageLoaderScope.cancel() - testSubject(uriOne, previewSize) - } - - @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - val deferred = - async(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - imageLoaderScope.cancel() - scheduler.advanceUntilIdle() - deferred.await() - } - } - - @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = - scope.runTest { - val dispatcher = StandardTestDispatcher(scheduler) - val imageLoaderScope = CoroutineScope(coroutineContext + dispatcher) - val testSubject = - ImagePreviewImageLoader( - imageLoaderScope, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, true) } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, previewSize, true) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_semaphoreGuardsContentResolverCalls() = - scope.runTest { - val contentResolver = - mock<ContentResolver> { - on { loadThumbnail(any(), any(), anyOrNull()) } doThrow - SecurityException("test") - } - val acquireCount = AtomicInteger() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - acquireCount.getAndIncrement() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher), - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - testSubject(uriOne, previewSize, false) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(acquireCount.get()).isEqualTo(1) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = - scope.runTest { - val semaphoreDeferred = CompletableDeferred<Unit>() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - semaphoreDeferred.await() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher), - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - launch(start = UNDISPATCHED) { testSubject(uriOne, previewSize, false) } - - verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) - - semaphoreDeferred.complete(Unit) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() = - scope.runTest { - val requestCount = 4 - val thumbnailCallsCdl = CountDownLatch(requestCount) - val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() - val contentResolver = - mock<ContentResolver> { - on { loadThumbnail(any(), any(), anyOrNull()) } doAnswer - { - val latch = CountDownLatch(1) - synchronized(pendingThumbnailCalls) { - pendingThumbnailCalls.offer(latch) - } - thumbnailCallsCdl.countDown() - assertTrue("Timeout waiting thumbnail calls", latch.await(1, SECONDS)) - bitmap - } - } - val name = "LoadImage" - val maxSimultaneousRequests = 2 - val threadsStartedCdl = CountDownLatch(requestCount) - val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } - val testSubject = - ImagePreviewImageLoader( - CoroutineScope(coroutineContext + dispatcher + CoroutineName(name)), - imageSize.width, - contentResolver, - cacheSize = 1, - maxSimultaneousRequests, - ) - coroutineScope { - repeat(requestCount) { - launch { - testSubject(Uri.parse("content://org.pkg.app/image-$it.png"), previewSize) - } - } - yield() - // wait for all requests to be dispatched - assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() - - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - for (cdl in pendingThumbnailCalls) { - cdl.countDown() - } - } - } -} - -private class NewThreadDispatcher( - private val coroutineName: String, - private val launchedCallback: () -> Unit -) : CoroutineDispatcher() { - override fun isDispatchNeeded(context: CoroutineContext): Boolean = true - - override fun dispatch(context: CoroutineContext, block: Runnable) { - Thread { - if (coroutineName == context[CoroutineName.Key]?.name) { - launchedCallback() - } - block.run() - } - .start() - } -} diff --git a/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt b/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt new file mode 100644 index 00000000..75d4ec0d --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt @@ -0,0 +1,420 @@ +/* + * 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.interactive.domain.interactor + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_QUICK_VIEW +import android.content.Intent.ACTION_RUN +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_VIEW +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +import android.content.IntentSender +import android.os.Binder +import android.os.IBinder +import android.os.IBinder.DeathRecipient +import android.os.IInterface +import android.os.Parcel +import android.os.ResultReceiver +import android.os.ShellCallback +import android.service.chooser.ChooserTarget +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.IChooserController +import com.android.intentresolver.IChooserInteractiveSessionCallback +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +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.interactive.data.repository.InteractiveSessionCallbackRepository +import com.android.intentresolver.shared.model.ActivityModel +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import java.io.FileDescriptor +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class InteractiveSessionInteractorTest { + private val activityModelRepo = + ActivityModelRepository().apply { + initialize { + ActivityModel( + intent = Intent(), + launchedFromUid = 12345, + launchedFromPackage = "org.client.package", + referrer = null, + isTaskRoot = false, + ) + } + } + private val interactiveSessionCallback = FakeChooserInteractiveSessionCallback() + private val pendingSelectionCallbackRepo = PendingSelectionCallbackRepository() + private val savedStateHandle = SavedStateHandle() + private val interactiveCallbackRepo = InteractiveSessionCallbackRepository(savedStateHandle) + + @Test + fun testChooserLaunchedInNewTask_sessionClosed() = runTest { + val activityModelRepo = + ActivityModelRepository().apply { + initialize { + ActivityModel( + intent = Intent(), + launchedFromUid = 12345, + launchedFromPackage = "org.client.package", + referrer = null, + isTaskRoot = true, + ) + } + } + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + testSubject.activate() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).containsExactly(null) + } + + @Test + fun testDeadBinder_sessionEnd() = runTest { + interactiveSessionCallback.isAlive = false + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + this.testScheduler.runCurrent() + + assertThat(testSubject.isSessionActive.value).isFalse() + } + + @Test + fun testBinderDies_sessionEnd() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + this.testScheduler.runCurrent() + + assertThat(testSubject.isSessionActive.value).isTrue() + assertThat(interactiveSessionCallback.linkedDeathRecipients).hasSize(1) + + interactiveSessionCallback.linkedDeathRecipients[0].binderDied() + + assertThat(testSubject.isSessionActive.value).isFalse() + } + + @Test + fun testScopeCancelled_unsubscribeFromBinder() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + val job = backgroundScope.launch { testSubject.activate() } + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.linkedDeathRecipients).hasSize(1) + assertThat(interactiveSessionCallback.unlinkedDeathRecipients).hasSize(0) + + job.cancel() + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.unlinkedDeathRecipients).hasSize(1) + } + + @Test + fun endSession_intentUpdaterCallbackReset() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1) + + testSubject.endSession() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(2) + assertThat(interactiveSessionCallback.registeredIntentUpdaters[1]).isNull() + } + + @Test + fun nullChooserIntentReceived_sessionEnds() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1) + interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent(null) + testScheduler.runCurrent() + + assertThat(testSubject.isSessionActive.value).isFalse() + } + + @Test + fun invalidChooserIntentReceived_intentIgnored() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1) + interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent(Intent()) + testScheduler.runCurrent() + + assertThat(testSubject.isSessionActive.value).isTrue() + assertThat(chooserRequestRepository.chooserRequest.value) + .isEqualTo(chooserRequestRepository.initialRequest) + } + + @Test + fun validChooserIntentReceived_chooserRequestUpdated() = runTest { + val chooserRequestRepository = + ChooserRequestRepository( + initialRequest = + ChooserRequest( + targetIntent = Intent(ACTION_SEND), + interactiveSessionCallback = interactiveSessionCallback, + launchedFromPackage = activityModelRepo.value.launchedFromPackage, + ), + initialActions = emptyList(), + ) + val testSubject = + InteractiveSessionInteractor( + activityModelRepo = activityModelRepo, + chooserRequestRepository = chooserRequestRepository, + pendingSelectionCallbackRepo, + interactiveCallbackRepo, + ) + + backgroundScope.launch { testSubject.activate() } + testScheduler.runCurrent() + + assertThat(interactiveSessionCallback.registeredIntentUpdaters).hasSize(1) + val newTargetIntent = Intent(ACTION_VIEW).apply { type = "image/png" } + val newFilteredComponents = arrayOf(ComponentName.unflattenFromString("com.app/.MainA")) + val newCallerTargets = + arrayOf( + ChooserTarget( + "A", + null, + 0.5f, + ComponentName.unflattenFromString("org.pkg/.Activity"), + null, + ) + ) + val newAdditionalIntents = arrayOf(Intent(ACTION_RUN)) + val newReplacementExtras = bundleOf("ONE" to 1, "TWO" to 2) + val newInitialIntents = arrayOf(Intent(ACTION_QUICK_VIEW)) + val newResultSender = IntentSender(Binder()) + val newRefinementSender = IntentSender(Binder()) + interactiveSessionCallback.registeredIntentUpdaters[0]!!.updateIntent( + Intent.createChooser(newTargetIntent, "").apply { + putExtra(EXTRA_EXCLUDE_COMPONENTS, newFilteredComponents) + putExtra(EXTRA_CHOOSER_TARGETS, newCallerTargets) + putExtra(EXTRA_ALTERNATE_INTENTS, newAdditionalIntents) + putExtra(EXTRA_REPLACEMENT_EXTRAS, newReplacementExtras) + putExtra(EXTRA_INITIAL_INTENTS, newInitialIntents) + putExtra(EXTRA_CHOOSER_RESULT_INTENT_SENDER, newResultSender) + putExtra(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER, newRefinementSender) + } + ) + testScheduler.runCurrent() + + assertThat(testSubject.isSessionActive.value).isTrue() + val updatedRequest = chooserRequestRepository.chooserRequest.value + assertThat(updatedRequest.targetAction).isEqualTo(newTargetIntent.action) + assertThat(updatedRequest.targetType).isEqualTo(newTargetIntent.type) + assertThat(updatedRequest.filteredComponentNames).containsExactly(newFilteredComponents[0]) + assertThat(updatedRequest.callerChooserTargets).containsExactly(newCallerTargets[0]) + assertThat(updatedRequest.additionalTargets) + .comparingElementsUsing<Intent, String>( + Correspondence.transforming({ it.action }, "action") + ) + .containsExactly(newAdditionalIntents[0].action) + assertThat(updatedRequest.replacementExtras!!.keySet()) + .containsExactlyElementsIn(newReplacementExtras.keySet()) + assertThat(updatedRequest.initialIntents) + .comparingElementsUsing<Intent, String>( + Correspondence.transforming({ it.action }, "action") + ) + .containsExactly(newInitialIntents[0].action) + assertThat(updatedRequest.chosenComponentSender).isEqualTo(newResultSender) + assertThat(updatedRequest.refinementIntentSender).isEqualTo(newRefinementSender) + } +} + +private class FakeChooserInteractiveSessionCallback : + IChooserInteractiveSessionCallback, IBinder, IInterface { + var isAlive = true + val registeredIntentUpdaters = ArrayList<IChooserController?>() + val linkedDeathRecipients = ArrayList<DeathRecipient>() + val unlinkedDeathRecipients = ArrayList<DeathRecipient>() + + override fun registerChooserController(intentUpdater: IChooserController?) { + registeredIntentUpdaters.add(intentUpdater) + } + + override fun onDrawerVerticalOffsetChanged(offset: Int) {} + + override fun asBinder() = this + + override fun getInterfaceDescriptor() = "" + + override fun pingBinder() = true + + override fun isBinderAlive() = isAlive + + override fun queryLocalInterface(descriptor: String): IInterface = + this@FakeChooserInteractiveSessionCallback + + override fun dump(fd: FileDescriptor, args: Array<out String>?) = Unit + + override fun dumpAsync(fd: FileDescriptor, args: Array<out String>?) = Unit + + override fun shellCommand( + `in`: FileDescriptor?, + out: FileDescriptor?, + err: FileDescriptor?, + args: Array<out String>, + shellCallback: ShellCallback?, + resultReceiver: ResultReceiver, + ) = Unit + + override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int) = true + + override fun linkToDeath(recipient: DeathRecipient, flags: Int) { + linkedDeathRecipients.add(recipient) + } + + override fun unlinkToDeath(recipient: DeathRecipient, flags: Int): Boolean { + unlinkedDeathRecipients.add(recipient) + return true + } +} diff --git a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt index d11cb460..8167f610 100644 --- a/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ b/tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt @@ -31,7 +31,6 @@ import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule import androidx.test.filters.SmallTest import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUTS_FLASHING -import com.android.intentresolver.Flags.FLAG_FIX_SHORTCUT_LOADER_JOB_LEAK import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.createAppTarget import com.android.intentresolver.createShareShortcutInfo @@ -109,7 +108,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -122,7 +121,7 @@ class ShortcutLoaderTest { // ignored createAppTarget( createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) + ), ) val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, atLeastOnce()) @@ -137,7 +136,7 @@ class ShortcutLoaderTest { assertArrayEquals( "Wrong input app targets in the result", appTargets, - result.appTargets + result.appTargets, ) assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) @@ -145,12 +144,12 @@ class ShortcutLoaderTest { assertEquals( "Wrong AppTarget in the cache", matchingAppTarget, - result.directShareAppTargetCache[shortcut] + result.directShareAppTargetCache[shortcut], ) assertEquals( "Wrong ShortcutInfo in the cache", matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] + result.directShareShortcutInfoCache[shortcut], ) } } @@ -162,7 +161,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -178,7 +177,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -191,19 +190,19 @@ class ShortcutLoaderTest { assertArrayEquals( "Wrong input app targets in the result", appTargets, - result.appTargets + result.appTargets, ) assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) for (shortcut in result.shortcutsByApp[0].shortcuts) { assertTrue( "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() + result.directShareAppTargetCache.isEmpty(), ) assertEquals( "Wrong ShortcutInfo in the cache", matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] + result.directShareShortcutInfoCache[shortcut], ) } } @@ -215,7 +214,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -231,7 +230,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -250,19 +249,19 @@ class ShortcutLoaderTest { assertArrayEquals( "Wrong input app targets in the result", appTargets, - result.appTargets + result.appTargets, ) assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) for (shortcut in result.shortcutsByApp[0].shortcuts) { assertTrue( "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() + result.directShareAppTargetCache.isEmpty(), ) assertEquals( "Wrong ShortcutInfo in the cache", matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] + result.directShareShortcutInfoCache[shortcut], ) } } @@ -274,7 +273,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -292,7 +291,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -307,19 +306,19 @@ class ShortcutLoaderTest { assertArrayEquals( "Wrong input app targets in the result", appTargets, - result.appTargets + result.appTargets, ) assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) for (shortcut in result.shortcutsByApp[0].shortcuts) { assertTrue( "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() + result.directShareAppTargetCache.isEmpty(), ) assertEquals( "Wrong ShortcutInfo in the cache", matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] + result.directShareShortcutInfoCache[shortcut], ) } } @@ -332,7 +331,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -348,7 +347,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -373,7 +372,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -386,7 +385,7 @@ class ShortcutLoaderTest { // ignored createAppTarget( createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) + ), ) val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() verify(appPredictor, atLeastOnce()) @@ -406,7 +405,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -422,7 +421,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -472,7 +471,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) verify(appPredictor, times(1)).requestPredictionUpdate() @@ -486,7 +485,7 @@ class ShortcutLoaderTest { listOf( ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) + createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1), ) val shortcutManager = mock<ShortcutManager> { @@ -502,7 +501,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) verify(shortcutManager, times(1)).getShareTargets(any()) @@ -530,7 +529,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) verify(appPredictor, never()).unregisterPredictionUpdates(any()) @@ -553,7 +552,7 @@ class ShortcutLoaderTest { isPersonalProfile = true, targetIntentFilter = null, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -575,7 +574,7 @@ class ShortcutLoaderTest { assertArrayEquals( "Wrong input app targets in the result", appTargets, - result.appTargets + result.appTargets, ) assertWithMessage("An empty result is expected").that(result.shortcutsByApp).isEmpty() } @@ -611,7 +610,6 @@ class ShortcutLoaderTest { } @Test - @EnableFlags(FLAG_FIX_SHORTCUT_LOADER_JOB_LEAK) fun test_ShortcutLoaderDestroyed_appPredictorCallbackUnregisteredAndWatchdogCancelled() { scope.runTest { val testSubject = @@ -623,7 +621,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(appTargets) @@ -637,7 +635,7 @@ class ShortcutLoaderTest { private fun testDisabledWorkProfileDoNotCallSystem( isUserRunning: Boolean = true, isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false + isQuietModeEnabled: Boolean = false, ) = scope.runTest { val userHandle = UserHandle.of(10) @@ -658,7 +656,7 @@ class ShortcutLoaderTest { false, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) @@ -669,7 +667,7 @@ class ShortcutLoaderTest { private fun testAlwaysCallSystemForMainProfile( isUserRunning: Boolean = true, isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false + isQuietModeEnabled: Boolean = false, ) = scope.runTest { val userHandle = UserHandle.of(10) @@ -690,7 +688,7 @@ class ShortcutLoaderTest { true, intentFilter, dispatcher, - callback + callback, ) testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) 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 5f86159c..b48a6422 100644 --- a/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt @@ -30,7 +30,7 @@ class ActivityModelTest { @Test fun testDefaultValues() { - val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null) + val input = ActivityModel(Intent(ACTION_CHOOSER), 0, "example.com", null, false) val output = input.toParcelAndBack() @@ -41,7 +41,13 @@ class ActivityModelTest { fun testCommonValues() { val intent = Intent(ACTION_CHOOSER).apply { putExtra(EXTRA_TEXT, "Test") } val input = - ActivityModel(intent, 1234, "com.example", Uri.parse("android-app://example.com")) + ActivityModel( + intent, + 1234, + "com.example", + Uri.parse("android-app://example.com"), + false, + ) val output = input.toParcelAndBack() @@ -56,6 +62,7 @@ class ActivityModelTest { launchedFromUid = 1000, launchedFromPackage = "other.example.com", referrer = Uri.parse("android-app://app.example.com"), + false, ) assertThat(launch1.referrerPackage).isEqualTo("app.example.com") @@ -69,6 +76,7 @@ class ActivityModelTest { launchedFromUid = 1000, launchedFromPackage = "example.com", referrer = Uri.parse("http://some.other.value"), + false, ) assertThat(launch.referrerPackage).isNull() @@ -82,6 +90,7 @@ class ActivityModelTest { launchedFromUid = 1000, launchedFromPackage = "example.com", referrer = null, + false, ) 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 71f28950..7bc1e785 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt @@ -28,6 +28,9 @@ import android.content.Intent.EXTRA_REFERRER import android.content.Intent.EXTRA_TEXT import android.content.Intent.EXTRA_TITLE import android.net.Uri +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule import androidx.core.net.toUri import androidx.core.os.bundleOf import com.android.intentresolver.ContentTypeHint @@ -37,13 +40,16 @@ import com.android.intentresolver.validation.Importance import com.android.intentresolver.validation.Invalid import com.android.intentresolver.validation.NoValue import com.android.intentresolver.validation.Valid +import com.android.systemui.shared.Flags import com.google.common.truth.Truth.assertThat +import org.junit.Rule import org.junit.Test private fun createActivityModel( targetIntent: Intent?, referrer: Uri? = null, additionalIntents: List<Intent>? = null, + launchedFromPackage: String = "com.android.example", ) = ActivityModel( Intent(ACTION_CHOOSER).apply { @@ -51,11 +57,13 @@ private fun createActivityModel( additionalIntents?.also { putExtra(EXTRA_ALTERNATE_INTENTS, it.toTypedArray()) } }, launchedFromUid = 10000, - launchedFromPackage = "com.android.example", - referrer = referrer ?: "android-app://com.android.example".toUri(), + launchedFromPackage = launchedFromPackage, + referrer = referrer ?: "android-app://$launchedFromPackage".toUri(), + false, ) class ChooserRequestTest { + @get:Rule val flagsRule = SetFlagsRule() @Test fun missingIntent() { @@ -264,4 +272,46 @@ class ChooserRequestTest { assertThat(request.sharedTextTitle).isEqualTo(title) } } + + @Test + @DisableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL) + fun testCallerAllowsTextToggle_flagOff() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val model = + createActivityModel(targetIntent = intent, launchedFromPackage = "com.android.systemui") + val result = readChooserRequest(model) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.callerAllowsTextToggle).isFalse() + } + + @Test + @EnableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL) + fun testCallerAllowsTextToggle_sysuiPackage() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val model = + createActivityModel(targetIntent = intent, launchedFromPackage = "com.android.systemui") + val result = readChooserRequest(model) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.callerAllowsTextToggle).isTrue() + } + + @Test + @EnableFlags(Flags.FLAG_SCREENSHOT_CONTEXT_URL) + fun testCallerAllowsTextToggle_otherPackage() { + val intent = Intent().putExtras(bundleOf(EXTRA_INTENT to Intent(ACTION_SEND))) + val model = + createActivityModel(targetIntent = intent, launchedFromPackage = "com.hello.world") + val result = readChooserRequest(model) + + assertThat(result).isInstanceOf(Valid::class.java) + result as Valid<ChooserRequest> + + assertThat(result.value.callerAllowsTextToggle).isFalse() + } } 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 70512021..be6560c2 100644 --- a/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt +++ b/tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt @@ -40,6 +40,7 @@ private fun createActivityModel(targetIntent: Intent, referrer: Uri? = null) = launchedFromUid = 10000, launchedFromPackage = "com.android.example", referrer = referrer ?: "android-app://com.android.example".toUri(), + false, ) class ResolverRequestTest { |