summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Android.bp5
-rw-r--r--AndroidManifest-app.xml2
-rw-r--r--aconfig/FeatureFlags.aconfig29
-rw-r--r--java/aidl/com/android/intentresolver/IChooserController.aidl8
-rw-r--r--java/aidl/com/android/intentresolver/IChooserInteractiveSessionCallback.aidl9
-rw-r--r--java/res/color/resolver_profile_tab_text.xml2
-rw-r--r--java/res/drawable/resolver_profile_tab_bg.xml2
-rw-r--r--java/res/layout/resolver_profile_tab_button.xml1
-rw-r--r--java/res/values-iw/strings.xml2
-rw-r--r--java/src/android/service/chooser/ChooserSession.kt39
-rw-r--r--java/src/com/android/intentresolver/ChooserActivity.java80
-rw-r--r--java/src/com/android/intentresolver/ChooserHelper.kt9
-rw-r--r--java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt110
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java3
-rw-r--r--java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java9
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt15
-rw-r--r--java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt178
-rw-r--r--java/src/com/android/intentresolver/contentpreview/PreviewImageLoader.kt17
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt104
-rw-r--r--java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt19
-rw-r--r--java/src/com/android/intentresolver/data/model/ChooserRequest.kt6
-rw-r--r--java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt5
-rw-r--r--java/src/com/android/intentresolver/interactive/data/repository/InteractiveSessionCallbackRepository.kt54
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractor.kt139
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/interactor/SafeChooserInteractiveSessionCallback.kt43
-rw-r--r--java/src/com/android/intentresolver/interactive/domain/model/ChooserIntentUpdater.kt36
-rw-r--r--java/src/com/android/intentresolver/shared/model/ActivityModel.kt5
-rw-r--r--java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt8
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt25
-rw-r--r--java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt9
-rw-r--r--java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java4
-rw-r--r--java/src/com/android/intentresolver/widget/ResolverDrawerLayoutExt.kt51
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoaderTest.kt280
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt77
-rw-r--r--tests/unit/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt375
-rw-r--r--tests/unit/src/com/android/intentresolver/interactive/domain/interactor/InteractiveSessionInteractorTest.kt420
-rw-r--r--tests/unit/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt76
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/model/ActivityModelTest.kt13
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/viewmodel/ChooserRequestTest.kt54
-rw-r--r--tests/unit/src/com/android/intentresolver/ui/viewmodel/ResolverRequestTest.kt1
40 files changed, 1217 insertions, 1107 deletions
diff --git a/Android.bp b/Android.bp
index c0e09105..9dccb9f1 100644
--- a/Android.bp
+++ b/Android.bp
@@ -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 {