diff options
22 files changed, 336 insertions, 72 deletions
@@ -99,7 +99,7 @@ android_app { enabled: true, optimize: true, shrink: true, - shrink_resources: true, + optimized_shrink_resources: true, proguard_flags_files: ["proguard.flags"], }, visibility: ["//visibility:public"], diff --git a/aconfig/FeatureFlags.aconfig b/aconfig/FeatureFlags.aconfig index a102328a..71974cf8 100644 --- a/aconfig/FeatureFlags.aconfig +++ b/aconfig/FeatureFlags.aconfig @@ -71,6 +71,16 @@ flag { } flag { + name: "fix_partial_image_edit_transition" + namespace: "intentresolver" + description: "Do not run the shared element transition animation for a partially visible image" + bug: "339583191" + metadata { + purpose: PURPOSE_BUGFIX + } +} + +flag { name: "fix_private_space_locked_on_restart" namespace: "intentresolver" description: "Dismiss Share sheet on restart if private space became locked while stopped" diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml index 277d03d9..75fe0b77 100644 --- a/java/res/values-it/strings.xml +++ b/java/res/values-it/strings.xml @@ -102,10 +102,7 @@ <string name="exclude_link" msgid="1332778255031992228">"Escludi link"</string> <string name="include_link" msgid="827855767220339802">"Includi link"</string> <string name="pinned" msgid="7623664001331394139">"Elemento fissato"</string> - <!-- no translation found for selectable_image (3157858923437182271) --> - <skip /> - <!-- no translation found for selectable_video (1271768647699300826) --> - <skip /> - <!-- no translation found for selectable_item (7557320816744205280) --> - <skip /> + <string name="selectable_image" msgid="3157858923437182271">"Immagine selezionabile"</string> + <string name="selectable_video" msgid="1271768647699300826">"Video selezionabile"</string> + <string name="selectable_item" msgid="7557320816744205280">"Elemento selezionabile"</string> </resources> diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml index dc4257b2..faf27da5 100644 --- a/java/res/values-sq/strings.xml +++ b/java/res/values-sq/strings.xml @@ -102,10 +102,7 @@ <string name="exclude_link" msgid="1332778255031992228">"Përjashto lidhjen"</string> <string name="include_link" msgid="827855767220339802">"Përfshi lidhjen"</string> <string name="pinned" msgid="7623664001331394139">"U gozhdua"</string> - <!-- no translation found for selectable_image (3157858923437182271) --> - <skip /> - <!-- no translation found for selectable_video (1271768647699300826) --> - <skip /> - <!-- no translation found for selectable_item (7557320816744205280) --> - <skip /> + <string name="selectable_image" msgid="3157858923437182271">"Imazh që mund të zgjidhet"</string> + <string name="selectable_video" msgid="1271768647699300826">"Video që mund të zgjidhet"</string> + <string name="selectable_item" msgid="7557320816744205280">"Artikull që mund të zgjidhet"</string> </resources> diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index dae1ab52..cc7091e4 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -16,6 +16,8 @@ package com.android.intentresolver; +import static com.android.intentresolver.widget.ViewExtensionsKt.isFullyVisible; + import android.app.Activity; import android.app.ActivityOptions; import android.app.PendingIntent; @@ -131,7 +133,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, Consumer</* @Nullable */ Integer> finishCallback, - ClipboardManager clipboardManager) { + ClipboardManager clipboardManager, + FeatureFlags featureFlags) { this( context, makeCopyButtonRunnable( @@ -147,7 +150,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio imageEditor), firstVisibleImageQuery, activityStarter, - log), + log, + featureFlags.fixPartialImageEditTransition()), chooserActions, onUpdateSharedTextIsExcluded, log, @@ -336,7 +340,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - EventLog log) { + EventLog log, + boolean requireFullVisibility) { if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. @@ -347,7 +352,8 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio firstImageView = firstVisibleImageQuery.call(); } catch (Exception e) { /* ignore */ } // Action bar is user-independent; always start as primary. - if (firstImageView == null) { + if (firstImageView == null + || (requireFullVisibility && !isFullyVisible(firstImageView))) { activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); } else { activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 7353ff37..670512ac 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -2212,7 +2212,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, mShareResultSender, this::finishWithStatus, - mClipboardManager); + mClipboardManager, + mFeatureFlags); } private Supplier<ActionRow.Action> createModifyShareActionFactory() { diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index 8b848e55..ff0c40d7 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -479,17 +479,23 @@ public class ChooserListAdapter extends ResolverListAdapter { private void loadDirectShareIcon(SelectableTargetInfo info) { if (mRequestedIcons.add(info)) { - mTargetDataLoader.loadDirectShareIcon( + Drawable icon = mTargetDataLoader.getOrLoadDirectShareIcon( info, getUserHandle(), - (drawable) -> onDirectShareIconLoaded(info, drawable)); + (drawable) -> onDirectShareIconLoaded(info, drawable, true)); + if (icon != null) { + onDirectShareIconLoaded(info, icon, false); + } } } - private void onDirectShareIconLoaded(SelectableTargetInfo mTargetInfo, Drawable icon) { + private void onDirectShareIconLoaded( + SelectableTargetInfo mTargetInfo, @Nullable Drawable icon, boolean notify) { if (icon != null && !mTargetInfo.hasDisplayIcon()) { mTargetInfo.getDisplayIconHolder().setDisplayIcon(icon); - notifyDataSetChanged(); + if (notify) { + notifyDataSetChanged(); + } } } diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index ce064cdf..2e2aa938 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -28,6 +28,7 @@ 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.launch @@ -104,6 +105,10 @@ constructor( // [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/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 629651a3..81913a8e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -35,6 +35,9 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm /** Prepopulate the image loader cache. */ fun prePopulate(uris: List<Uri>) + /** Returns a bitmap for the given URI if it's already cached, otherwise null */ + fun getCachedBitmap(uri: Uri): Bitmap? = null + /** Load preview image; caching is allowed. */ override suspend fun invoke(uri: Uri) = invoke(uri, true) 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 8e2626bf..c63055d2 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 @@ -15,6 +15,7 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -57,6 +58,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel @@ -124,14 +127,14 @@ private fun PreviewCarousel( } } - ShareouselCard(viewModel.preview(model, previewIndex)) + ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope())) } } } @Composable private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { - val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() @@ -141,39 +144,56 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) } - ShareouselCard( - image = { - // TODO: max ratio is actually equal to the viewport ratio - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - bitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } - ?: run { - // TODO: look at ScrollableImagePreviewView.setLoading() - Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) - } - }, - contentType = viewModel.contentType, - selected = selected, + Crossfade( + targetState = bitmapLoadState, modifier = - Modifier.thenIf(selected) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp), - ) - } - .semantics { this.contentDescription = contentDescription } + Modifier.semantics { this.contentDescription = contentDescription } .clip(RoundedCornerShape(size = 12.dp)) .toggleable( value = selected, onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) + ) { state -> + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + 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), + ) + } + ) + } + } else { + PlaceholderBox(aspectRatio) + } + } +} + +@Composable +private fun PlaceholderBox(aspectRatio: Float) { + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(aspectRatio) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) ) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt index 540229c9..de435290 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -17,13 +17,15 @@ package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow /** An individual preview within Shareousel. */ data class ShareouselPreviewViewModel( /** Image to be shared. */ - val bitmap: Flow<Bitmap?>, + val bitmapLoadState: StateFlow<ValueUpdate<Bitmap?>>, /** Type of data to be shared. */ val contentType: ContentType, /** Whether this preview has been selected by the user. */ 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 c3ad7b6c..d0b89860 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 @@ -24,6 +24,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel @@ -55,7 +56,8 @@ data class ShareouselViewModel( /** List of action chips presented underneath Shareousel. */ val actions: Flow<List<ActionChipViewModel>>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ - val preview: (key: PreviewModel, index: Int?) -> ShareouselPreviewViewModel, + val preview: + (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel, ) @Module @@ -112,7 +114,7 @@ interface ShareouselViewModelModule { } } }, - preview = { key, index -> + preview = { key, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = @@ -121,8 +123,19 @@ interface ShareouselViewModelModule { mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video else -> ContentType.Other } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent ShareouselPreviewViewModel( - bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + bitmapLoadState = + flow { + emit( + key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } + ?: ValueUpdate.Absent + ) + } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), contentType = contentType, isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt index b3054231..8474b4c3 100644 --- a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -28,7 +28,7 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching -private typealias IconCache = LruCache<ComponentName, Drawable> +private typealias IconCache = LruCache<String, Drawable> class CachingTargetDataLoader( private val targetDataLoader: TargetDataLoader, @@ -49,18 +49,27 @@ class CachingTargetDataLoader( } } - override fun loadDirectShareIcon( + override fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer<Drawable> - ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback) + ): Drawable? { + val cacheKey = info.toCacheKey() + return cacheKey?.let { getCachedAppIcon(it, userHandle) } + ?: targetDataLoader.getOrLoadDirectShareIcon(info, userHandle) { drawable -> + if (cacheKey != null) { + getProfileIconCache(userHandle).put(cacheKey, drawable) + } + callback.accept(drawable) + } + } override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) = targetDataLoader.loadLabel(info, callback) override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) - private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? = + private fun getCachedAppIcon(component: String, userHandle: UserHandle): Drawable? = getProfileIconCache(userHandle)[component] private fun getProfileIconCache(userHandle: UserHandle): IconCache = @@ -70,7 +79,20 @@ class CachingTargetDataLoader( private fun DisplayResolveInfo.toCacheKey() = ComponentName( - resolveInfo.activityInfo.packageName, - resolveInfo.activityInfo.name, - ) + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name, + ) + .flattenToString() + + private fun SelectableTargetInfo.toCacheKey(): String? = + if (chooserTargetIcon != null) { + // do not cache icons for caller-provided targets + null + } else { + buildString { + append(chooserTargetComponentName?.flattenToString() ?: "") + append("|") + append(directShareShortcutInfo?.id ?: "") + } + } } diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 1a724d73..e7392f58 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -77,11 +77,11 @@ class DefaultTargetDataLoader( return null } - override fun loadDirectShareIcon( + override fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) { + ): Drawable? { val taskId = nextTaskId.getAndIncrement() LoadDirectShareIconTask( context.createContextAsUser(userHandle, 0), @@ -93,6 +93,7 @@ class DefaultTargetDataLoader( } .also { addTask(taskId, it) } .executeOnExecutor(executor) + return null } override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) { diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index 0f135d63..e2c0362d 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -57,7 +57,7 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { @Override protected Drawable doInBackground(Void... voids) { - Drawable drawable; + Drawable drawable = null; Trace.beginSection("shortcut-icon"); try { final Icon icon = mTargetInfo.getChooserTargetIcon(); @@ -70,6 +70,8 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { } else { Log.e(TAG, "Failed to load shortcut icon for " + mTargetInfo.getChooserTargetComponentName() + "; no access"); + } + if (drawable == null) { drawable = loadIconPlaceholder(); } } catch (Exception e) { @@ -86,6 +88,7 @@ class LoadDirectShareIconTask extends BaseLoadIconTask { } @WorkerThread + @Nullable private Drawable getChooserTargetIconDrawable( Context context, @Nullable Icon icon, diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 7789df44..935b527a 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -32,11 +32,11 @@ abstract class TargetDataLoader { ): Drawable? /** Load a shortcut icon */ - abstract fun loadDirectShareIcon( + abstract fun getOrLoadDirectShareIcon( info: SelectableTargetInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) + ): Drawable? /** Load target label */ abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt index d19933f5..64aa9352 100644 --- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -16,6 +16,7 @@ package com.android.intentresolver.widget +import android.graphics.Rect import android.util.Log import android.view.View import androidx.core.view.OneShotPreDrawListener @@ -42,3 +43,9 @@ internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { ) continuation.invokeOnCancellation { callback.removeListener() } } + +internal fun View.isFullyVisible(): Boolean { + val rect = Rect() + val isVisible = getLocalVisibleRect(rect) + return isVisible && rect.width() == width && rect.height() == height +} diff --git a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java index b46d8bc3..22633085 100644 --- a/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java +++ b/tests/activity/src/com/android/intentresolver/ResolverWrapperActivity.java @@ -180,11 +180,12 @@ public class ResolverWrapperActivity extends ResolverActivity { } @Override - public void loadDirectShareIcon( + @Nullable + public Drawable getOrLoadDirectShareIcon( @NonNull SelectableTargetInfo info, @NonNull UserHandle userHandle, @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); + return mTargetDataLoader.getOrLoadDirectShareIcon(info, userHandle, callback); } @Override diff --git a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt index 8dfbdbdd..c8e17de4 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserActionFactoryTest.kt @@ -69,6 +69,8 @@ class ChooserActionFactoryTest { latestReturn = resultCode } } + private val featureFlags = + FakeFeatureFlagsImpl().apply { setFlag(Flags.FLAG_FIX_PARTIAL_IMAGE_EDIT_TRANSITION, true) } @Before fun setup() { @@ -119,6 +121,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -140,6 +143,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNull() } @@ -162,6 +166,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ resultSender, /* finishCallback = */ {}, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) assertThat(testSubject.copyButtonRunnable).isNotNull() @@ -194,6 +199,7 @@ class ChooserActionFactoryTest { /* shareResultSender = */ null, /* finishCallback = */ resultConsumer, /* clipboardManager = */ mock(), + /* featureFlags = */ featureFlags, ) } } diff --git a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt index 5ac4f2b0..bad3b18c 100644 --- a/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ b/tests/unit/src/com/android/intentresolver/ChooserListAdapterTest.kt @@ -101,7 +101,7 @@ class ChooserListAdapterTest { val targetInfo = createSelectableTargetInfo() testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any()) } @Test @@ -117,7 +117,7 @@ class ChooserListAdapterTest { testSubject.onBindView(view, targetInfo, 0) - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) + verify(mTargetDataLoader, times(1)).getOrLoadDirectShareIcon(any(), any(), any()) } @Test diff --git a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt index ec4a9c3e..a26b4288 100644 --- a/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt +++ b/tests/unit/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModelTest.kt @@ -40,6 +40,7 @@ import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.payloadToggleImageLoader import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectablePreviewsInteractor import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.selectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel @@ -185,9 +186,14 @@ class ShareouselViewModelTest { mimeType = "video/mpeg" ), /* index = */ 1, + viewModelScope, ) - assertWithMessage("preview bitmap is null").that(previewVm.bitmap.first()).isNotNull() + runCurrent() + + assertWithMessage("preview bitmap is null") + .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) + .isNotNull() assertThat(previewVm.isSelected.first()).isFalse() assertThat(previewVm.contentType).isEqualTo(ContentType.Video) @@ -199,6 +205,47 @@ class ShareouselViewModelTest { } @Test + fun previews_wontLoad() = + runTest(targetIntentModifier = { Intent() }) { + cursorPreviewsRepository.previewsModel.value = + PreviewsModel( + previewModels = + listOf( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "image/png", + ), + PreviewModel( + uri = Uri.fromParts("scheme1", "ssp1", "fragment1"), + mimeType = "video/mpeg", + ) + ), + startIdx = 1, + loadMoreLeft = null, + loadMoreRight = null, + leftTriggerIndex = 0, + rightTriggerIndex = 1, + ) + runCurrent() + + val previewVm = + shareouselViewModel.preview.invoke( + PreviewModel( + uri = Uri.fromParts("scheme", "ssp", "fragment"), + mimeType = "video/mpeg" + ), + /* index = */ 1, + viewModelScope, + ) + + runCurrent() + + assertWithMessage("preview bitmap is not null") + .that((previewVm.bitmapLoadState.first() as ValueUpdate.Value).value) + .isNull() + } + + @Test fun actions() { runTest { assertThat(shareouselViewModel.actions.first()).isEmpty() diff --git a/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt new file mode 100644 index 00000000..a36b512b --- /dev/null +++ b/tests/unit/src/com/android/intentresolver/icons/CachingTargetDataLoaderTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.os.UserHandle +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class CachingTargetDataLoaderTest { + private val userHandle = UserHandle.of(1) + + @Test + fun doNotCacheCallerProvidedShortcuts() { + val callerTarget = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ null, + /* backupResolveInfo = */ null, + /* resolvedIntent = */ Intent(), + /* chooserTargetComponentName =*/ ComponentName("package", "Activity"), + "chooserTargetUninitializedTitle", + /* chooserTargetIcon =*/ Icon.createWithContentUri("content://package/icon.png"), + /* chooserTargetIntentExtras =*/ null, + /* modifiedScore =*/ 1f, + /* shortcutInfo = */ null, + /* appTarget = */ null, + /* referrerFillInIntent = */ Intent(), + ) as SelectableTargetInfo + + val targetDataLoader = + mock<TargetDataLoader> { + on { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } doReturn + null + } + val testSubject = CachingTargetDataLoader(targetDataLoader) + val callback = Consumer<Drawable> {} + + testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback) + testSubject.getOrLoadDirectShareIcon(callerTarget, userHandle, callback) + + verify(targetDataLoader) { + 2 * { getOrLoadDirectShareIcon(eq(callerTarget), eq(userHandle), any()) } + } + } + + @Test + fun serviceShortcutsAreCached() { + val context = + mock<Context> { + on { userId } doReturn 1 + on { packageName } doReturn "package" + } + val targetInfo = + SelectableTargetInfo.newSelectableTargetInfo( + /* sourceInfo = */ null, + /* backupResolveInfo = */ null, + /* resolvedIntent = */ Intent(), + /* chooserTargetComponentName =*/ ComponentName("package", "Activity"), + "chooserTargetUninitializedTitle", + /* chooserTargetIcon =*/ null, + /* chooserTargetIntentExtras =*/ null, + /* modifiedScore =*/ 1f, + /* shortcutInfo = */ ShortcutInfo.Builder(context, "1").build(), + /* appTarget = */ null, + /* referrerFillInIntent = */ Intent(), + ) as SelectableTargetInfo + + val targetDataLoader = mock<TargetDataLoader>() + doAnswer { + val callback = it.arguments[2] as Consumer<Drawable> + callback.accept(BitmapDrawable(createBitmap())) + null + } + .whenever(targetDataLoader) + .getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) + val testSubject = CachingTargetDataLoader(targetDataLoader) + val callback = Consumer<Drawable> {} + + testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback) + testSubject.getOrLoadDirectShareIcon(targetInfo, userHandle, callback) + + verify(targetDataLoader) { + 1 * { getOrLoadDirectShareIcon(eq(targetInfo), eq(userHandle), any()) } + } + } +} + +private fun createBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) |