1/*
2 * Copyright 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.slice.widget;
18
19import static android.app.slice.Slice.SUBTYPE_COLOR;
20import static android.app.slice.SliceItem.FORMAT_INT;
21
22import android.app.PendingIntent;
23import android.content.Context;
24import android.content.res.TypedArray;
25import android.graphics.drawable.ColorDrawable;
26import android.os.Handler;
27import android.util.AttributeSet;
28import android.util.Log;
29import android.view.HapticFeedbackConstants;
30import android.view.MotionEvent;
31import android.view.View;
32import android.view.ViewConfiguration;
33import android.view.ViewGroup;
34
35import androidx.annotation.ColorInt;
36import androidx.annotation.IntDef;
37import androidx.annotation.NonNull;
38import androidx.annotation.Nullable;
39import androidx.annotation.RequiresApi;
40import androidx.annotation.RestrictTo;
41import androidx.lifecycle.Observer;
42import androidx.slice.Slice;
43import androidx.slice.SliceItem;
44import androidx.slice.SliceMetadata;
45import androidx.slice.core.SliceActionImpl;
46import androidx.slice.core.SliceHints;
47import androidx.slice.core.SliceQuery;
48import androidx.slice.view.R;
49
50import java.lang.annotation.Retention;
51import java.lang.annotation.RetentionPolicy;
52import java.util.List;
53
54/**
55 * A view for displaying a {@link Slice} which is a piece of app content and actions. SliceView is
56 * able to present slice content in a templated format outside of the associated app. The way this
57 * content is displayed depends on the structure of the slice, the hints associated with the
58 * content, and the mode that SliceView is configured for. The modes that SliceView supports are:
59 * <ul>
60 * <li><b>Shortcut</b>: A shortcut is presented as an icon and a text label representing the main
61 * content or action associated with the slice.</li>
62 * <li><b>Small</b>: The small format has a restricted height and can present a single
63 * {@link SliceItem} or a limited collection of items.</li>
64 * <li><b>Large</b>: The large format displays multiple small templates in a list, if scrolling is
65 * not enabled (see {@link #setScrollable(boolean)}) the view will show as many items as it can
66 * comfortably fit.</li>
67 * </ul>
68 * <p>
69 * When constructing a slice, the contents of it can be annotated with hints, these provide the OS
70 * with some information on how the content should be displayed. For example, text annotated with
71 * {@link android.app.slice.Slice#HINT_TITLE} would be placed in the title position of a template.
72 * A slice annotated with {@link android.app.slice.Slice#HINT_LIST} would present the child items
73 * of that slice in a list.
74 * <p>
75 * Example usage:
76 *
77 * <pre class="prettyprint">
78 * SliceView v = new SliceView(getContext());
79 * v.setMode(desiredMode);
80 * LiveData<Slice> liveData = SliceLiveData.fromUri(sliceUri);
81 * liveData.observe(lifecycleOwner, v);
82 * </pre>
83 * @see SliceLiveData
84 */
85public class SliceView extends ViewGroup implements Observer<Slice>, View.OnClickListener {
86
87    private static final String TAG = "SliceView";
88
89    /**
90     * Implement this interface to be notified of interactions with the slice displayed
91     * in this view.
92     * @see EventInfo
93     */
94    public interface OnSliceActionListener {
95        /**
96         * Called when an interaction has occurred with an element in this view.
97         * @param info the type of event that occurred.
98         * @param item the specific item within the {@link Slice} that was interacted with.
99         */
100        void onSliceAction(@NonNull EventInfo info, @NonNull SliceItem item);
101    }
102
103    /**
104     * @hide
105     */
106    @RestrictTo(RestrictTo.Scope.LIBRARY)
107    @IntDef({
108            MODE_SMALL, MODE_LARGE, MODE_SHORTCUT
109    })
110    @Retention(RetentionPolicy.SOURCE)
111    public @interface SliceMode {}
112
113    /**
114     * Mode indicating this slice should be presented in small template format.
115     */
116    public static final int MODE_SMALL       = 1;
117    /**
118     * Mode indicating this slice should be presented in large template format.
119     */
120    public static final int MODE_LARGE       = 2;
121    /**
122     * Mode indicating this slice should be presented as an icon. A shortcut requires an intent,
123     * icon, and label. This can be indicated by using {@link android.app.slice.Slice#HINT_TITLE}
124     * on an action in a slice.
125     */
126    public static final int MODE_SHORTCUT    = 3;
127
128    private int mMode = MODE_LARGE;
129    private Slice mCurrentSlice;
130    private ListContent mListContent;
131    private SliceChildView mCurrentView;
132    private List<SliceItem> mActions;
133    private ActionRow mActionRow;
134
135    private boolean mShowActions = false;
136    private boolean mIsScrollable = true;
137    private boolean mShowLastUpdated = true;
138
139    private int mShortcutSize;
140    private int mMinLargeHeight;
141    private int mMaxLargeHeight;
142    private int mActionRowHeight;
143
144    private AttributeSet mAttrs;
145    private int mDefStyleAttr;
146    private int mDefStyleRes;
147    private int mThemeTintColor = -1;
148
149    private OnSliceActionListener mSliceObserver;
150    private int mTouchSlopSquared;
151    private View.OnLongClickListener mLongClickListener;
152    private View.OnClickListener mOnClickListener;
153    private int mDownX;
154    private int mDownY;
155    private boolean mPressing;
156    private boolean mInLongpress;
157    private Handler mHandler;
158    int[] mClickInfo;
159
160    public SliceView(Context context) {
161        this(context, null);
162    }
163
164    public SliceView(Context context, @Nullable AttributeSet attrs) {
165        this(context, attrs, R.attr.sliceViewStyle);
166    }
167
168    public SliceView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
169        super(context, attrs, defStyleAttr);
170        init(context, attrs, defStyleAttr, R.style.Widget_SliceView);
171    }
172
173    @RequiresApi(21)
174    public SliceView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
175        super(context, attrs, defStyleAttr, defStyleRes);
176        init(context, attrs, defStyleAttr, defStyleRes);
177    }
178
179    private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
180        mAttrs = attrs;
181        mDefStyleAttr = defStyleAttr;
182        mDefStyleRes = defStyleRes;
183        TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SliceView,
184                defStyleAttr, defStyleRes);
185
186        try {
187            mThemeTintColor = a.getColor(R.styleable.SliceView_tintColor, -1);
188        } finally {
189            a.recycle();
190        }
191        mShortcutSize = getContext().getResources()
192                .getDimensionPixelSize(R.dimen.abc_slice_shortcut_size);
193        mMinLargeHeight = getResources().getDimensionPixelSize(R.dimen.abc_slice_large_height);
194        mMaxLargeHeight = getResources().getDimensionPixelSize(R.dimen.abc_slice_max_large_height);
195        mActionRowHeight = getResources().getDimensionPixelSize(
196                R.dimen.abc_slice_action_row_height);
197
198        mCurrentView = new LargeTemplateView(getContext());
199        mCurrentView.setMode(getMode());
200        addView(mCurrentView, getChildLp(mCurrentView));
201
202        // TODO: action row background should support light / dark / maybe presenter customization
203        mActionRow = new ActionRow(getContext(), true);
204        mActionRow.setBackground(new ColorDrawable(0xffeeeeee));
205        addView(mActionRow, getChildLp(mActionRow));
206
207        final int slop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
208        mTouchSlopSquared = slop * slop;
209        mHandler = new Handler();
210
211        super.setOnClickListener(this);
212    }
213
214    /**
215     * Indicates whether this view reacts to click events or not.
216     * @hide
217     */
218    @RestrictTo(RestrictTo.Scope.LIBRARY)
219    public boolean isSliceViewClickable() {
220        return mOnClickListener != null
221                || (mListContent != null && mListContent.getPrimaryAction() != null);
222    }
223
224    /**
225     * Sets the event info for logging a click.
226     * @hide
227     */
228    @RestrictTo(RestrictTo.Scope.LIBRARY)
229    public void setClickInfo(int[] info) {
230        mClickInfo = info;
231    }
232
233    @Override
234    public void onClick(View v) {
235        if (mListContent != null && mListContent.getPrimaryAction() != null) {
236            try {
237                SliceActionImpl sa = new SliceActionImpl(mListContent.getPrimaryAction());
238                sa.getAction().send();
239                if (mSliceObserver != null && mClickInfo != null && mClickInfo.length > 1) {
240                    EventInfo eventInfo = new EventInfo(getMode(),
241                            EventInfo.ACTION_TYPE_CONTENT, mClickInfo[0], mClickInfo[1]);
242                    mSliceObserver.onSliceAction(eventInfo, mListContent.getPrimaryAction());
243                }
244            } catch (PendingIntent.CanceledException e) {
245                Log.e(TAG, "PendingIntent for slice cannot be sent", e);
246            }
247        } else if (mOnClickListener != null) {
248            mOnClickListener.onClick(this);
249        }
250    }
251
252    @Override
253    public void setOnClickListener(View.OnClickListener listener) {
254        mOnClickListener = listener;
255    }
256
257    @Override
258    public void setOnLongClickListener(View.OnLongClickListener listener) {
259        super.setOnLongClickListener(listener);
260        mLongClickListener = listener;
261    }
262
263    @Override
264    public boolean onInterceptTouchEvent(MotionEvent ev) {
265        boolean ret = super.onInterceptTouchEvent(ev);
266        if (mLongClickListener != null) {
267            return handleTouchForLongpress(ev);
268        }
269        return ret;
270    }
271
272    @Override
273    public boolean onTouchEvent(MotionEvent ev) {
274        boolean ret = super.onTouchEvent(ev);
275        if (mLongClickListener != null) {
276            return handleTouchForLongpress(ev);
277        }
278        return ret;
279    }
280
281    private boolean handleTouchForLongpress(MotionEvent ev) {
282        int action = ev.getActionMasked();
283        switch (action) {
284            case MotionEvent.ACTION_DOWN:
285                mHandler.removeCallbacks(mLongpressCheck);
286                mDownX = (int) ev.getRawX();
287                mDownY = (int) ev.getRawY();
288                mPressing = true;
289                mInLongpress = false;
290                mHandler.postDelayed(mLongpressCheck, ViewConfiguration.getLongPressTimeout());
291                break;
292
293            case MotionEvent.ACTION_MOVE:
294                final int deltaX = (int) ev.getRawX() - mDownX;
295                final int deltaY = (int) ev.getRawY() - mDownY;
296                int distance = (deltaX * deltaX) + (deltaY * deltaY);
297                if (distance > mTouchSlopSquared) {
298                    mPressing = false;
299                    mHandler.removeCallbacks(mLongpressCheck);
300                }
301                break;
302
303            case MotionEvent.ACTION_CANCEL:
304            case MotionEvent.ACTION_UP:
305                mPressing = false;
306                mInLongpress = false;
307                mHandler.removeCallbacks(mLongpressCheck);
308                break;
309        }
310        return mInLongpress;
311    }
312
313    private int getHeightForMode() {
314        int mode = getMode();
315        if (mode == MODE_SHORTCUT) {
316            return mListContent != null && mListContent.isValid() ? mShortcutSize : 0;
317        }
318        return mode == MODE_LARGE
319                ? mCurrentView.getActualHeight()
320                : mCurrentView.getSmallHeight();
321    }
322
323    @Override
324    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
325        int width = MeasureSpec.getSize(widthMeasureSpec);
326        int childWidth = MeasureSpec.getSize(widthMeasureSpec);
327        if (MODE_SHORTCUT == mMode) {
328            // TODO: consider scaling the shortcut to fit if too small
329            childWidth = mShortcutSize;
330            width = mShortcutSize + getPaddingLeft() + getPaddingRight();
331        }
332        final int actionHeight = mActionRow.getVisibility() != View.GONE
333                ? mActionRowHeight
334                : 0;
335        final int sliceHeight = getHeightForMode();
336        final int heightAvailable = MeasureSpec.getSize(heightMeasureSpec);
337        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
338        // Remove the padding from our available height
339        int height = heightAvailable - getPaddingTop() - getPaddingBottom();
340        if (heightAvailable >= sliceHeight + actionHeight
341                || heightMode == MeasureSpec.UNSPECIFIED) {
342            // Available space is larger than the slice or we be what we want
343            if (heightMode != MeasureSpec.EXACTLY) {
344                if (!mIsScrollable) {
345                    height = Math.min(mMaxLargeHeight, sliceHeight);
346                } else {
347                    // If we want to be bigger than max, then we can be a good scrollable at min
348                    // large height, if it's not larger lets just use its desired height
349                    height = sliceHeight > mMaxLargeHeight ? mMinLargeHeight : sliceHeight;
350                }
351            }
352        } else {
353            // Not enough space available for slice in current mode
354            if (getMode() == MODE_LARGE && heightAvailable >= mMinLargeHeight + actionHeight) {
355                // It's just a slice with scrolling content; cap it to height available.
356                height = Math.min(mMinLargeHeight, heightAvailable);
357            } else if (getMode() == MODE_SHORTCUT) {
358                // TODO: consider scaling the shortcut to fit if too small
359                height = mShortcutSize;
360            }
361        }
362
363        int childHeight = height + getPaddingTop() + getPaddingBottom();
364        int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY);
365        int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY);
366        measureChild(mCurrentView, childWidthMeasureSpec, childHeightMeasureSpec);
367
368        int actionPaddedHeight = actionHeight + getPaddingTop() + getPaddingBottom();
369        int actionHeightSpec = MeasureSpec.makeMeasureSpec(actionPaddedHeight, MeasureSpec.EXACTLY);
370        measureChild(mActionRow, childWidthMeasureSpec, actionHeightSpec);
371
372        // Total height should include action row and our padding
373        height += actionHeight + getPaddingTop() + getPaddingBottom();
374        setMeasuredDimension(width, height);
375    }
376
377    @Override
378    protected void onLayout(boolean changed, int l, int t, int r, int b) {
379        View v = mCurrentView;
380        final int left = getPaddingLeft();
381        final int top = getPaddingTop();
382        v.layout(left, top, left + v.getMeasuredWidth(), top + v.getMeasuredHeight());
383        if (mActionRow.getVisibility() != View.GONE) {
384            mActionRow.layout(left,
385                    top + v.getMeasuredHeight(),
386                    left + mActionRow.getMeasuredWidth(),
387                    top + v.getMeasuredHeight() + mActionRow.getMeasuredHeight());
388        }
389    }
390
391    @Override
392    public void onChanged(@Nullable Slice slice) {
393        setSlice(slice);
394    }
395
396    /**
397     * Populates this view to the provided {@link Slice}.
398     *
399     * This will not update automatically if the slice content changes, for live
400     * content see {@link SliceLiveData}.
401     */
402    public void setSlice(@Nullable Slice slice) {
403        if (slice != null) {
404            if (mCurrentSlice == null || !mCurrentSlice.getUri().equals(slice.getUri())) {
405                mCurrentView.resetView();
406            }
407        } else {
408            // No slice, no actions
409            mActions = null;
410        }
411        mActions = SliceMetadata.getSliceActions(slice);
412        mCurrentSlice = slice;
413        reinflate();
414    }
415
416    /**
417     * @return the slice being used to populate this view.
418     */
419    @Nullable
420    public Slice getSlice() {
421        return mCurrentSlice;
422    }
423
424    /**
425     * Returns the slice actions presented in this view.
426     * <p>
427     * Note that these may be different from {@link SliceMetadata#getSliceActions()} if the actions
428     * set on the view have been adjusted using {@link #setSliceActions(List)}.
429     */
430    @Nullable
431    public List<SliceItem> getSliceActions() {
432        return mActions;
433    }
434
435    /**
436     * Sets the slice actions to display for the slice contained in this view. Normally SliceView
437     * will automatically show actions, however, it is possible to reorder or omit actions on the
438     * view using this method. This is generally discouraged.
439     * <p>
440     * It is required that the slice be set on this view before actions can be set, otherwise
441     * this will throw {@link IllegalStateException}. If any of the actions supplied are not
442     * available for the slice set on this view (i.e. the action is not returned by
443     * {@link SliceMetadata#getSliceActions()} this will throw {@link IllegalArgumentException}.
444     */
445    public void setSliceActions(@Nullable List<SliceItem> newActions) {
446        // Check that these actions are part of available set
447        if (mCurrentSlice == null) {
448            throw new IllegalStateException("Trying to set actions on a view without a slice");
449        }
450        List<SliceItem> availableActions = SliceMetadata.getSliceActions(mCurrentSlice);
451        if (availableActions != null && newActions != null) {
452            for (int i = 0; i < newActions.size(); i++) {
453                if (!availableActions.contains(newActions.get(i))) {
454                    throw new IllegalArgumentException(
455                            "Trying to set an action that isn't available: " + newActions.get(i));
456                }
457            }
458        }
459        mActions = newActions;
460        updateActions();
461    }
462
463    /**
464     * Set the mode this view should present in.
465     */
466    public void setMode(@SliceMode int mode) {
467        setMode(mode, false /* animate */);
468    }
469
470    /**
471     * Set whether this view should allow scrollable content when presenting in {@link #MODE_LARGE}.
472     */
473    public void setScrollable(boolean isScrollable) {
474        mIsScrollable = isScrollable;
475        reinflate();
476    }
477
478    /**
479     * Sets the listener to notify when an interaction events occur on the view.
480     * @see EventInfo
481     */
482    public void setOnSliceActionListener(@Nullable OnSliceActionListener observer) {
483        mSliceObserver = observer;
484        mCurrentView.setSliceActionListener(mSliceObserver);
485    }
486
487    /**
488     * @deprecated TO BE REMOVED; use {@link #setAccentColor(int)} instead.
489     */
490    @Deprecated
491    public void setTint(int tintColor) {
492        setAccentColor(tintColor);
493    }
494
495    /**
496     * Contents of a slice such as icons, text, and controls (e.g. toggle) can be tinted. Normally
497     * a color for tinting will be provided by the slice. Using this method will override
498     * the slice-provided color information and instead tint elements with the color set here.
499     *
500     * @param accentColor the color to use for tinting contents of this view.
501     */
502    public void setAccentColor(@ColorInt int accentColor) {
503        mThemeTintColor = accentColor;
504        mCurrentView.setTint(accentColor);
505    }
506
507    /**
508     * @hide
509     */
510    @RestrictTo(RestrictTo.Scope.LIBRARY)
511    public void setMode(@SliceMode int mode, boolean animate) {
512        if (animate) {
513            Log.e(TAG, "Animation not supported yet");
514        }
515        if (mMode == mode) {
516            return;
517        }
518        mMode = mode;
519        reinflate();
520    }
521
522    /**
523     * @return the mode this view is presenting in.
524     */
525    public @SliceMode int getMode() {
526        return mMode;
527    }
528
529    /**
530     * @hide
531     *
532     * Whether this view should show a row of actions with it.
533     */
534    @RestrictTo(RestrictTo.Scope.LIBRARY)
535    public void setShowActionRow(boolean show) {
536        mShowActions = show;
537        updateActions();
538    }
539
540    /**
541     * @return whether this view is showing a row of actions.
542     * @hide
543     */
544    @RestrictTo(RestrictTo.Scope.LIBRARY)
545    public boolean isShowingActionRow() {
546        return mShowActions;
547    }
548
549    private void reinflate() {
550        if (mCurrentSlice == null) {
551            mCurrentView.resetView();
552            updateActions();
553            return;
554        }
555        mListContent = new ListContent(getContext(), mCurrentSlice, mAttrs, mDefStyleAttr,
556                mDefStyleRes);
557        if (!mListContent.isValid()) {
558            mCurrentView.resetView();
559            updateActions();
560            return;
561        }
562
563        // TODO: Smarter mapping here from one state to the next.
564        int mode = getMode();
565        boolean isCurrentViewShortcut = mCurrentView instanceof ShortcutView;
566        if (mode == MODE_SHORTCUT && !isCurrentViewShortcut) {
567            removeAllViews();
568            mCurrentView = new ShortcutView(getContext());
569            addView(mCurrentView, getChildLp(mCurrentView));
570        } else if (mode != MODE_SHORTCUT && isCurrentViewShortcut) {
571            removeAllViews();
572            mCurrentView = new LargeTemplateView(getContext());
573            addView(mCurrentView, getChildLp(mCurrentView));
574        }
575        mCurrentView.setMode(mode);
576
577        mCurrentView.setSliceActionListener(mSliceObserver);
578        if (mCurrentView instanceof LargeTemplateView) {
579            ((LargeTemplateView) mCurrentView).setScrollable(mIsScrollable);
580        }
581        mCurrentView.setStyle(mAttrs, mDefStyleAttr, mDefStyleRes);
582        mCurrentView.setTint(getTintColor());
583
584        // Check if the slice content is expired and show when it was last updated
585        SliceMetadata sliceMetadata = SliceMetadata.from(getContext(), mCurrentSlice);
586        long lastUpdated = sliceMetadata.getLastUpdatedTime();
587        long expiry = sliceMetadata.getExpiry();
588        long now = System.currentTimeMillis();
589        mCurrentView.setLastUpdated(lastUpdated);
590        boolean expired = expiry != 0 && expiry != SliceHints.INFINITY && now > expiry;
591        mCurrentView.setShowLastUpdated(mShowLastUpdated && expired);
592
593        // Set the slice
594        mCurrentView.setSliceContent(mListContent);
595        updateActions();
596    }
597
598    private void updateActions() {
599        if (mActions == null || mActions.isEmpty()) {
600            // No actions, hide the row, clear out the view
601            mActionRow.setVisibility(View.GONE);
602            mCurrentView.setSliceActions(null);
603            return;
604        }
605
606        // TODO: take priority attached to actions into account
607        if (mShowActions && mMode != MODE_SHORTCUT && mActions.size() >= 2) {
608            // Show in action row if available
609            mActionRow.setActions(mActions, getTintColor());
610            mActionRow.setVisibility(View.VISIBLE);
611            // Hide them on the template
612            mCurrentView.setSliceActions(null);
613        } else if (mActions.size() > 0) {
614            // Otherwise set them on the template
615            mCurrentView.setSliceActions(mActions);
616            mActionRow.setVisibility(View.GONE);
617        }
618    }
619
620    private int getTintColor() {
621        if (mThemeTintColor != -1) {
622            // Theme has specified a color, use that
623            return mThemeTintColor;
624        } else {
625            final SliceItem colorItem = SliceQuery.findSubtype(
626                    mCurrentSlice, FORMAT_INT, SUBTYPE_COLOR);
627            return colorItem != null
628                    ? colorItem.getInt()
629                    : SliceViewUtil.getColorAccent(getContext());
630        }
631    }
632
633    private LayoutParams getChildLp(View child) {
634        if (child instanceof ShortcutView) {
635            return new LayoutParams(mShortcutSize, mShortcutSize);
636        } else {
637            return new LayoutParams(LayoutParams.MATCH_PARENT,
638                    LayoutParams.MATCH_PARENT);
639        }
640    }
641
642    /**
643     * @return String representation of the provided mode.
644     * @hide
645     */
646    @RestrictTo(RestrictTo.Scope.LIBRARY)
647    public static String modeToString(@SliceMode int mode) {
648        switch(mode) {
649            case MODE_SHORTCUT:
650                return "MODE SHORTCUT";
651            case MODE_SMALL:
652                return "MODE SMALL";
653            case MODE_LARGE:
654                return "MODE LARGE";
655            default:
656                return "unknown mode: " + mode;
657        }
658    }
659
660    Runnable mLongpressCheck = new Runnable() {
661        @Override
662        public void run() {
663            if (mPressing && mLongClickListener != null) {
664                mInLongpress = true;
665                mLongClickListener.onLongClick(SliceView.this);
666                performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
667            }
668        }
669    };
670}
671