diff options
Diffstat (limited to 'java')
29 files changed, 602 insertions, 390 deletions
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 +} |