diff --git a/app/build.gradle b/app/build.gradle index 2dd1ceb300..a6767f414f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -551,16 +551,16 @@ dependencies { def appcompat_version = "1.7.0" def emoji_version = "1.5.0" def flatbuffers_version = "2.0.0" - def activity_version = "1.10.0" // 1.11.0-rc01 - def fragment_version = "1.8.6" - def windows_version = "1.3.0" // 1.4.0-rc02/1.5.0-alpha02 - def webkit_version = "1.13.0" // 1.14.0-beta01 + def activity_version = "1.10.0" // 1.11.0-rc01//1.12.0-alpha01 + def fragment_version = "1.8.7" + def windows_version = "1.4.0" // 1.5.0-alpha02 + def webkit_version = "1.13.0" // 1.14.0-rc01 def recyclerview_version = "1.4.0" def coordinatorlayout_version = "1.2.0" // 1.3.0-rc01 def constraintlayout_version = "2.2.0" def viewpager_version = "1.1.0-beta01" // 1.1.0 def material_version = "1.12.0" // 1.13.0-alpha03 - def browser_version = "1.8.0" // 1.9.0-alpha03 + def browser_version = "1.8.0" // 1.9.0-alpha04 def lbm_version = "1.1.0" def swiperefresh_version = "1.2.0-beta01" def documentfile_version = "1.1.0" @@ -573,7 +573,7 @@ dependencies { def preference_version = "1.2.1" def work_version = "2.10.1" def exif_version = "1.4.1" - def biometric_version = "1.2.0-alpha05" // 1.4.0-alpha03 + def biometric_version = "1.2.0-alpha05" // 1.4.0-alpha04 def billingclient_version = "6.0.1" // 6.2.0 def playservicesbasement_version = "18.5.0"; def transparency_version = "2.5.73" @@ -644,7 +644,7 @@ dependencies { // https://mvnrepository.com/artifact/androidx.recyclerview/recyclerview // https://mvnrepository.com/artifact/androidx.recyclerview/recyclerview-selection implementation "androidx.recyclerview:recyclerview:$recyclerview_version" - //implementation "androidx.recyclerview:recyclerview-selection:1.1.0" // 1.2.0-rc01 + //implementation "androidx.recyclerview:recyclerview-selection:1.2.0" // https://mvnrepository.com/artifact/androidx.coordinatorlayout/coordinatorlayout implementation "androidx.coordinatorlayout:coordinatorlayout:$coordinatorlayout_version" @@ -862,7 +862,7 @@ dependencies { implementation "com.github.seancfoley:ipaddress:$ipaddress_version" // https://mvnrepository.com/artifact/androidx.car.app/app?repo=google - // implementation "androidx.car.app:app:1.4.0-rc02" + // implementation "androidx.car.app:app:1.4.0-rc02" // 1.8.0-alpha01 // https://github.com/square/leakcanary // https://square.github.io/leakcanary/getting_started/ diff --git a/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java b/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java index bd365759e7..29e37a6eed 100644 --- a/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java +++ b/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java @@ -17,21 +17,18 @@ package androidx.recyclerview.selection; import static androidx.annotation.RestrictTo.Scope.LIBRARY; -import static androidx.annotation.VisibleForTesting.PACKAGE_PRIVATE; import android.graphics.Point; -import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; + +import org.jspecify.annotations.NonNull; /** * Provides support for auto-scrolling a view. * - * @hide */ @RestrictTo(LIBRARY) -@VisibleForTesting(otherwise = PACKAGE_PRIVATE) public abstract class AutoScroller { /** diff --git a/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java b/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java index 3ec4ccb8ac..e8c815e5b8 100644 --- a/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java +++ b/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java @@ -21,12 +21,12 @@ import static androidx.core.util.Preconditions.checkArgument; import android.view.MotionEvent; import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * Provides a means of controlling when and where band selection can be initiated. * @@ -132,7 +132,7 @@ public abstract class BandPredicate { return false; } - @Nullable ItemDetailsLookup.ItemDetails details = + ItemDetailsLookup.ItemDetails details = mDetailsLookup.getItemDetails(e); return (details == null) || !details.inDragRegion(e); } diff --git a/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java b/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java index 591d1274ac..93b0b629e9 100644 --- a/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java +++ b/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java @@ -26,13 +26,14 @@ import android.util.Log; import android.view.MotionEvent; import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.util.Set; /** @@ -210,7 +211,7 @@ class BandSelectionHelper implements OnItemTouchListener, Resettable { } // We shouldn't get any events in this method when band select is not active, - // but it turns some guests show up late to the party. + // but it turns out some guests show up late to the party. // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh) if (!isActive()) { return; @@ -254,6 +255,8 @@ class BandSelectionHelper implements OnItemTouchListener, Resettable { mLock.start(); mFocusDelegate.clearFocus(); mOrigin = origin; + mCurrentPosition = origin; + // NOTE: Pay heed that resizeBand modifies the y coordinates // in onScrolled. Not sure if model expects this. If not // it should be defending against this. @@ -323,6 +326,22 @@ class BandSelectionHelper implements OnItemTouchListener, Resettable { return; } + // mOrigin and mCurrentPosition should never be null when onScrolled is called, + // but "never say never" increasingly looks like a motto to follow. + // For this reason we guard those specific cases and provide a clear + // error message in the logs. + if (mOrigin == null) { + Log.e(TAG, "onScrolled called while mOrigin null."); + if (DEBUG) throw new IllegalStateException("mOrigin is null."); + return; + } + + if (mCurrentPosition == null) { + Log.e(TAG, "onScrolled called while mCurrentPosition null."); + if (DEBUG) throw new IllegalStateException("mCurrentPosition is null."); + return; + } + // Adjust the y-coordinate of the origin the opposite number of pixels so that the // origin remains in the same place relative to the view's items. mOrigin.y -= dy; diff --git a/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java b/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java index 3b6e256489..41c26ba4bb 100644 --- a/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java +++ b/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java @@ -25,7 +25,6 @@ import android.graphics.drawable.Drawable; import android.view.View; import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; import androidx.recyclerview.widget.GridLayoutManager; @@ -33,6 +32,8 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.ItemDecoration; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; +import org.jspecify.annotations.NonNull; + /** * RecyclerView backed {@link BandSelectionHelper.BandHost}. */ diff --git a/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java index 8f564e44b2..ffc0f8736a 100644 --- a/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java +++ b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java @@ -24,14 +24,15 @@ import static androidx.recyclerview.selection.Shared.DEBUG; import android.os.Bundle; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.recyclerview.selection.Range.RangeType; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -46,7 +47,6 @@ import java.util.Set; * {@link SelectionPredicate#canSelectMultiple()}. * * @param Selection key type. @see {@link StorageStrategy} for supported types. - * @hide */ @RestrictTo(LIBRARY) @SuppressWarnings("unchecked") @@ -191,7 +191,7 @@ public class DefaultSelectionTracker extends SelectionTracker implements R private Selection clearSelectionQuietly() { mRange = null; - MutableSelection prevSelection = new MutableSelection(); + MutableSelection prevSelection = new MutableSelection<>(); if (hasSelection()) { copySelection(prevSelection); mSelection.clear(); @@ -394,6 +394,11 @@ public class DefaultSelectionTracker extends SelectionTracker implements R @SuppressWarnings({"WeakerAccess", "unchecked"}) /* synthetic access */ void onDataSetChanged() { + if (mSelection.isEmpty()) { + Log.d(TAG, "Ignoring onDataSetChange. No active selection."); + return; + } + //mSelection.clearProvisionalSelection(); notifySelectionRefresh(); @@ -401,10 +406,12 @@ public class DefaultSelectionTracker extends SelectionTracker implements R List toRemove = null; for (K key : mSelection) { // If the underlying data set has changed, before restoring - // selection we must re-verify that it can be selected. + // selection we must re-verify that the items are present + // and if so, can still be selected. // Why? Because if the dataset has changed, then maybe the - // selectability of an item has changed. - if (!canSetState(key, true)) { + // selectability of an item has changed, or item disappeared. + if (mKeyProvider.getPosition(key) == RecyclerView.NO_POSITION + || !canSetState(key, true)) { if (toRemove == null) { toRemove = new ArrayList<>(); } @@ -420,10 +427,13 @@ public class DefaultSelectionTracker extends SelectionTracker implements R if (toRemove != null) { for (K key : toRemove) { + // TODO(b/163840879): Calling deselect fires onSelectionChanged + // once per call. Meaning we're firing it n+1 times when deselecting. deselect(key); } } + // TODO: Send onSelectionCleared if empty in 2.0 release. notifySelectionChanged(); } @@ -552,7 +562,7 @@ public class DefaultSelectionTracker extends SelectionTracker implements R return; } - @Nullable Bundle selectionState = state.getBundle(getInstanceStateKey()); + Bundle selectionState = state.getBundle(getInstanceStateKey()); if (selectionState == null) { return; } @@ -613,6 +623,10 @@ public class DefaultSelectionTracker extends SelectionTracker implements R public void onItemRangeRemoved(int startPosition, int itemCount) { if (mSelectionTracker.isOverlapping(startPosition, itemCount)) mSelectionTracker.endRange(); + // Since SelectionTracker deals in keys, not positions, we turn + // to the `onDataSetChanged` sledge hammer. + // DefaultSelectionTracker will validate and update it's selection. + mSelectionTracker.onDataSetChanged(); } @Override diff --git a/app/src/main/java/androidx/recyclerview/selection/DisallowInterceptFilter.java b/app/src/main/java/androidx/recyclerview/selection/DisallowInterceptFilter.java new file mode 100644 index 0000000000..9decd3d663 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/DisallowInterceptFilter.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020 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 androidx.recyclerview.selection; + +import android.view.MotionEvent; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; + +import org.jspecify.annotations.NonNull; + +/** + * Wrapper class that regulates delivery of MotionEvents to delegate listeners, uniformly + * honoring requests to onRequestDisallowInterceptTouchEvent. + * Wrap this class around other OnItemTouchListeners to bestow them with + * proper support for onRequestDisallowInterceptTouchEvent. + */ +// TODO: Replace in-situ "disallow" implementation in EventRouter, ResetManager, +// BandSelectionHelper, GestureSelectionHelper by wrapping w/ this class. +class DisallowInterceptFilter implements + OnItemTouchListener, Resettable { + + private final OnItemTouchListener mDelegate; + private boolean mDisallowIntercept; + + DisallowInterceptFilter(@NonNull OnItemTouchListener delegate) { + mDelegate = delegate; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + // Reset disallow when the event is down as advised in http://b/139141511#comment20. + if (mDisallowIntercept && MotionEvents.isActionDown(e)) { + mDisallowIntercept = false; + } + return !mDisallowIntercept && mDelegate.onInterceptTouchEvent(rv, e); + } + + @Override + public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + mDelegate.onInterceptTouchEvent(rv, e); + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + mDisallowIntercept = true; + } + + @Override + public boolean isResetRequired() { + return mDisallowIntercept; + } + + @Override + public void reset() { + mDisallowIntercept = false; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/EventBackstop.java b/app/src/main/java/androidx/recyclerview/selection/EventBackstop.java new file mode 100644 index 0000000000..a24d703626 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/EventBackstop.java @@ -0,0 +1,76 @@ +/* + * Copyright 2020 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 androidx.recyclerview.selection; + +import android.view.MotionEvent; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; + +import org.jspecify.annotations.NonNull; + +/** + * OnItemTouchListener that claims all ACTION_UP events in streams that have otherwise gone + * unclaimed after a LongPress has been detected by GestureDetector. + * This addresses issue described in b/166836317, where child view + * OnClickListeners were being called unexpectedly. + */ +class EventBackstop implements OnItemTouchListener, Resettable { + + private boolean mLongPressFired; + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + // We only claim ACTION_UP events after a LongPress event. Were we to claim + // all ACTION_UP events we'd deprive RecyclerView of the signal it needs to + // initiate fling scrolling. + if (MotionEvents.isActionUp(e) && mLongPressFired) { + mLongPressFired = false; + return true; + } + + // Recover from disallow state. + if (MotionEvents.isActionDown(e) && isResetRequired()) { + reset(); + } + return false; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + // We should never receive any events, but were we to...we want to ignore them. + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + throw new UnsupportedOperationException("Wrap me in an InterceptFilter."); + } + + @Override + public boolean isResetRequired() { + return mLongPressFired; + } + + @Override + public void reset() { + mLongPressFired = false; + } + + void onLongPress() { + mLongPressFired = true; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/EventBridge.java b/app/src/main/java/androidx/recyclerview/selection/EventBridge.java index c2731063d0..66860b944b 100644 --- a/app/src/main/java/androidx/recyclerview/selection/EventBridge.java +++ b/app/src/main/java/androidx/recyclerview/selection/EventBridge.java @@ -17,17 +17,17 @@ package androidx.recyclerview.selection; import static androidx.annotation.RestrictTo.Scope.LIBRARY; -import static androidx.annotation.VisibleForTesting.PACKAGE_PRIVATE; import static androidx.core.util.Preconditions.checkArgument; import static androidx.recyclerview.selection.Shared.VERBOSE; import android.util.Log; -import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; +import androidx.core.util.Consumer; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * Provides the necessary glue to notify RecyclerView when selection data changes, * and to notify SelectionTracker when the underlying RecyclerView.Adapter data changes. @@ -36,10 +36,8 @@ import androidx.recyclerview.widget.RecyclerView; * with multiple RecyclerView instances. This may be necessary when multiple * different views of data are presented to the user. * - * @hide */ @RestrictTo(LIBRARY) -@VisibleForTesting(otherwise = PACKAGE_PRIVATE) public class EventBridge { private static final String TAG = "EventsRelays"; @@ -50,37 +48,45 @@ public class EventBridge { * @param adapter * @param selectionTracker * @param keyProvider + * @param runner Callback allowing operation to be run at next opportune time. + * Implementation could be {@link RecyclerView#postOnAnimation(Runnable)}. * * @param Selection key type. @see {@link StorageStrategy} for supported types. */ public static void install( - @NonNull RecyclerView.Adapter adapter, + RecyclerView.@NonNull Adapter adapter, @NonNull SelectionTracker selectionTracker, - @NonNull ItemKeyProvider keyProvider) { + @NonNull ItemKeyProvider keyProvider, + @NonNull Consumer runner) { // setup bridges to relay selection and adapter events - new TrackerToAdapterBridge<>(selectionTracker, keyProvider, adapter); + new TrackerToAdapterBridge<>(selectionTracker, keyProvider, adapter, runner); adapter.registerAdapterDataObserver(selectionTracker.getAdapterDataObserver()); } private static final class TrackerToAdapterBridge extends SelectionTracker.SelectionObserver { + // Non-private as necessary to avoid synthetic accessors for inner classes. + final RecyclerView.Adapter mAdapter; private final ItemKeyProvider mKeyProvider; - private final RecyclerView.Adapter mAdapter; + private final Consumer mRunner; TrackerToAdapterBridge( @NonNull SelectionTracker selectionTracker, @NonNull ItemKeyProvider keyProvider, - @NonNull RecyclerView.Adapter adapter) { + RecyclerView.@NonNull Adapter adapter, + Consumer runner) { selectionTracker.addObserver(this); checkArgument(keyProvider != null); checkArgument(adapter != null); + checkArgument(runner != null); mKeyProvider = keyProvider; mAdapter = adapter; + mRunner = runner; } /** @@ -96,7 +102,12 @@ public class EventBridge { return; } - mAdapter.notifyItemChanged(position, SelectionTracker.SELECTION_CHANGED_MARKER); + mRunner.accept(new Runnable() { + @Override + public void run() { + mAdapter.notifyItemChanged(position, SelectionTracker.SELECTION_CHANGED_MARKER); + } + }); } } diff --git a/app/src/main/java/androidx/recyclerview/selection/EventRouter.java b/app/src/main/java/androidx/recyclerview/selection/EventRouter.java index c83523ea1b..8ff72b5aef 100644 --- a/app/src/main/java/androidx/recyclerview/selection/EventRouter.java +++ b/app/src/main/java/androidx/recyclerview/selection/EventRouter.java @@ -20,10 +20,11 @@ import static androidx.core.util.Preconditions.checkArgument; import android.view.MotionEvent; -import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import org.jspecify.annotations.NonNull; + /** * A class responsible for routing MotionEvents to tool-type specific handlers. * Individual tool-type specific handlers are added after the class is constructed. @@ -33,37 +34,62 @@ import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; * {@link RecyclerView#addOnItemTouchListener(OnItemTouchListener)}. Despite "Touch" * being in the name, it receives MotionEvents for all types of tools. */ -final class EventRouter implements OnItemTouchListener { +final class EventRouter implements OnItemTouchListener, Resettable { - private final ToolHandlerRegistry mDelegates; + private final ToolSourceHandlerRegistry mDelegates; + private boolean mDisallowIntercept; EventRouter() { - mDelegates = new ToolHandlerRegistry<>(new DummyOnItemTouchListener()); + mDelegates = new ToolSourceHandlerRegistry<>(new StubOnItemTouchListener()); } /** - * @param toolType See MotionEvent for details on available types. - * @param delegate An {@link OnItemTouchListener} to receive events - * of {@code toolType}. + * @param key Either a TOOL_TYPE or a combination of TOOL_TYPE and SOURCE + * @param delegate An {@link OnItemTouchListener} to receive events of {@code ToolSourceKey}. */ - void set(int toolType, @NonNull OnItemTouchListener delegate) { + void set(@NonNull ToolSourceKey key, @NonNull OnItemTouchListener delegate) { checkArgument(delegate != null); - mDelegates.set(toolType, delegate); + mDelegates.set(key, delegate); } @Override public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - return mDelegates.get(e).onInterceptTouchEvent(rv, e); + // Reset disallow when the event is down as advised in http://b/139141511#comment20. + if (mDisallowIntercept && MotionEvents.isActionDown(e)) { + mDisallowIntercept = false; + } + return !mDisallowIntercept && mDelegates.get(e).onInterceptTouchEvent(rv, e); } @Override public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - mDelegates.get(e).onTouchEvent(rv, e); + if (!mDisallowIntercept) { + mDelegates.get(e).onTouchEvent(rv, e); + } } @Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - // TODO(b/139141511): Handle onRequestDisallowInterceptTouchEvent. + if (!disallowIntercept) { + return; // Ignore as advised in http://b/139141511#comment20 + } + + // Some types of views, such as HorizontalScrollView, may want + // to take over the input stream. In this case they'll call this method + // with disallowIntercept=true. mDisallowIntercept is reset on UP or CANCEL + // events in onInterceptTouchEvent. + mDisallowIntercept = disallowIntercept; + } + + + @Override + public boolean isResetRequired() { + return mDisallowIntercept; + } + + @Override + public void reset() { + mDisallowIntercept = false; } } diff --git a/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java b/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java index 9bc31ec0db..8c2185644b 100644 --- a/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java +++ b/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java @@ -16,10 +16,11 @@ package androidx.recyclerview.selection; -import androidx.annotation.NonNull; import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * Override methods in this class to provide application specific behaviors * related to focusing item. @@ -28,7 +29,7 @@ import androidx.recyclerview.widget.RecyclerView; */ public abstract class FocusDelegate { - static FocusDelegate dummy() { + static FocusDelegate stub() { return new FocusDelegate() { @Override public void focusItem(@NonNull ItemDetails item) { diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureDetectorOnItemTouchListenerAdapter.java b/app/src/main/java/androidx/recyclerview/selection/GestureDetectorOnItemTouchListenerAdapter.java deleted file mode 100644 index 5cf2c74188..0000000000 --- a/app/src/main/java/androidx/recyclerview/selection/GestureDetectorOnItemTouchListenerAdapter.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2019 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 androidx.recyclerview.selection; - -import static androidx.core.util.Preconditions.checkArgument; - -import android.view.GestureDetector; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -/** - * Class allowing GestureDetector to listen directly to RecyclerView touch events. - */ -final class GestureDetectorOnItemTouchListenerAdapter implements RecyclerView.OnItemTouchListener { - - private final GestureDetector mDetector; - - GestureDetectorOnItemTouchListenerAdapter(@NonNull GestureDetector detector) { - checkArgument(detector != null); - - mDetector = detector; - } - - @Override - public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - // While the idea of "intercepting" an event stream isn't consistent - // with the world-view of GestureDetector, failure to return true here - // resulted in a bug where a context menu shown on an item view was not - // visible...despite returning reporting that the menu was shown. - // See b/143494310 for further details. - return mDetector.onTouchEvent(e); - } - - @Override - public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - } - - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - } -} - diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureDetectorWrapper.java b/app/src/main/java/androidx/recyclerview/selection/GestureDetectorWrapper.java new file mode 100644 index 0000000000..3f21fe1413 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/GestureDetectorWrapper.java @@ -0,0 +1,99 @@ +/* + * Copyright 2019 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 androidx.recyclerview.selection; + +import static androidx.core.util.Preconditions.checkArgument; + +import android.view.GestureDetector; +import android.view.MotionEvent; + +import androidx.recyclerview.widget.RecyclerView; + +import org.jspecify.annotations.NonNull; + +/** + * A wrapper class for GestureDetector allowing it interact with SelectionTracker + * and its dependencies (like RecyclerView) on terms more amenable to SelectionTracker. + */ +final class GestureDetectorWrapper implements RecyclerView.OnItemTouchListener, Resettable { + + private final GestureDetector mDetector; + private boolean mDisallowIntercept; + + GestureDetectorWrapper(@NonNull GestureDetector detector) { + checkArgument(detector != null); + + mDetector = detector; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + // Reset disallow when the event is down as advised in http://b/139141511#comment20. + if (mDisallowIntercept && MotionEvents.isActionDown(e)) { + mDisallowIntercept = false; + } + + // While the idea of "intercepting" an event stream isn't consistent + // with the world-view of GestureDetector, failure to return true here + // resulted in a bug where a context menu shown on an item view was not + // visible...despite returning reporting that the menu was shown. + // See b/143494310 for further details. + return !mDisallowIntercept && mDetector.onTouchEvent(e); + } + + @Override + public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (!disallowIntercept) { + return; // Ignore as advised in http://b/139141511#comment20 + } + + // Some types of views, such as HorizontalScrollView, may want + // to take over the input stream. In this case they'll call this method + // with disallowIntercept=true. mDisallowIntercept is reset on UP or CANCEL + // events in onInterceptTouchEvent. + mDisallowIntercept = disallowIntercept; + + + // GestureDetector may have internal state (such as timers) that can + // result in subsequent event handlers being called, even after + // we receive a request to disallow intercept (e.g. LONG_PRESS). + // For that reason we proactively reset GestureDetector. + sendCancelEvent(); + } + + @Override + public boolean isResetRequired() { + // Always resettable as we don't know the specifics of GD's internal state. + return true; + } + + @Override + public void reset() { + mDisallowIntercept = false; + sendCancelEvent(); + } + + private void sendCancelEvent() { + // GestureDetector does not provide a public affordance for resetting + // it's internal state, so we send it a synthetic ACTION_CANCEL event. + mDetector.onTouchEvent(MotionEvents.createCancelEvent()); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java index edd308ada3..72e5c948cd 100644 --- a/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java +++ b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java @@ -23,8 +23,8 @@ import android.view.GestureDetector.OnGestureListener; import android.view.GestureDetector.SimpleOnGestureListener; import android.view.MotionEvent; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * GestureRouter is responsible for routing gestures detected by a GestureDetector @@ -36,11 +36,11 @@ import androidx.annotation.Nullable; final class GestureRouter implements OnGestureListener, OnDoubleTapListener { - private final ToolHandlerRegistry mDelegates; + private final ToolSourceHandlerRegistry mDelegates; GestureRouter(@NonNull T defaultDelegate) { checkArgument(defaultDelegate != null); - mDelegates = new ToolHandlerRegistry<>(defaultDelegate); + mDelegates = new ToolSourceHandlerRegistry<>(defaultDelegate); } @SuppressWarnings("unchecked") @@ -49,11 +49,11 @@ final class GestureRouter } /** - * @param toolType + * @param key * @param delegate the delegate, or null to unregister. */ - public void register(int toolType, @Nullable T delegate) { - mDelegates.set(toolType, delegate); + public void register(@NonNull ToolSourceKey key, @Nullable T delegate) { + mDelegates.set(key, delegate); } @Override diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java b/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java index 046a8afa89..5bceee0a0b 100644 --- a/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java +++ b/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java @@ -24,13 +24,13 @@ import android.util.Log; import android.view.MotionEvent; import android.view.View; -import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import androidx.core.view.ViewCompat; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import org.jspecify.annotations.NonNull; + /** * GestureSelectionHelper provides logic that interprets a combination * of motions and gestures in order to provide gesture driven selection support @@ -96,7 +96,6 @@ final class GestureSelectionHelper implements OnItemTouchListener, Resettable { } @Override - /** @hide */ public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { // MotionEvents that aren't ACTION_DOWN are only ever passed to either onInterceptTouchEvent // or onTouchEvent; never to both, so events delivered to this method are effectively @@ -120,7 +119,6 @@ final class GestureSelectionHelper implements OnItemTouchListener, Resettable { } @Override - /** @hide */ public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { if (!mStarted) { if (VERBOSE) Log.i(TAG, "Ignoring input event. Not started."); @@ -147,7 +145,6 @@ final class GestureSelectionHelper implements OnItemTouchListener, Resettable { } @Override - /** @hide */ public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } @@ -268,11 +265,12 @@ final class GestureSelectionHelper implements OnItemTouchListener, Resettable { @Override int getLastGlidedItemPosition(@NonNull MotionEvent e) { - // If user has moved his pointer to the bottom-right empty pane (ie. to the right of the - // last item of the recycler view), we would want to set that as the currentItemPos + // If user has moved their pointer to the bottom-right empty pane (ie. to the + // right of the last item of the recycler view), we would want to set that as + // the currentItemPos View lastItem = mRecyclerView.getLayoutManager() .getChildAt(mRecyclerView.getLayoutManager().getChildCount() - 1); - int direction = ViewCompat.getLayoutDirection(mRecyclerView); + int direction = mRecyclerView.getLayoutDirection(); final boolean pastLastItem = isPastLastItem(lastItem.getTop(), lastItem.getLeft(), lastItem.getRight(), diff --git a/app/src/main/java/androidx/recyclerview/selection/GridModel.java b/app/src/main/java/androidx/recyclerview/selection/GridModel.java index 8453b41d08..e8b24c9ad8 100644 --- a/app/src/main/java/androidx/recyclerview/selection/GridModel.java +++ b/app/src/main/java/androidx/recyclerview/selection/GridModel.java @@ -17,6 +17,7 @@ package androidx.recyclerview.selection; import static androidx.core.util.Preconditions.checkArgument; +import static androidx.core.util.Preconditions.checkState; import android.graphics.Point; import android.graphics.Rect; @@ -25,12 +26,13 @@ import android.util.SparseArray; import android.util.SparseBooleanArray; import android.util.SparseIntArray; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -145,6 +147,7 @@ final class GridModel { mIsActive = true; mPointer = mHost.createAbsolutePoint(relativeOrigin); + mRelOrigin = createRelativePoint(mPointer); mRelPointer = createRelativePoint(mPointer); computeCurrentSelection(); @@ -171,7 +174,11 @@ final class GridModel { */ void resizeSelection(Point relativePointer) { mPointer = mHost.createAbsolutePoint(relativePointer); - updateModel(); + // Should probably never been empty at this point, yet we guard against + // known exceptions because wholesome goodness. + if (!isEmpty()) { + updateModel(); + } } /** @@ -191,7 +198,12 @@ final class GridModel { mPointer.x += dx; mPointer.y += dy; recordVisibleChildren(); - updateModel(); + + // Should probably never been empty at this point, yet we guard against + // known exceptions because wholesome goodness. + if (!isEmpty()) { + updateModel(); + } } /** @@ -258,7 +270,9 @@ final class GridModel { * in a selection change and, if it has, notifies listeners of this change. */ private void updateModel() { + checkState(!isEmpty()); RelativePoint old = mRelPointer; + mRelPointer = createRelativePoint(mPointer); if (mRelPointer.equals(old)) { return; @@ -590,6 +604,11 @@ final class GridModel { } RelativePoint createRelativePoint(Point point) { + // mColumnBounds and mRowBounds is empty when there are no items in the view. + // Clients have to verify items exist before calling this method. + checkState(!mColumnBounds.isEmpty(), "Column bounds not established."); + checkState(!mRowBounds.isEmpty(), "Row bounds not established."); + return new RelativePoint( new RelativeCoordinate(mColumnBounds, point.x), new RelativeCoordinate(mRowBounds, point.y)); @@ -604,14 +623,6 @@ final class GridModel { final RelativeCoordinate mX; final RelativeCoordinate mY; - RelativePoint( - @NonNull List columnLimits, - @NonNull List rowLimits, Point point) { - - this.mX = new RelativeCoordinate(columnLimits, point.x); - this.mY = new RelativeCoordinate(rowLimits, point.y); - } - RelativePoint(@NonNull RelativeCoordinate x, @NonNull RelativeCoordinate y) { this.mX = x; this.mY = y; diff --git a/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java b/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java index 1cdf135203..e7255e2ad8 100644 --- a/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java +++ b/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java @@ -20,11 +20,12 @@ import static androidx.annotation.RestrictTo.Scope.LIBRARY; import android.view.MotionEvent; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + /** * The Selection library calls {@link #getItemDetails(MotionEvent)} when it needs * access to information about the area and/or {@link ItemDetails} under a {@link MotionEvent}. @@ -70,7 +71,6 @@ public abstract class ItemDetailsLookup { /** * @return true if there is an item w/ a stable ID at the event coordinates. - * @hide */ @RestrictTo(LIBRARY) protected boolean overItemWithSelectionKey(@NonNull MotionEvent e) { @@ -100,7 +100,7 @@ public abstract class ItemDetailsLookup { * @return the adapter position of the item at the event coordinates. */ final int getItemPosition(@NonNull MotionEvent e) { - @Nullable ItemDetails item = getItemDetails(e); + ItemDetails item = getItemDetails(e); return item != null ? item.getPosition() : RecyclerView.NO_POSITION; @@ -172,7 +172,8 @@ public abstract class ItemDetailsLookup { /** * Returns the adapter position of the item. See - * {@link RecyclerView.ViewHolder#getAdapterPosition() ViewHolder.getAdapterPosition} + * {@link RecyclerView.ViewHolder#getAbsoluteAdapterPosition() ViewHolder + * .getAbsoluteAdapterPosition} * * @return the position of an item. */ diff --git a/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java b/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java index a216b2466c..d84807fbe5 100644 --- a/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java +++ b/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java @@ -19,10 +19,11 @@ package androidx.recyclerview.selection; import static androidx.core.util.Preconditions.checkArgument; import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -79,7 +80,8 @@ public abstract class ItemKeyProvider { public abstract @Nullable K getKey(int position); /** - * @return the position corresponding to the selection key, or RecyclerView.NO_POSITION. + * @return the position corresponding to the selection key, or RecyclerView.NO_POSITION + * if the key is unrecognized. */ public abstract int getPosition(@NonNull K key); } diff --git a/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java b/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java index aac3c0674d..df084c4d30 100644 --- a/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java +++ b/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java @@ -17,17 +17,27 @@ package androidx.recyclerview.selection; import android.graphics.Point; +import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; -import androidx.annotation.NonNull; +import org.jspecify.annotations.NonNull; /** * Utility methods for working with {@link MotionEvent} instances. */ final class MotionEvents { - private MotionEvents() {} + private MotionEvents() { + } + + static boolean isTouchpadEvent(@NonNull MotionEvent e) { + // ChromeOS ARC devices with touchpads emit their events with + // {@link MotionEvent#TOOL_TYPE_MOUSE}, so this is specifically capturing non-ARC devices + // with touchpads (e.g. attachable keyboards with touchpads on Android tablets). + return e.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER + && e.getSource() == InputDevice.SOURCE_MOUSE; + } static boolean isMouseEvent(@NonNull MotionEvent e) { return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE; @@ -101,20 +111,22 @@ final class MotionEvents { static boolean isTouchpadScroll(@NonNull MotionEvent e) { // Touchpad inputs are treated as mouse inputs, and when scrolling, there are no buttons // returned. - return isMouseEvent(e) && isActionMove(e) && e.getButtonState() == 0; - } - - /** - * Returns true if the event is a drag event (which is presumbaly, but not - * explicitly required to be a mouse event). - * @param e - */ - static boolean isPointerDragEvent(MotionEvent e) { - return isPrimaryMouseButtonPressed(e) - && isActionMove(e); + return (isTouchpadEvent(e) || isMouseEvent(e)) && isActionMove(e) + && e.getButtonState() == 0; } private static boolean hasBit(int metaState, int bit) { return (metaState & bit) != 0; } + + static MotionEvent createCancelEvent() { + return MotionEvent.obtain( + 0, // down time + 1, // event time + MotionEvent.ACTION_CANCEL, + 0, // x + 0, // y + 0 // metaState + ); + } } diff --git a/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java index 0a3d324683..e1a685b343 100644 --- a/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java +++ b/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java @@ -22,11 +22,12 @@ import static androidx.core.util.Preconditions.checkState; import android.view.GestureDetector.SimpleOnGestureListener; import android.view.MotionEvent; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + /** * Base class for handlers that can be registered w/ {@link GestureRouter}. */ diff --git a/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java index db3f43486b..2b41d2e3ca 100644 --- a/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java +++ b/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java @@ -23,11 +23,11 @@ import static androidx.recyclerview.selection.Shared.VERBOSE; import android.util.Log; import android.view.MotionEvent; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * A MotionInputHandler that provides the high-level glue for mouse driven selection. This * class works with {@link RecyclerView}, {@link GestureRouter}, and {@link GestureSelectionHelper} @@ -35,7 +35,7 @@ import androidx.recyclerview.widget.RecyclerView; */ final class MouseInputHandler extends MotionInputHandler { - private static final String TAG = "MouseInputDelegate"; + private static final String TAG = "MouseInputHandler"; private final ItemDetailsLookup mDetailsLookup; private final OnContextClickListener mOnContextClickListener; @@ -170,7 +170,7 @@ final class MouseInputHandler extends MotionInputHandler { return false; } - @Nullable ItemDetails item = mDetailsLookup.getItemDetails(e); + ItemDetails item = mDetailsLookup.getItemDetails(e); if (item == null || !item.hasSelectionKey()) { return false; } @@ -204,7 +204,7 @@ final class MouseInputHandler extends MotionInputHandler { private boolean onRightClick(@NonNull MotionEvent e) { if (mDetailsLookup.overItemWithSelectionKey(e)) { - @Nullable ItemDetails item = mDetailsLookup.getItemDetails(e); + ItemDetails item = mDetailsLookup.getItemDetails(e); if (item != null && !mSelectionTracker.isSelected(item.getSelectionKey())) { mSelectionTracker.clearSelection(); selectItem(item); diff --git a/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java b/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java index 3a5cebe19f..689e6bc1b0 100644 --- a/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java +++ b/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java @@ -16,7 +16,7 @@ package androidx.recyclerview.selection; -import androidx.annotation.NonNull; +import org.jspecify.annotations.NonNull; /** * Subclass of {@link Selection} exposing public support for mutating the underlying diff --git a/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java b/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java index b7e432e602..ebbd893f70 100644 --- a/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java +++ b/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java @@ -18,7 +18,7 @@ package androidx.recyclerview.selection; import android.view.MotionEvent; -import androidx.annotation.NonNull; +import org.jspecify.annotations.NonNull; /** * Override methods in this class to provide application specific behaviors diff --git a/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java b/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java index 50d8f1b3d7..590914810d 100644 --- a/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java +++ b/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java @@ -22,7 +22,7 @@ import android.content.ClipData; import android.view.MotionEvent; import android.view.View; -import androidx.annotation.NonNull; +import org.jspecify.annotations.NonNull; /** * Register an OnDragInitiatedListener to be notified when user intent to perform drag and drop diff --git a/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java b/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java index 60c41c642c..aa2dc55164 100644 --- a/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java +++ b/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java @@ -18,9 +18,10 @@ package androidx.recyclerview.selection; import android.view.MotionEvent; -import androidx.annotation.NonNull; import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; +import org.jspecify.annotations.NonNull; + /** * Register an OnItemActivatedListener to be notified when an item is activated * (tapped or double clicked). @@ -31,7 +32,7 @@ public interface OnItemActivatedListener { /** * Called when an item is "activated". An item is activated, for example, when no selection - * exists and the user taps an item with her finger, or double clicks an item with a + * exists and the user taps an item with their finger, or double clicks an item with a * pointing device like a Mouse. * * @param item details of the item. diff --git a/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java b/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java index 53d4b82ed3..63e96f1e4b 100644 --- a/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java +++ b/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java @@ -24,9 +24,10 @@ import static androidx.recyclerview.selection.Shared.DEBUG; import android.util.Log; import androidx.annotation.MainThread; -import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; +import org.jspecify.annotations.NonNull; + import java.util.ArrayList; import java.util.List; @@ -52,7 +53,7 @@ public final class OperationMonitor { // Ideally OperationMonitor would implement Resettable // directly, but Metalava couldn't understand that // `OperationMonitor` was public API while `Resettable` was - // not. This is our klunkuy workaround. + // not. This is our clever workaround :) private final Resettable mResettable = new Resettable() { @Override @@ -68,55 +69,65 @@ public final class OperationMonitor { private int mNumOps = 0; + private final Object mLock = new Object(); + @MainThread - synchronized void start() { - mNumOps++; + void start() { + synchronized (mLock) { + mNumOps++; - if (mNumOps == 1) { - notifyStateChanged(); - } + if (mNumOps == 1) { + notifyStateChanged(); + } - if (DEBUG) Log.v(TAG, "Incremented content lock count to " + mNumOps + "."); + if (DEBUG) Log.v(TAG, "Incremented content lock count to " + mNumOps + "."); + } } @MainThread - synchronized void stop() { - if (mNumOps == 0) { - if (DEBUG) Log.w(TAG, "Stop called whith opt count of 0."); - return; - } - - mNumOps--; - if (DEBUG) Log.v(TAG, "Decremented content lock count to " + mNumOps + "."); - - if (mNumOps == 0) { - notifyStateChanged(); + void stop() { + synchronized (mLock) { + if (mNumOps == 0) { + if (DEBUG) Log.w(TAG, "Stop called whith opt count of 0."); + return; + } + + mNumOps--; + if (DEBUG) Log.v(TAG, "Decremented content lock count to " + mNumOps + "."); + + if (mNumOps == 0) { + notifyStateChanged(); + } } } - /** @hide */ @RestrictTo(LIBRARY) @MainThread - synchronized void reset() { - if (DEBUG) Log.d(TAG, "Received reset request."); - if (mNumOps > 0) { - Log.w(TAG, "Resetting OperationMonitor with " + mNumOps + " active operations."); + void reset() { + synchronized (mLock) { + if (DEBUG) Log.d(TAG, "Received reset request."); + if (mNumOps > 0) { + Log.w(TAG, "Resetting OperationMonitor with " + mNumOps + " active operations."); + } + mNumOps = 0; + notifyStateChanged(); } - mNumOps = 0; - notifyStateChanged(); } - /** @hide */ @RestrictTo(LIBRARY) - synchronized boolean isResetRequired() { - return isStarted(); + boolean isResetRequired() { + synchronized (mLock) { + return isStarted(); + } } /** * @return true if there are any running operations. */ - public synchronized boolean isStarted() { - return mNumOps > 0; + public boolean isStarted() { + synchronized (mLock) { + return mNumOps > 0; + } } /** @@ -154,7 +165,6 @@ public final class OperationMonitor { /** * Work around b/139109223. - * @hide */ @RestrictTo(LIBRARY) @NonNull Resettable asResettable() { diff --git a/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java b/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java index ef0f077d69..8af39c164a 100644 --- a/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java +++ b/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java @@ -19,14 +19,16 @@ package androidx.recyclerview.selection; import static androidx.core.util.Preconditions.checkArgument; import android.view.MotionEvent; +import android.view.ViewConfiguration; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import org.jspecify.annotations.Nullable; + /** - * OnItemTouchListener that detects and delegates drag events to a drag listener, - * else sends event to fallback {@link OnItemTouchListener}. + * OnItemTouchListener that detects and delegates drag events to a drag listener, else sends event + * to fallback {@link OnItemTouchListener}. * *

See {@link OnDragInitiatedListener} for details on implementing drag and drop. */ @@ -35,6 +37,9 @@ final class PointerDragEventInterceptor implements OnItemTouchListener { private final ItemDetailsLookup mEventDetailsLookup; private final OnDragInitiatedListener mDragListener; private OnItemTouchListener mDelegate; + private float mDownX; + private float mDownY; + private boolean mDownInItemDragRegion; PointerDragEventInterceptor( ItemDetailsLookup eventDetailsLookup, @@ -50,14 +55,28 @@ final class PointerDragEventInterceptor implements OnItemTouchListener { if (delegate != null) { mDelegate = delegate; } else { - mDelegate = new DummyOnItemTouchListener(); + mDelegate = new StubOnItemTouchListener(); } } @Override public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { - if (MotionEvents.isPointerDragEvent(e) && mEventDetailsLookup.inItemDragRegion(e)) { - return mDragListener.onDragInitiated(e); + if (MotionEvents.isPrimaryMouseButtonPressed(e)) { + float x = e.getX(); + float y = e.getY(); + if (MotionEvents.isActionDown(e)) { + mDownX = x; + mDownY = y; + mDownInItemDragRegion = mEventDetailsLookup.inItemDragRegion(e); + } else if (mDownInItemDragRegion && MotionEvents.isActionMove(e)) { + int touchSlop = ViewConfiguration.get(rv.getContext()).getScaledTouchSlop(); + float dx = x - mDownX; + float dy = y - mDownY; + float distanceSquared = (dx * dx) + (dy * dy); + if (distanceSquared > (touchSlop * touchSlop)) { + return mDragListener.onDragInitiated(e); + } + } } return mDelegate.onInterceptTouchEvent(rv, e); } diff --git a/app/src/main/java/androidx/recyclerview/selection/Range.java b/app/src/main/java/androidx/recyclerview/selection/Range.java index 85ddeff947..dc372bad93 100644 --- a/app/src/main/java/androidx/recyclerview/selection/Range.java +++ b/app/src/main/java/androidx/recyclerview/selection/Range.java @@ -23,7 +23,8 @@ import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; import android.util.Log; import androidx.annotation.IntDef; -import androidx.annotation.NonNull; + +import org.jspecify.annotations.NonNull; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/app/src/main/java/androidx/recyclerview/selection/ResetManager.java b/app/src/main/java/androidx/recyclerview/selection/ResetManager.java index 254d013b63..b6702d800d 100644 --- a/app/src/main/java/androidx/recyclerview/selection/ResetManager.java +++ b/app/src/main/java/androidx/recyclerview/selection/ResetManager.java @@ -21,11 +21,12 @@ import static androidx.recyclerview.selection.Shared.DEBUG; import android.util.Log; import android.view.MotionEvent; -import androidx.annotation.NonNull; import androidx.recyclerview.selection.SelectionTracker.SelectionObserver; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import org.jspecify.annotations.NonNull; + import java.util.ArrayList; import java.util.List; diff --git a/app/src/main/java/androidx/recyclerview/selection/Resettable.java b/app/src/main/java/androidx/recyclerview/selection/Resettable.java index 85092aa215..0b46005c34 100644 --- a/app/src/main/java/androidx/recyclerview/selection/Resettable.java +++ b/app/src/main/java/androidx/recyclerview/selection/Resettable.java @@ -28,7 +28,6 @@ import androidx.annotation.RestrictTo; * should always return false when called immediately after {@link #reset()} * has been called. * - * @hide */ @RestrictTo(LIBRARY) public interface Resettable { diff --git a/app/src/main/java/androidx/recyclerview/selection/Selection.java b/app/src/main/java/androidx/recyclerview/selection/Selection.java index 11dc284bab..72dcf2c7ac 100644 --- a/app/src/main/java/androidx/recyclerview/selection/Selection.java +++ b/app/src/main/java/androidx/recyclerview/selection/Selection.java @@ -16,8 +16,8 @@ package androidx.recyclerview.selection; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.Iterator; import java.util.LinkedHashMap; diff --git a/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java b/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java index 58780affca..ba48f338ac 100644 --- a/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java +++ b/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java @@ -16,9 +16,10 @@ package androidx.recyclerview.selection; -import androidx.annotation.NonNull; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; +import org.jspecify.annotations.NonNull; + /** * Utility class for creating SelectionPredicate instances. Provides default * implementations for common cases like "single selection" and "select anything". @@ -34,7 +35,7 @@ public final class SelectionPredicates { * @param Selection key type. @see {@link StorageStrategy} for supported types. * @return */ - public static @NonNull SelectionPredicate createSelectAnything() { + public static @NonNull SelectionPredicate createSelectAnything() { return new SelectionPredicate() { @Override public boolean canSetStateForKey(@NonNull K key, boolean nextState) { @@ -60,7 +61,7 @@ public final class SelectionPredicates { * @param Selection key type. @see {@link StorageStrategy} for supported types. * @return */ - public static @NonNull SelectionPredicate createSelectSingleAnything() { + public static @NonNull SelectionPredicate createSelectSingleAnything() { return new SelectionPredicate() { @Override public boolean canSetStateForKey(@NonNull K key, boolean nextState) { diff --git a/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java index a338ae1071..8c86d4adbc 100644 --- a/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java +++ b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java @@ -19,22 +19,25 @@ package androidx.recyclerview.selection; import static androidx.annotation.RestrictTo.Scope.LIBRARY; import static androidx.core.util.Preconditions.checkArgument; +import android.annotation.SuppressLint; import android.content.Context; import android.os.Bundle; import android.os.Parcelable; import android.util.Log; import android.view.GestureDetector; import android.view.HapticFeedbackConstants; +import android.view.InputDevice; import android.view.MotionEvent; import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.util.Set; /** @@ -90,8 +93,6 @@ import java.util.Set; */ public abstract class SelectionTracker { - private static final String TAG = "SelectionTracker"; - /** * This value is included in the payload when SelectionTracker notifies RecyclerView * of changes to selection. Look for this value in the {@code payload} @@ -103,6 +104,7 @@ public abstract class SelectionTracker { * When state is being restored, this argument will not be present. */ public static final String SELECTION_CHANGED_MARKER = "Selection-Changed"; + private static final String TAG = "SelectionTracker"; /** * Adds {@code observer} to be notified when changes to selection occur. @@ -163,6 +165,7 @@ public abstract class SelectionTracker { * Sets the selected state of the specified items if permitted after consulting * SelectionPredicate. */ + @SuppressLint("LambdaLast") public abstract boolean setItemsSelected(@NonNull Iterable keys, boolean selected); /** @@ -181,7 +184,7 @@ public abstract class SelectionTracker { */ public abstract boolean deselect(@NonNull K key); - /** @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) protected abstract @NonNull AdapterDataObserver getAdapterDataObserver(); @@ -192,8 +195,8 @@ public abstract class SelectionTracker { * @param position The "anchor" position for the range. Subsequent range operations * (primarily keyboard and mouse based operations like SHIFT + click) * work with the established anchor point to define selection ranges. - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) public abstract void startRange(int position); @@ -208,8 +211,8 @@ public abstract class SelectionTracker { * @param position The new end position for the selection range. * @throws IllegalStateException if a range selection is not active. Range selection * must have been started by a call to {@link #startRange(int)}. - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) public abstract void extendRange(int position); @@ -217,16 +220,15 @@ public abstract class SelectionTracker { * Clears an in-progress range selection. Provisional range selection established * using {@link #extendProvisionalRange(int)} will be cleared (unless * {@link #mergeProvisionalSelection()} is called first.) - * - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) public abstract void endRange(); /** * @return Whether or not there is a current range selection active. - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) public abstract boolean isRangeActive(); @@ -238,8 +240,8 @@ public abstract class SelectionTracker { * TODO: Reconcile this with startRange. Maybe just docs need to be updated. * * @param position the anchor position. Must already be selected. - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) public abstract void anchorRange(int position); @@ -247,33 +249,30 @@ public abstract class SelectionTracker { * Creates a provisional selection from anchor to {@code position}. * * @param position the end point. - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) protected abstract void extendProvisionalRange(int position); /** * Sets the provisional selection, replacing any existing selection. - * - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) protected abstract void setProvisionalSelection(@NonNull Set newSelection); /** * Clears any existing provisional selection - * - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) protected abstract void clearProvisionalSelection(); /** * Converts the provisional selection into primary selection, then clears * provisional selection. - * - * @hide */ + @SuppressWarnings("HiddenAbstractMethod") @RestrictTo(LIBRARY) protected abstract void mergeProvisionalSelection(); @@ -308,8 +307,6 @@ public abstract class SelectionTracker { /** * Called when Selection is cleared. * TODO(smckay): Make public in a future public API. - * - * @hide */ @RestrictTo(LIBRARY) protected void onSelectionCleared() { @@ -327,8 +324,7 @@ public abstract class SelectionTracker { /** * Called immediately after completion of any set of changes, excluding - * those resulting in calls to {@link #onSelectionRefresh()} and - * {@link #onSelectionRestored()}. + * those resulting in calls {@link #onSelectionRestored()}. */ public void onSelectionChanged() { } @@ -385,59 +381,64 @@ public abstract class SelectionTracker { } /** - * Builder is the primary mechanism for create a {@link SelectionTracker} that + * Builder is the primary mechanism for creating a {@link SelectionTracker} that * can be used with your RecyclerView. Once installed, users will be able to create and - * manipulate selection using a variety of intuitive techniques like tap, gesture, - * and mouse lasso. + * manipulate a selection of items in a RecyclerView instance using a variety of + * intuitive techniques like tap, gesture, and mouse-based band selection (aka 'lasso'). * *

- * Example usage: - *

SelectionTracker tracker = new SelectionTracker.Builder<>(
-     *        "my-uri-selection",
-     *        recyclerView,
-     *        new DemoStableIdProvider(recyclerView.getAdapter()),
-     *        new MyDetailsLookup(recyclerView),
-     *        StorageStrategy.createParcelableStorage(Uri.class))
-     *        .build();
-     * 
+ * Building a bare-bones instance: + * + *
{@code
+     * SelectionTracker tracker = new SelectionTracker.Builder<>(
+     *         "my-uri-selection",
+     *         recyclerView,
+     *         new YourItemKeyProvider(recyclerView.getAdapter()),
+     *         new YourItemDetailsLookup(recyclerView),
+     *         StorageStrategy.createParcelableStorage(Uri.class))
+     *     .build();
+     * }
* *

* Restricting which items can be selected and limiting selection size * *

- * {@link SelectionPredicate} provides a mechanism to restrict which Items can be selected, - * to limit the number of items that can be selected, as well as allowing the selection - * code to be placed into "single select" mode, which as the name indicates, constrains - * the selection size to a single item. - * - *

Configuring the tracker for single single selection support can be done - * by supplying {@link SelectionPredicates#createSelectSingleAnything()}. - * + * {@link SelectionPredicate} and + * {@link SelectionTracker.Builder#withSelectionPredicate(SelectionPredicate)} + * together provide a mechanism for restricting which items can be selected and + * limiting selection size. Use {@link SelectionPredicates#createSelectSingleAnything()} + * for single-selection, or write your own {@link SelectionPredicate} if other + * constraints are required. + * + *

{@code
      * SelectionTracker tracker = new SelectionTracker.Builder<>(
-     * "my-string-selection",
-     * recyclerView,
-     * new DemoStableIdProvider(recyclerView.getAdapter()),
-     * new MyDetailsLookup(recyclerView),
-     * StorageStrategy.createStringStorage())
-     * .withSelectionPredicate(SelectionPredicates#createSelectSingleAnything())
-     * .build();
-     * 
+ * "my-string-selection", + * recyclerView, + * new YourItemKeyProvider(recyclerView.getAdapter()), + * new YourItemDetailsLookup(recyclerView), + * StorageStrategy.createStringStorage()) + * .withSelectionPredicate(SelectionPredicates#createSelectSingleAnything()) + * .build(); + * } + * *

* Retaining state across Android lifecycle events * *

* Support for storage/persistence of selection must be configured and invoked manually * owing to its reliance on Activity lifecycle events. - * Failure to include support for selection storage will result in the active selection - * being lost when the Activity receives a configuration change (e.g. rotation) - * or when the application process is destroyed by the OS to reclaim resources. + * Failure to include support for selection storage will result in selection + * being lost when the Activity receives a configuration change (e.g. rotation), + * or when the application is paused or stopped. For this reason + * {@link StorageStrategy} is a required argument to obtain a {@link Builder} + * instance. * *

* Key Type * *

- * Developers must decide on the key type used to identify selected items. Support - * is provided for three types: {@link Parcelable}, {@link String}, and {@link Long}. + * A developer must decide on the key type used to identify selected items. + * Support is provided for three types: {@link Parcelable}, {@link String}, and {@link Long}. * *

* {@link Parcelable}: Any Parcelable type can be used as the selection key. This is especially @@ -450,30 +451,55 @@ public abstract class SelectionTracker { * {@link String}: Use String when a string based stable identifier is available. * *

- * {@link Long}: Use Long when RecyclerView's long stable ids are - * already in use. It comes with some limitations, however, as access to stable ids - * at runtime is limited. Band selection support is not available when using the default - * long key storage implementation. See {@link StableIdKeyProvider} for details. + * {@link Long}: Use Long when a project is already employing RecyclerView's built-in + * support for stable ids. In this case you may choose to use {@link StableIdKeyProvider} + * to supply selection keys to the SelectionTracker based on data already accessible + * in RecyclerView and it's Adapter. + * + * See {@link StableIdKeyProvider} for important details and limitations (and a suggestion + * that you might just want to write your own {@link ItemKeyProvider}. It's easy!) + * See the "Gotchas" selection below for details on selection size limits. * *

* Usage: * - *

-     * private SelectionTracker mTracker;
+     * 
{@code
+     * private SelectionTracker tracker;
      *
      * public void onCreate(Bundle savedInstanceState) {
-     * // See above for details on constructing a SelectionTracker instance.
-     *
-     * if (savedInstanceState != null) {
-     * mTracker.onRestoreInstanceState(savedInstanceState);
-     * }
+     *   if (savedInstanceState != null) {
+     *     tracker.onRestoreInstanceState(savedInstanceState);
+     *   }
      * }
      *
      * protected void onSaveInstanceState(Bundle outState) {
-     * super.onSaveInstanceState(outState);
-     * mTracker.onSaveInstanceState(outState);
+     *   super.onSaveInstanceState(outState);
+     *   tracker.onSaveInstanceState(outState);
      * }
-     * 
+ * }
+ * + *

+ * Gotchas + * + *

TransactionTooLargeException: + * + *

Many factors affect the maximum number of items that can be persisted when the + * application is paused or stopped. Unfortunately that number is not deterministic as it + * depends on the size of the key type used for selection, the number of selected items, and + * external demand on system resources. For that reason it is best to use the smallest viable + * key type, and to enforce a limit on the number of items that can be selected. + * + *

Furthermore the inability to persist a selection during a lifecycle event will result + * in a android.os.{@link android.os.TransactionTooLargeException}. See + * http://issuetracker.google.com/168706011 for details. + * + *

ItemTouchHelper + * + *

When using {@link SelectionTracker} along side an + * {@link androidx.recyclerview.widget.ItemTouchHelper} with the same RecyclerView instance + * the SelectionTracker instance must be created and installed before the ItemTouchHelper. + * Failure to do so will result in unintended selections during item drag operations, and + * possibly other situations. * * @param Selection key type. Built in support is provided for {@link String}, * {@link Long}, and {@link Parcelable}. {@link StorageStrategy} @@ -496,7 +522,7 @@ public abstract class SelectionTracker { private ItemKeyProvider mKeyProvider; private ItemDetailsLookup mDetailsLookup; - private FocusDelegate mFocusDelegate = FocusDelegate.dummy(); + private FocusDelegate mFocusDelegate = FocusDelegate.stub(); private OnItemActivatedListener mOnItemActivatedListener; private OnDragInitiatedListener mOnDragInitiatedListener; @@ -646,12 +672,11 @@ public abstract class SelectionTracker { * * @param toolTypes the tool types to be used * @return this - * * @deprecated GestureSelection is best bound to {@link MotionEvent#TOOL_TYPE_FINGER}, * and only that tool type. This method will be removed in a future release. */ //@Deprecated - public @NonNull Builder withGestureTooltypes(@NonNull int... toolTypes) { + public @NonNull Builder withGestureTooltypes(int @NonNull ... toolTypes) { Log.w(TAG, "Setting gestureTooltypes is likely to result in unexpected behavior."); mGestureToolTypes = toolTypes; return this; @@ -685,12 +710,11 @@ public abstract class SelectionTracker { * * @param toolTypes the tool types to be used * @return this - * * @deprecated PointerSelection is best bound to {@link MotionEvent#TOOL_TYPE_MOUSE}, * and only that tool type. This method will be removed in a future release. */ - @Deprecated - public @NonNull Builder withPointerTooltypes(@NonNull int... toolTypes) { + //@Deprecated + public @NonNull Builder withPointerTooltypes(int @NonNull ... toolTypes) { Log.w(TAG, "Setting pointerTooltypes is likely to result in unexpected behavior."); mPointerToolTypes = toolTypes; return this; @@ -709,7 +733,7 @@ public abstract class SelectionTracker { // Event glue between RecyclerView and SelectionTracker keeps the classes separate // so that a SelectionTracker can be shared across RecyclerView instances that // represent the same data in different ways. - EventBridge.install(mAdapter, tracker, mKeyProvider); + EventBridge.install(mAdapter, tracker, mKeyProvider, mRecyclerView::post); // Scroller is stateful and can be reset, but we don't manage it directly. // GestureSelectionHelper will reset scroller when it is reset. @@ -728,19 +752,29 @@ public abstract class SelectionTracker { GestureDetector gestureDetector = new GestureDetector(mContext, gestureRouter); // GestureSelectionHelper provides logic that interprets a combination - // of motions and gestures in order to provide gesture driven selection support - // when used in conjunction with RecyclerView. - final GestureSelectionHelper gestureHelper = GestureSelectionHelper.create( + // of motions and gestures in order to provide fluid "long-press and drag" + // finger driven selection support. + final GestureSelectionHelper gestureSelectionHelper = GestureSelectionHelper.create( tracker, mSelectionPredicate, mRecyclerView, scroller, mMonitor); // EventRouter receives events for RecyclerView, dispatching to handlers // registered by tool-type. EventRouter eventRouter = new EventRouter(); + GestureDetectorWrapper gestureDetectorWrapper = + new GestureDetectorWrapper(gestureDetector); + + // Temp fix for b/166836317. + // TODO: Add support for multiple listeners per tool type to EventRouter, then + // register backstop with primary router. + EventRouter backstopRouter = new EventRouter(); + EventBackstop backstop = new EventBackstop(); + DisallowInterceptFilter backstopWrapper = new DisallowInterceptFilter(backstop); + backstopRouter.set(new ToolSourceKey(MotionEvent.TOOL_TYPE_UNKNOWN), backstopWrapper); // Finally hook the framework up to listening to RecycleView events. mRecyclerView.addOnItemTouchListener(eventRouter); - mRecyclerView.addOnItemTouchListener( - new GestureDetectorOnItemTouchListenerAdapter(gestureDetector)); + mRecyclerView.addOnItemTouchListener(gestureDetectorWrapper); + mRecyclerView.addOnItemTouchListener(backstopRouter); // Reset manager listens for cancel events from RecyclerView. In response to that it // advises other classes it is time to reset state. @@ -750,20 +784,27 @@ public abstract class SelectionTracker { // // 1. Monitor selection reset which can be invoked by clients in response // to back key press and some application lifecycle events. - // - // 2. Monitor ACTION_CANCEL events (which arrive exclusively - // via TOOL_TYPE_UNKNOWN). tracker.addObserver(resetMgr.getSelectionObserver()); + // ...and 2. Monitor ACTION_CANCEL events (which arrive exclusively + // via TOOL_TYPE_UNKNOWN). + // // CAUTION! Registering resetMgr directly with RecyclerView#addOnItemTouchListener // will not work as expected. Once EventRouter returns true, RecyclerView will // no longer dispatch any events to other listeners for the duration of the // stream, not even ACTION_CANCEL events. - eventRouter.set(MotionEvent.TOOL_TYPE_UNKNOWN, resetMgr.getInputListener()); + eventRouter.set(new ToolSourceKey(MotionEvent.TOOL_TYPE_UNKNOWN), + resetMgr.getInputListener()); + // Finally register all of the Resettables. resetMgr.addResetHandler(tracker); resetMgr.addResetHandler(mMonitor.asResettable()); - resetMgr.addResetHandler(gestureHelper); + resetMgr.addResetHandler(gestureSelectionHelper); + resetMgr.addResetHandler(gestureDetectorWrapper); + resetMgr.addResetHandler(eventRouter); + resetMgr.addResetHandler(backstopRouter); + resetMgr.addResetHandler(backstop); + resetMgr.addResetHandler(backstopWrapper); // But before you move on, there's more work to do. Event plumbing has been // installed, but we haven't registered any of our helpers or callbacks. @@ -774,7 +815,7 @@ public abstract class SelectionTracker { // be configured to handle other types of input (to satisfy user expectation).); // Internally, the code doesn't permit nullable listeners, so we lazily - // initialize dummy instances if the developer didn't supply a real listener. + // initialize stub instances if the developer didn't supply a real listener. mOnDragInitiatedListener = (mOnDragInitiatedListener != null) ? mOnDragInitiatedListener : new OnDragInitiatedListener() { @@ -789,7 +830,7 @@ public abstract class SelectionTracker { : new OnItemActivatedListener() { @Override public boolean onItemActivated( - @NonNull ItemDetailsLookup.ItemDetails item, + ItemDetailsLookup.@NonNull ItemDetails item, @NonNull MotionEvent e) { return false; } @@ -811,18 +852,7 @@ public abstract class SelectionTracker { mKeyProvider, mDetailsLookup, mSelectionPredicate, - new Runnable() { - @Override - public void run() { - if (mSelectionPredicate.canSelectMultiple()) { - try { - gestureHelper.start(); - } catch (Throwable ex) { - eu.faircode.email.Log.e(ex); - } - } - } - }, + gestureSelectionHelper::start, mOnDragInitiatedListener, mOnItemActivatedListener, mFocusDelegate, @@ -831,11 +861,14 @@ public abstract class SelectionTracker { public void run() { mRecyclerView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); } - }); + }, + // Provide temporary glue to address b/166836317 + backstop::onLongPress); for (int toolType : mGestureToolTypes) { - gestureRouter.register(toolType, touchHandler); - eventRouter.set(toolType, gestureHelper); + ToolSourceKey key = new ToolSourceKey(toolType); + gestureRouter.register(key, touchHandler); + eventRouter.set(key, gestureSelectionHelper); } // Provides high level glue for binding mouse events and gestures @@ -849,10 +882,14 @@ public abstract class SelectionTracker { mFocusDelegate); for (int toolType : mPointerToolTypes) { - gestureRouter.register(toolType, mouseHandler); + gestureRouter.register(new ToolSourceKey(toolType), mouseHandler); } - @Nullable BandSelectionHelper bandHelper = null; + ToolSourceKey touchpadKey = new ToolSourceKey(MotionEvent.TOOL_TYPE_FINGER, + InputDevice.SOURCE_MOUSE); + gestureRouter.register(touchpadKey, mouseHandler); + + BandSelectionHelper bandHelper = null; // Band selection not supported in single select mode, or when key access // is limited to anything less than the entire corpus. @@ -880,7 +917,8 @@ public abstract class SelectionTracker { OnItemTouchListener pointerEventHandler = new PointerDragEventInterceptor( mDetailsLookup, mOnDragInitiatedListener, bandHelper); - eventRouter.set(MotionEvent.TOOL_TYPE_MOUSE, pointerEventHandler); + eventRouter.set(new ToolSourceKey(MotionEvent.TOOL_TYPE_MOUSE), pointerEventHandler); + eventRouter.set(touchpadKey, pointerEventHandler); return tracker; } diff --git a/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java b/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java index bfda007efc..bfae1831ae 100644 --- a/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java +++ b/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java @@ -16,27 +16,44 @@ package androidx.recyclerview.selection; +import static androidx.core.util.Preconditions.checkArgument; +import static androidx.core.util.Preconditions.checkNotNull; import static androidx.recyclerview.selection.Shared.DEBUG; import android.util.Log; import android.util.SparseArray; import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.collection.LongSparseArray; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnChildAttachStateChangeListener; +import androidx.recyclerview.widget.RecyclerView.RecyclerListener; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** - * An {@link ItemKeyProvider} that provides stable ids by way of cached - * {@link RecyclerView.Adapter} stable ids. Items enter the cache as they are laid out by - * RecyclerView, and are removed from the cache as they are recycled. + * An {@link ItemKeyProvider} that provides item keys by way of native + * {@link RecyclerView.Adapter} stable ids. + * + *

The corresponding RecyclerView.Adapter instance must: + *

    + *
  1. Enable stable ids using {@link RecyclerView.Adapter#setHasStableIds(boolean)} + *
  2. Override {@link RecyclerView.Adapter#getItemId(int)} with a real implementation. + *
* *

- * There are trade-offs with this implementation as it necessarily auto-boxes {@code long} - * stable id values into {@code Long} values for use as selection keys. The core Selection API - * uses a parameterized key type to permit other keys (such as Strings or URIs). + * There are trade-offs with this implementation: + *

    + *
  • It necessarily auto-boxes {@code long} stable id values into {@code Long} values for + * use as selection keys. + *
  • It deprives Chromebook users (actually, any device with an attached pointer) of support + * for band-selection. + *
+ * + *

See com.example.android.supportv7.widget.selection.fancy.DemoAdapter.KeyProvider in the + * SupportV7 Demos package for an example of how to implement a better ItemKeyProvider. */ public final class StableIdKeyProvider extends ItemKeyProvider { @@ -44,48 +61,57 @@ public final class StableIdKeyProvider extends ItemKeyProvider { private final SparseArray mPositionToKey = new SparseArray<>(); private final LongSparseArray mKeyToPosition = new LongSparseArray<>(); - private final RecyclerView mRecyclerView; - - /** - * Creates a new key provider that uses cached {@code long} stable ids associated - * with the RecyclerView items. - * - * @param recyclerView the owner RecyclerView - */ - public StableIdKeyProvider(@NonNull RecyclerView recyclerView) { + private final ViewHost mHost; - // Since this provide is based on stable ids based on whats laid out in the window - // we can only satisfy "window" scope key access. + StableIdKeyProvider(@NonNull ViewHost host) { + // Provider is based on the stable ids provided by ViewHolders which + // are only accessible when the holders are attached or yet-to-be-recycled. + // For that reason we can only satisfy "CACHED" scope key access which + // limits library features such as mouse-driven band selection. super(SCOPE_CACHED); - mRecyclerView = recyclerView; + checkNotNull(host); + mHost = host; - mRecyclerView.addOnChildAttachStateChangeListener( - new OnChildAttachStateChangeListener() { + mHost.registerLifecycleListener( + new ViewHost.LifecycleListener() { @Override - public void onChildViewAttachedToWindow(View view) { - onAttached(view); + public void onAttached(@NonNull View view) { + StableIdKeyProvider.this.onAttached(view); } @Override - public void onChildViewDetachedFromWindow(View view) { - onDetached(view); + public void onRecycled(@NonNull View view) { + StableIdKeyProvider.this.onRecycled(view); } } ); + } + + /** + * Creates a new key provider that uses cached {@code long} stable ids associated + * with the RecyclerView items. + * + * @param recyclerView the owner RecyclerView + */ + public StableIdKeyProvider(@NonNull RecyclerView recyclerView) { + this(new DefaultViewHost(recyclerView)); + // Adapters used w/ StableIdKeyProvider MUST have StableIds enabled. + checkArgument(recyclerView.getAdapter().hasStableIds(), "RecyclerView" + + ".Adapter#hasStableIds must return true."); } @SuppressWarnings("WeakerAccess") /* synthetic access */ void onAttached(@NonNull View view) { - RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view); + ViewHolder holder = mHost.findViewHolder(view); if (holder == null) { if (DEBUG) { Log.w(TAG, "Unable to find ViewHolder for View. Ignoring onAttached event."); } return; } - int position = holder.getAbsoluteAdapterPosition(); + int position = mHost.getPosition(holder); long id = holder.getItemId(); if (position != RecyclerView.NO_POSITION && id != RecyclerView.NO_ID) { mPositionToKey.put(position, id); @@ -94,15 +120,15 @@ public final class StableIdKeyProvider extends ItemKeyProvider { } @SuppressWarnings("WeakerAccess") /* synthetic access */ - void onDetached(@NonNull View view) { - RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view); + void onRecycled(@NonNull View view) { + ViewHolder holder = mHost.findViewHolder(view); if (holder == null) { if (DEBUG) { Log.w(TAG, "Unable to find ViewHolder for View. Ignoring onDetached event."); } return; } - int position = holder.getAbsoluteAdapterPosition(); + int position = mHost.getPosition(holder); long id = holder.getItemId(); if (position != RecyclerView.NO_POSITION && id != RecyclerView.NO_ID) { mPositionToKey.delete(position); @@ -112,6 +138,8 @@ public final class StableIdKeyProvider extends ItemKeyProvider { @Override public @Nullable Long getKey(int position) { + // TODO: Consider using RecyclerView.NO_ID for consistency w/ getPosition impl. + // Currently GridModel impl depends on null return values. return mPositionToKey.get(position, null); } @@ -119,4 +147,91 @@ public final class StableIdKeyProvider extends ItemKeyProvider { public int getPosition(@NonNull Long key) { return mKeyToPosition.get(key, RecyclerView.NO_POSITION); } + + /** + * A wrapper interface for RecyclerView allowing for easy unit testing. + */ + interface ViewHost { + /** Registers View{Holder} lifecycle event listener. */ + void registerLifecycleListener(@NonNull LifecycleListener listener); + + /** + * Returns the ViewHolder containing {@code View}. + */ + @Nullable ViewHolder findViewHolder(@NonNull View view); + + /** + * Returns the position of the ViewHolder, or RecyclerView.NO_POSITION + * if unknown. + * + * This method supports testing of StableIdKeyProvider independent of + * a real RecyclerView instance. The correct runtime implementation is + * {@code return holder.getAbsoluteAdapterPosition}. This implementation + * depends on a concrete RecyclerView instance, which isn't test friendly + * given the testing approach in StableIdKeyProviderTest. Thus the + * introduction of this interface method allowing a test double to + * supply adapter position as needed to test. + */ + int getPosition(@NonNull ViewHolder holder); + + /** A View{Holder} lifecycle listener interface. */ + interface LifecycleListener { + + /** Called when view is attached. */ + void onAttached(@NonNull View view); + + /** Called when view is recycled. */ + void onRecycled(@NonNull View view); + } + } + + /** + * Implementation of ViewHost that wraps a RecyclerView instance. + */ + private static class DefaultViewHost implements ViewHost { + private final @NonNull RecyclerView mRecyclerView; + + DefaultViewHost(@NonNull RecyclerView recyclerView) { + checkNotNull(recyclerView); + mRecyclerView = recyclerView; + } + + @Override + public void registerLifecycleListener(@NonNull LifecycleListener listener) { + + mRecyclerView.addOnChildAttachStateChangeListener( + new OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(@NonNull View view) { + listener.onAttached(view); + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View view) { + // Cached position <> key data is discarded only when + // a view is recycled. See b/145767095 for details. + } + } + ); + + mRecyclerView.addRecyclerListener( + new RecyclerListener() { + @Override + public void onViewRecycled(@NonNull ViewHolder holder) { + listener.onRecycled(holder.itemView); + } + } + ); + } + + @Override + public @Nullable ViewHolder findViewHolder(@NonNull View view) { + return mRecyclerView.findContainingViewHolder(view); + } + + @Override + public int getPosition(@NonNull ViewHolder holder) { + return holder.getAbsoluteAdapterPosition(); + } + } } diff --git a/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java b/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java index 1fb7b7504b..e90ea04a60 100644 --- a/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java +++ b/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java @@ -21,10 +21,11 @@ import static androidx.core.util.Preconditions.checkArgument; import android.os.Bundle; import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; /** @@ -87,7 +88,7 @@ public abstract class StorageStrategy { * @return StorageStrategy suitable for use with {@link Parcelable} keys * (like {@link android.net.Uri}). */ - public static @NonNull StorageStrategy createParcelableStorage( + public static @NonNull StorageStrategy createParcelableStorage( @NonNull Class type) { return new ParcelableStorageStrategy<>(type); } @@ -120,7 +121,7 @@ public abstract class StorageStrategy { return null; } - @Nullable ArrayList stored = state.getStringArrayList(SELECTION_ENTRIES); + ArrayList stored = state.getStringArrayList(SELECTION_ENTRIES); if (stored == null) { return null; } @@ -158,7 +159,7 @@ public abstract class StorageStrategy { return null; } - @Nullable long[] stored = state.getLongArray(SELECTION_ENTRIES); + long[] stored = state.getLongArray(SELECTION_ENTRIES); if (stored == null) { return null; } @@ -196,6 +197,7 @@ public abstract class StorageStrategy { } @Override + @SuppressWarnings("deprecation") public @Nullable Selection asSelection(@NonNull Bundle state) { String keyType = state.getString(SELECTION_KEY_TYPE, null); @@ -203,7 +205,7 @@ public abstract class StorageStrategy { return null; } - @Nullable ArrayList stored = state.getParcelableArrayList(SELECTION_ENTRIES); + ArrayList stored = state.getParcelableArrayList(SELECTION_ENTRIES); if (stored == null) { return null; } diff --git a/app/src/main/java/androidx/recyclerview/selection/DummyOnItemTouchListener.java b/app/src/main/java/androidx/recyclerview/selection/StubOnItemTouchListener.java similarity index 91% rename from app/src/main/java/androidx/recyclerview/selection/DummyOnItemTouchListener.java rename to app/src/main/java/androidx/recyclerview/selection/StubOnItemTouchListener.java index 5880d9781a..093cd3e9fd 100644 --- a/app/src/main/java/androidx/recyclerview/selection/DummyOnItemTouchListener.java +++ b/app/src/main/java/androidx/recyclerview/selection/StubOnItemTouchListener.java @@ -18,14 +18,15 @@ package androidx.recyclerview.selection; import android.view.MotionEvent; -import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * No-op implementation of OnItemTouchListener suitable for use as a default * handler w/ ToolHandlerRegistery, or in tests. */ -final class DummyOnItemTouchListener implements RecyclerView.OnItemTouchListener { +final class StubOnItemTouchListener implements RecyclerView.OnItemTouchListener { @Override public boolean onInterceptTouchEvent( @NonNull RecyclerView unused, @NonNull MotionEvent e) { diff --git a/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java b/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java deleted file mode 100644 index 856ebb3bc8..0000000000 --- a/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2017 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 androidx.recyclerview.selection; - -import static androidx.core.util.Preconditions.checkArgument; -import static androidx.core.util.Preconditions.checkState; - -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Arrays; -import java.util.List; - -/** - * Registry for tool specific event handler. This provides map like functionality, - * along with fallback to a default handler, while avoiding auto-boxing of tool - * type values that would be necessitated were a Map used.k - * - *

ToolHandlerRegistry guarantees that it will never return a null handler ensuring - * client code isn't peppered with null checks. To that end a default handler - * is required. This default handler will be returned when a handler matching - * the event tooltype has not be registered using {@link #set(int, T)}. - * - * @param type of item being registered. - */ -final class ToolHandlerRegistry { - - // list with one null entry for each known tooltype (0-4). - // See MotionEvent.TOOL_TYPE_ERASER for details. We're using a list here because - // it is parameterized type friendly, and a natural container given that - // the index values are 0-based ints. - private final List mHandlers = Arrays.asList(null, null, null, null, null); - private final T mDefault; - - ToolHandlerRegistry(@NonNull T defaultDelegate) { - checkArgument(defaultDelegate != null); - mDefault = defaultDelegate; - } - - /** - * @param toolType - * @param delegate the delegate, or null to unregister. - * @throws IllegalStateException if an tooltype handler is already registered. - */ - void set(int toolType, @Nullable T delegate) { - checkArgument(toolType >= 0 && toolType <= MotionEvent.TOOL_TYPE_ERASER); - checkState(mHandlers.get(toolType) == null); - - mHandlers.set(toolType, delegate); - } - - T get(@NonNull MotionEvent e) { - T d = mHandlers.get(e.getToolType(0)); - return d != null ? d : mDefault; - } -} diff --git a/app/src/main/java/androidx/recyclerview/selection/ToolSourceHandlerRegistry.java b/app/src/main/java/androidx/recyclerview/selection/ToolSourceHandlerRegistry.java new file mode 100644 index 0000000000..fd05c014e3 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ToolSourceHandlerRegistry.java @@ -0,0 +1,82 @@ +/* + * Copyright 2017 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 androidx.recyclerview.selection; + +import static androidx.core.util.Preconditions.checkArgument; + +import android.view.MotionEvent; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry for event handlers. This is keyed by a ToolSourceKey which allows for searching for a + * handler which handles both a specific SOURCE + TOOL and if none exists, will fall back to + * TOOL only handlers. If none exists, a default handler will be returned instead. + * + *

ToolHandlerRegistry guarantees that it will never return a null handler ensuring + * client code isn't peppered with null checks. To that end a default handler + * is required. This default handler will be returned when a handler matching + * the event ToolSourceKey has not be registered using + * {@link ToolSourceHandlerRegistry#set(ToolSourceKey, T)}. + * + * @param type of item being registered. + */ +final class ToolSourceHandlerRegistry { + + /** + * A map that is keyed by a ToolSourceKey which contains either a TOOL + SOURCE or just a + * TOOL. This allows for handlers to get routed to more specific handlers before falling back + * to less specific and finally the default one. + */ + + private final Map mHandlers = new HashMap(); + + private final T mDefault; + + ToolSourceHandlerRegistry(@NonNull T defaultDelegate) { + checkArgument(defaultDelegate != null); + mDefault = defaultDelegate; + } + + /** + * @param delegate the delegate, or null to unregister. + * @throws IllegalStateException if a key already has a registered handler. + */ + void set(@NonNull ToolSourceKey key, @Nullable T delegate) { + if (delegate == null && mHandlers.containsKey(key)) { + mHandlers.remove(key); + return; + } + + mHandlers.put(key, delegate); + } + + T get(@NonNull MotionEvent e) { + ToolSourceKey key = ToolSourceKey.fromMotionEvent(e); + T d = mHandlers.get(key); + if (d == null) { + // If the map of handlers doesn't contain a specific MotionEventKey(tool, source) + // then fallback to the less specific MotionEventKey(tool). + d = mHandlers.get(new ToolSourceKey(key.getToolType())); + } + return d != null ? d : mDefault; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/ToolSourceKey.java b/app/src/main/java/androidx/recyclerview/selection/ToolSourceKey.java new file mode 100644 index 0000000000..8ed02fb52f --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ToolSourceKey.java @@ -0,0 +1,114 @@ +/* + * 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 androidx.recyclerview.selection; + +import static android.view.InputDevice.SOURCE_MOUSE; +import static android.view.InputDevice.SOURCE_UNKNOWN; +import static android.view.MotionEvent.TOOL_TYPE_ERASER; +import static android.view.MotionEvent.TOOL_TYPE_FINGER; +import static android.view.MotionEvent.TOOL_TYPE_MOUSE; +import static android.view.MotionEvent.TOOL_TYPE_STYLUS; +import static android.view.MotionEvent.TOOL_TYPE_UNKNOWN; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY; + +import android.view.InputDevice; +import android.view.MotionEvent; + +import androidx.annotation.IntDef; +import androidx.annotation.RestrictTo; + +import org.jspecify.annotations.NonNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Objects; + +/** + * Enables storing multiple {@link MotionEvent} parameters (e.g. + * {@link MotionEvent#getToolType(int)}) as a key in a map. This opens up the ability to map + * these multiple parameters against their respective handlers. For example some events behave + * differently based on their toolType and source where others just require toolType. + */ +@RestrictTo(LIBRARY) +public class ToolSourceKey { + private final @ToolType int mToolType; + private final @Source int mSource; + + ToolSourceKey(@ToolType int toolType) { + mToolType = toolType; + mSource = InputDevice.SOURCE_UNKNOWN; + } + + ToolSourceKey(@ToolType int toolType, @Source int source) { + mToolType = toolType; + mSource = source; + } + + /** + * Create a `ToolSourceKey` from a supplied `MotionEvent`. + * + * @return {@link ToolSourceKey} + */ + public static @NonNull ToolSourceKey fromMotionEvent(@NonNull MotionEvent e) { + return new ToolSourceKey(e.getToolType(0), e.getSource()); + } + + public int getToolType() { + return mToolType; + } + + public int getSource() { + return mSource; + } + + @Override + public int hashCode() { + return Objects.hash(mToolType, mSource); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ToolSourceKey)) { + return false; + } + ToolSourceKey matcher = (ToolSourceKey) obj; + return mToolType == matcher.getToolType() && mSource == matcher.getSource(); + } + + @Override + public @NonNull String toString() { + return String.valueOf(mToolType) + "," + String.valueOf(mSource); + } + + @IntDef(value = {TOOL_TYPE_FINGER, TOOL_TYPE_MOUSE, TOOL_TYPE_ERASER, TOOL_TYPE_STYLUS, + TOOL_TYPE_UNKNOWN}) + @Retention(RetentionPolicy.SOURCE) + @interface ToolType { + } + + /** + * Please add additional sources here from InputDevice.SOURCE_*. + */ + @IntDef(value = {SOURCE_MOUSE, SOURCE_UNKNOWN}) + @Retention(RetentionPolicy.SOURCE) + @interface Source { + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java index be4c3fa0b3..bee152f1cb 100644 --- a/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java +++ b/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java @@ -22,11 +22,12 @@ import static androidx.recyclerview.selection.Shared.DEBUG; import android.util.Log; import android.view.MotionEvent; -import androidx.annotation.NonNull; import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; + /** * A MotionInputHandler that provides the high-level glue for touch driven selection. This class * works with {@link RecyclerView}, {@link GestureRouter}, and {@link GestureSelectionHelper} to @@ -36,7 +37,7 @@ import androidx.recyclerview.widget.RecyclerView; */ final class TouchInputHandler extends MotionInputHandler { - private static final String TAG = "TouchInputDelegate"; + private static final String TAG = "TouchInputHandler"; private final ItemDetailsLookup mDetailsLookup; private final SelectionPredicate mSelectionPredicate; @@ -44,6 +45,7 @@ final class TouchInputHandler extends MotionInputHandler { private final OnDragInitiatedListener mOnDragInitiatedListener; private final Runnable mGestureStarter; private final Runnable mHapticPerformer; + private final Runnable mLongPressCallback; TouchInputHandler( @NonNull SelectionTracker selectionTracker, @@ -54,7 +56,8 @@ final class TouchInputHandler extends MotionInputHandler { @NonNull OnDragInitiatedListener onDragInitiatedListener, @NonNull OnItemActivatedListener onItemActivatedListener, @NonNull FocusDelegate focusDelegate, - @NonNull Runnable hapticPerformer) { + @NonNull Runnable hapticPerformer, + @NonNull Runnable longPressCallback) { super(selectionTracker, keyProvider, focusDelegate); @@ -71,6 +74,7 @@ final class TouchInputHandler extends MotionInputHandler { mOnItemActivatedListener = onItemActivatedListener; mOnDragInitiatedListener = onDragInitiatedListener; mHapticPerformer = hapticPerformer; + mLongPressCallback = longPressCallback; } @Override @@ -80,16 +84,10 @@ final class TouchInputHandler extends MotionInputHandler { checkArgument(MotionEvents.isActionUp(e)); } - if (!mDetailsLookup.overItemWithSelectionKey(e)) { - if (DEBUG) Log.d(TAG, "Tap not associated w/ model item. Clearing selection."); - mSelectionTracker.clearSelection(); - return false; - } - ItemDetails item = mDetailsLookup.getItemDetails(e); - // Should really not be null at this point, but... - if (item == null) { - return false; + if (item == null || !item.hasSelectionKey()) { + if (DEBUG) Log.d(TAG, "Tap not associated w/ model item. Clearing selection."); + return mSelectionTracker.clearSelection(); } if (mSelectionTracker.hasSelection()) { @@ -111,6 +109,25 @@ final class TouchInputHandler extends MotionInputHandler { : mOnItemActivatedListener.onItemActivated(item, e); } + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + // Reinterpret an UP event in the double tap event stream as a singleTapUp. + // + // Background: GestureRouter is both an OnGestureListener and a OnDoubleTapListener, + // which allows it to dispatch events based on tooltype to the Touch or Mouse + // input handler. + // + // Turns out the act of instantiating GestureDetector with an OnDoubleTapListener + // signals to it that we want onDoubleTap events rather than a series of individual + // onSingleTapUp events, resulting in some touch input being mishandled + // by TouchInputHandler. See b/161162268 for some supporting details. + // + // There are a variety of ways to work around this. Given long term plans + // to replace GestureDetector (b/159025478), we'll just reroute + // the second UP event to the onSingleTapUp handler. + return MotionEvents.isActionUp(e) && onSingleTapUp(e); + } + @Override public void onLongPress(@NonNull MotionEvent e) { if (DEBUG) { @@ -129,26 +146,31 @@ final class TouchInputHandler extends MotionInputHandler { return; } + // Temprary fix to address b/166836317. + mLongPressCallback.run(); + if (shouldExtendRange(e)) { extendSelectionRange(item); mHapticPerformer.run(); - } else { - if (mSelectionTracker.isSelected(item.getSelectionKey())) { - // Long press on existing selected item initiates drag/drop. - mOnDragInitiatedListener.onDragInitiated(e); - mHapticPerformer.run(); - } else if (mSelectionPredicate.canSetStateForKey(item.getSelectionKey(), true) - && selectItem(item)) { - // And finally if the item was selected && we can select multiple - // we kick off gesture selection. - // NOTE: isRangeActive should ALWAYS be true at this point, but there have - // been reports indicating that assumption isn't correct. So we explicitly - // check isRangeActive. - if (mSelectionPredicate.canSelectMultiple() && mSelectionTracker.isRangeActive()) { - mGestureStarter.run(); - } + return; + } + + if (mSelectionTracker.isSelected(item.getSelectionKey())) { + // Long press on existing selected item initiates drag/drop. + if (mOnDragInitiatedListener.onDragInitiated(e)) { mHapticPerformer.run(); } + } else if (mSelectionPredicate.canSetStateForKey(item.getSelectionKey(), true) + && selectItem(item)) { + // And finally if the item was selected && we can select multiple + // we kick off gesture selection. + // NOTE: isRangeActive should ALWAYS be true at this point, but there have + // been reports indicating that assumption isn't correct. So we explicitly + // check isRangeActive. + if (mSelectionPredicate.canSelectMultiple() && mSelectionTracker.isRangeActive()) { + mGestureStarter.run(); + } + mHapticPerformer.run(); } } } diff --git a/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java index 9201380274..7b6551f8e2 100644 --- a/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java +++ b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java @@ -25,12 +25,13 @@ import android.graphics.Point; import android.graphics.Rect; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.core.view.ViewCompat; import androidx.recyclerview.widget.RecyclerView; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + /** * Provides auto-scrolling upon request when user's interaction with the application * introduces a natural intent to scroll. Used by BandSelectionHelper and GestureSelectionHelper, diff --git a/patches/SelectionTracker_1.2.0.patch b/patches/SelectionTracker_1.2.0.patch new file mode 100644 index 0000000000..bc2f79b2f3 --- /dev/null +++ b/patches/SelectionTracker_1.2.0.patch @@ -0,0 +1,149 @@ +diff --git b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java a/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java +index 88418ace1d..ffc0f8736a 100644 +--- b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java ++++ a/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java +@@ -379,6 +379,10 @@ public class DefaultSelectionTracker extends SelectionTracker implements R + return mRange != null; + } + ++ boolean isOverlapping(int position, int count) { ++ return (mRange != null && mRange.isOverlapping(position, count)); ++ } ++ + private boolean canSetState(@NonNull K key, boolean nextState) { + return mSelectionPredicate.canSetStateForKey(key, nextState); + } +@@ -395,7 +399,7 @@ public class DefaultSelectionTracker extends SelectionTracker implements R + return; + } + +- mSelection.clearProvisionalSelection(); ++ //mSelection.clearProvisionalSelection(); + + notifySelectionRefresh(); + +@@ -611,12 +615,14 @@ public class DefaultSelectionTracker extends SelectionTracker implements R + + @Override + public void onItemRangeInserted(int startPosition, int itemCount) { +- mSelectionTracker.endRange(); ++ if (mSelectionTracker.isOverlapping(startPosition, itemCount)) ++ mSelectionTracker.endRange(); + } + + @Override + public void onItemRangeRemoved(int startPosition, int itemCount) { +- mSelectionTracker.endRange(); ++ if (mSelectionTracker.isOverlapping(startPosition, itemCount)) ++ mSelectionTracker.endRange(); + // Since SelectionTracker deals in keys, not positions, we turn + // to the `onDataSetChanged` sledge hammer. + // DefaultSelectionTracker will validate and update it's selection. +@@ -625,7 +631,9 @@ public class DefaultSelectionTracker extends SelectionTracker implements R + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { +- mSelectionTracker.endRange(); ++ if (mSelectionTracker.isOverlapping(fromPosition, itemCount) || ++ mSelectionTracker.isOverlapping(toPosition, itemCount)) ++ mSelectionTracker.endRange(); + } + } + } +diff --git b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java a/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java +index 9f6068f7bf..72e5c948cd 100644 +--- b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java ++++ a/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java +@@ -94,7 +94,28 @@ final class GestureRouter + + @Override + public void onLongPress(@NonNull MotionEvent e) { +- mDelegates.get(e).onLongPress(e); ++ try { ++ mDelegates.get(e).onLongPress(e); ++ } catch (Throwable ex) { ++ eu.faircode.email.Log.w(ex); ++ /* ++ java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling eu.faircode.email.FixedRecyclerView{239c688b VFED.... ........ 0,0-800,1162 #7f0a04da app:id/rvMessage}, adapter:eu.faircode.email.AdapterMessage@209415c5, layout:eu.faircode.email.FragmentMessages$7@190d7b1a, context:eu.faircode.email.ActivityView@3e8522fb ++ at androidx.recyclerview.widget.RecyclerView.assertNotInLayoutOrScroll(SourceFile:3) ++ at androidx.recyclerview.widget.RecyclerView$RecyclerViewDataObserver.onItemRangeChanged(SourceFile:1) ++ at androidx.recyclerview.widget.RecyclerView$AdapterDataObservable.notifyItemRangeChanged(SourceFile:3) ++ at androidx.recyclerview.widget.RecyclerView$Adapter.notifyItemChanged(SourceFile:2) ++ at androidx.recyclerview.selection.EventBridge$TrackerToAdapterBridge.onItemStateChanged(SourceFile:3) ++ at androidx.recyclerview.selection.DefaultSelectionTracker.notifyItemStateChanged(SourceFile:3) ++ at androidx.recyclerview.selection.DefaultSelectionTracker.select(SourceFile:8) ++ at androidx.recyclerview.selection.MotionInputHandler.selectItem(SourceFile:4) ++ at androidx.recyclerview.selection.TouchInputHandler.onLongPress(SourceFile:10) ++ at androidx.recyclerview.selection.GestureRouter.onLongPress(SourceFile:1) ++ at android.view.GestureDetector.dispatchLongPress(GestureDetector.java:700) ++ at android.view.GestureDetector.access$200(GestureDetector.java:40) ++ at android.view.GestureDetector$GestureHandler.handleMessage(GestureDetector.java:273) ++ at android.os.Handler.dispatchMessage(Handler.java:102) ++ */ ++ } + } + + @Override +diff --git b/app/src/main/java/androidx/recyclerview/selection/Range.java a/app/src/main/java/androidx/recyclerview/selection/Range.java +index 6a53b1f4fc..dc372bad93 100644 +--- b/app/src/main/java/androidx/recyclerview/selection/Range.java ++++ a/app/src/main/java/androidx/recyclerview/selection/Range.java +@@ -170,6 +170,11 @@ final class Range { + mCallbacks.updateForRange(begin, end, selected, type); + } + ++ boolean isOverlapping(int position, int count) { ++ return (position >= mBegin && position <= mEnd) || ++ (position + count >= mBegin && position + count <= mEnd); ++ } ++ + @Override + public String toString() { + return "Range{begin=" + mBegin + ", end=" + mEnd + "}"; +diff --git b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java a/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java +index 41ddefa9b1..8c86d4adbc 100644 +--- b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java ++++ a/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java +@@ -529,7 +529,7 @@ public abstract class SelectionTracker { + private OnContextClickListener mOnContextClickListener; + + private BandPredicate mBandPredicate; +- private int mBandOverlayId = R.drawable.selection_band_overlay; ++ private int mBandOverlayId = eu.faircode.email.R.drawable.selection_band_overlay; + + // TODO(b/144500333): Remove support for overriding gesture and pointer tooltypes. + private int[] mGestureToolTypes = new int[]{ +@@ -675,7 +675,7 @@ public abstract class SelectionTracker { + * @deprecated GestureSelection is best bound to {@link MotionEvent#TOOL_TYPE_FINGER}, + * and only that tool type. This method will be removed in a future release. + */ +- @Deprecated ++ //@Deprecated + public @NonNull Builder withGestureTooltypes(int @NonNull ... toolTypes) { + Log.w(TAG, "Setting gestureTooltypes is likely to result in unexpected behavior."); + mGestureToolTypes = toolTypes; +@@ -713,7 +713,7 @@ public abstract class SelectionTracker { + * @deprecated PointerSelection is best bound to {@link MotionEvent#TOOL_TYPE_MOUSE}, + * and only that tool type. This method will be removed in a future release. + */ +- @Deprecated ++ //@Deprecated + public @NonNull Builder withPointerTooltypes(int @NonNull ... toolTypes) { + Log.w(TAG, "Setting pointerTooltypes is likely to result in unexpected behavior."); + mPointerToolTypes = toolTypes; +diff --git b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java a/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java +index 4701bef30c..7b6551f8e2 100644 +--- b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java ++++ a/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java +@@ -108,6 +108,11 @@ final class ViewAutoScroller extends AutoScroller { + + if (VERBOSE) Log.v(TAG, "Running in background using event location @ " + mLastLocation); + ++ if (mLastLocation == null) { ++ eu.faircode.email.Log.w("ViewAutoScroller.mLastLocation is null"); ++ return; ++ } ++ + // Compute the number of pixels the pointer's y-coordinate is past the view. + // Negative values mean the pointer is at or before the top of the view, and + // positive values mean that the pointer is at or after the bottom of the view. Note