NotificationMenuRow.java revision d9c2232c9952d204a774041305b1ad5ce3d13715
1/*
2 * Copyright (C) 2016 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 com.android.systemui.statusbar;
18
19import static com.android.systemui.SwipeHelper.SWIPED_FAR_ENOUGH_SIZE_FRACTION;
20
21import java.util.ArrayList;
22
23import com.android.systemui.Interpolators;
24import com.android.systemui.R;
25import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin;
26import com.android.systemui.plugins.statusbar.NotificationSwipeActionHelper;
27import com.android.systemui.statusbar.NotificationGuts.GutsContent;
28import com.android.systemui.statusbar.stack.NotificationStackScrollLayout;
29
30import android.animation.Animator;
31import android.animation.AnimatorListenerAdapter;
32import android.animation.ValueAnimator;
33import android.app.Notification;
34import android.content.Context;
35import android.content.res.Resources;
36import android.graphics.drawable.Drawable;
37import android.os.Handler;
38import android.util.Log;
39import android.service.notification.StatusBarNotification;
40import android.view.LayoutInflater;
41import android.view.MotionEvent;
42import android.view.View;
43import android.view.ViewGroup;
44import android.widget.FrameLayout;
45import android.widget.FrameLayout.LayoutParams;
46
47public class NotificationMenuRow implements NotificationMenuRowPlugin, View.OnClickListener,
48        ExpandableNotificationRow.LayoutListener {
49
50    private static final boolean DEBUG = false;
51    private static final String TAG = "swipe";
52
53    private static final int ICON_ALPHA_ANIM_DURATION = 200;
54    private static final long SHOW_MENU_DELAY = 60;
55    private static final long SWIPE_MENU_TIMING = 200;
56
57    // Notification must be swiped at least this fraction of a single menu item to show menu
58    private static final float SWIPED_FAR_ENOUGH_MENU_FRACTION = 0.25f;
59    private static final float SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION = 0.15f;
60
61    // When the menu is displayed, the notification must be swiped within this fraction of a single
62    // menu item to snap back to menu (else it will cover the menu or it'll be dismissed)
63    private static final float SWIPED_BACK_ENOUGH_TO_COVER_FRACTION = 0.2f;
64
65    private ExpandableNotificationRow mParent;
66
67    private Context mContext;
68    private FrameLayout mMenuContainer;
69    private MenuItem mSnoozeItem;
70    private MenuItem mInfoItem;
71    private ArrayList<MenuItem> mMenuItems;
72    private OnMenuEventListener mMenuListener;
73
74    private ValueAnimator mFadeAnimator;
75    private boolean mAnimating;
76    private boolean mMenuFadedIn;
77
78    private boolean mOnLeft;
79    private boolean mIconsPlaced;
80
81    private boolean mDismissing;
82    private boolean mSnapping;
83    private float mTranslation;
84
85    private int[] mIconLocation = new int[2];
86    private int[] mParentLocation = new int[2];
87
88    private float mHorizSpaceForIcon;
89    private int mVertSpaceForIcons;
90    private int mIconPadding;
91
92    private float mAlpha = 0f;
93    private float mPrevX;
94
95    private CheckForDrag mCheckForDrag;
96    private Handler mHandler;
97
98    private boolean mMenuSnappedTo;
99    private boolean mMenuSnappedOnLeft;
100    private boolean mShouldShowMenu;
101
102    private NotificationSwipeActionHelper mSwipeHelper;
103
104    public NotificationMenuRow(Context context) {
105        mContext = context;
106        final Resources res = context.getResources();
107        mShouldShowMenu = res.getBoolean(R.bool.config_showNotificationGear);
108        mHorizSpaceForIcon = res.getDimensionPixelSize(R.dimen.notification_menu_icon_size);
109        mVertSpaceForIcons = res.getDimensionPixelSize(R.dimen.notification_min_height);
110        mIconPadding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding);
111        mHandler = new Handler();
112        mMenuItems = new ArrayList<>();
113        mSnoozeItem = createSnoozeItem(context);
114        mInfoItem = createInfoItem(context);
115        mMenuItems.add(mSnoozeItem);
116        mMenuItems.add(mInfoItem);
117    }
118
119    @Override
120    public ArrayList<MenuItem> getMenuItems(Context context) {
121        return mMenuItems;
122    }
123
124    @Override
125    public MenuItem getLongpressMenuItem(Context context) {
126        return mInfoItem;
127    }
128
129    @Override
130    public void setSwipeActionHelper(NotificationSwipeActionHelper helper) {
131        mSwipeHelper = helper;
132    }
133
134    @Override
135    public void setMenuClickListener(OnMenuEventListener listener) {
136        mMenuListener = listener;
137    }
138
139    @Override
140    public void createMenu(ViewGroup parent) {
141        mParent = (ExpandableNotificationRow) parent;
142        createMenuViews();
143    }
144
145    @Override
146    public boolean isMenuVisible() {
147        return mAlpha > 0;
148    }
149
150    @Override
151    public View getMenuView() {
152        return mMenuContainer;
153    }
154
155    @Override
156    public void resetMenu() {
157        resetState(true);
158    }
159
160    @Override
161    public void onNotificationUpdated() {
162        if (mMenuContainer == null) {
163            // Menu hasn't been created yet, no need to do anything.
164            return;
165        }
166        createMenuViews();
167    }
168
169    @Override
170    public void onConfigurationChanged() {
171        mParent.setLayoutListener(this);
172    }
173
174    @Override
175    public void onLayout() {
176        mIconsPlaced = false; // Force icons to be re-placed
177        setMenuLocation();
178        mParent.removeListener();
179    }
180
181    private void createMenuViews() {
182        // Filter the menu items based on the notification
183        if (mParent != null && mParent.getStatusBarNotification() != null) {
184            int flags = mParent.getStatusBarNotification().getNotification().flags;
185            boolean isForeground = (flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;
186            if (isForeground) {
187                // Don't show snooze for foreground services
188                mMenuItems.remove(mSnoozeItem);
189            } else if (!mMenuItems.contains(mSnoozeItem)) {
190                // Was a foreground service but is no longer, add snooze back
191                mMenuItems.add(mSnoozeItem);
192            }
193        }
194        // Recreate the menu
195        if (mMenuContainer != null) {
196            mMenuContainer.removeAllViews();
197        } else {
198            mMenuContainer = new FrameLayout(mContext);
199        }
200        for (int i = 0; i < mMenuItems.size(); i++) {
201            addMenuView(mMenuItems.get(i), mMenuContainer);
202        }
203        resetState(false /* notify */);
204    }
205
206    private void resetState(boolean notify) {
207        setMenuAlpha(0f);
208        mIconsPlaced = false;
209        mMenuFadedIn = false;
210        mAnimating = false;
211        mSnapping = false;
212        mDismissing = false;
213        mMenuSnappedTo = false;
214        setMenuLocation();
215        if (mMenuListener != null && notify) {
216            mMenuListener.onMenuReset(mParent);
217        }
218    }
219
220    @Override
221    public boolean onTouchEvent(View view, MotionEvent ev, float velocity) {
222        final int action = ev.getActionMasked();
223        switch (action) {
224            case MotionEvent.ACTION_DOWN:
225                mSnapping = false;
226                if (mFadeAnimator != null) {
227                    mFadeAnimator.cancel();
228                }
229                mHandler.removeCallbacks(mCheckForDrag);
230                mCheckForDrag = null;
231                mPrevX = ev.getRawX();
232                break;
233
234            case MotionEvent.ACTION_MOVE:
235                mSnapping = false;
236                float diffX = ev.getRawX() - mPrevX;
237                mPrevX = ev.getRawX();
238                if (!isTowardsMenu(diffX) && isMenuLocationChange()) {
239                    // Don't consider it "snapped" if location has changed.
240                    mMenuSnappedTo = false;
241
242                    // Changed directions, make sure we check to fade in icon again.
243                    if (!mHandler.hasCallbacks(mCheckForDrag)) {
244                        // No check scheduled, set null to schedule a new one.
245                        mCheckForDrag = null;
246                    } else {
247                        // Check scheduled, reset alpha and update location; check will fade it in
248                        setMenuAlpha(0f);
249                        setMenuLocation();
250                    }
251                }
252                if (mShouldShowMenu
253                        && !NotificationStackScrollLayout.isPinnedHeadsUp(view)
254                        && !mParent.areGutsExposed()
255                        && !mParent.isDark()
256                        && (mCheckForDrag == null || !mHandler.hasCallbacks(mCheckForDrag))) {
257                    // Only show the menu if we're not a heads up view and guts aren't exposed.
258                    mCheckForDrag = new CheckForDrag();
259                    mHandler.postDelayed(mCheckForDrag, SHOW_MENU_DELAY);
260                }
261                break;
262
263            case MotionEvent.ACTION_UP:
264                return handleUpEvent(ev, view, velocity);
265        }
266        return false;
267    }
268
269    private boolean handleUpEvent(MotionEvent ev, View animView, float velocity) {
270        // If the menu should not be shown, then there is no need to check if the a swipe
271        // should result in a snapping to the menu. As a result, just check if the swipe
272        // was enough to dismiss the notification.
273        if (!mShouldShowMenu) {
274            if (mSwipeHelper.isDismissGesture(ev)) {
275                dismiss(animView, velocity);
276            } else {
277                snapBack(animView, velocity);
278            }
279            return true;
280        }
281
282        final boolean gestureTowardsMenu = isTowardsMenu(velocity);
283        final boolean gestureFastEnough =
284                mSwipeHelper.getMinDismissVelocity() <= Math.abs(velocity);
285        final boolean gestureFarEnough =
286                mSwipeHelper.swipedFarEnough(mTranslation, mParent.getWidth());
287        final double timeForGesture = ev.getEventTime() - ev.getDownTime();
288        final boolean showMenuForSlowOnGoing = !mParent.canViewBeDismissed()
289                && timeForGesture >= SWIPE_MENU_TIMING;
290        final float menuSnapTarget = mOnLeft ? getSpaceForMenu() : -getSpaceForMenu();
291
292        if (DEBUG) {
293            Log.d(TAG, "mTranslation= " + mTranslation
294                    + " mAlpha= " + mAlpha
295                    + " velocity= " + velocity
296                    + " mMenuSnappedTo= " + mMenuSnappedTo
297                    + " mMenuSnappedOnLeft= " + mMenuSnappedOnLeft
298                    + " mOnLeft= " + mOnLeft
299                    + " minDismissVel= " + mSwipeHelper.getMinDismissVelocity()
300                    + " isDismissGesture= " + mSwipeHelper.isDismissGesture(ev)
301                    + " gestureTowardsMenu= " + gestureTowardsMenu
302                    + " gestureFastEnough= " + gestureFastEnough
303                    + " gestureFarEnough= " + gestureFarEnough);
304        }
305
306        if (mMenuSnappedTo && isMenuVisible() && mMenuSnappedOnLeft == mOnLeft) {
307            // Menu was snapped to previously and we're on the same side, figure out if
308            // we should stick to the menu, snap back into place, or dismiss
309            final float maximumSwipeDistance = mHorizSpaceForIcon
310                    * SWIPED_BACK_ENOUGH_TO_COVER_FRACTION;
311            final float targetLeft = getSpaceForMenu() - maximumSwipeDistance;
312            final float targetRight = mParent.getWidth() * SWIPED_FAR_ENOUGH_SIZE_FRACTION;
313            boolean withinSnapMenuThreshold = mOnLeft
314                    ? mTranslation > targetLeft && mTranslation < targetRight
315                    : mTranslation < -targetLeft && mTranslation > -targetRight;
316            boolean shouldSnapTo = mOnLeft ? mTranslation < targetLeft : mTranslation > -targetLeft;
317            if (DEBUG) {
318                Log.d(TAG, "   withinSnapMenuThreshold= " + withinSnapMenuThreshold
319                        + "   shouldSnapTo= " + shouldSnapTo
320                        + "   targetLeft= " + targetLeft
321                        + "   targetRight= " + targetRight);
322            }
323            if (withinSnapMenuThreshold && !mSwipeHelper.isDismissGesture(ev)) {
324                // Haven't moved enough to unsnap from the menu
325                showMenu(animView, menuSnapTarget, velocity);
326            } else if (mSwipeHelper.isDismissGesture(ev) && !shouldSnapTo) {
327                // Only dismiss if we're not moving towards the menu
328                dismiss(animView, velocity);
329            } else {
330                snapBack(animView, velocity);
331            }
332        } else if ((swipedEnoughToShowMenu() && (!gestureFastEnough || showMenuForSlowOnGoing))
333                || (gestureTowardsMenu && !mSwipeHelper.isDismissGesture(ev))) {
334            // Menu has not been snapped to previously and this is menu revealing gesture
335            showMenu(animView, menuSnapTarget, velocity);
336        } else if (mSwipeHelper.isDismissGesture(ev) && !gestureTowardsMenu) {
337            dismiss(animView, velocity);
338        } else {
339            snapBack(animView, velocity);
340        }
341        return true;
342    }
343
344    private void showMenu(View animView, float targetLeft, float velocity) {
345        mMenuSnappedTo = true;
346        mMenuSnappedOnLeft = mOnLeft;
347        mMenuListener.onMenuShown(animView);
348        mSwipeHelper.snap(animView, targetLeft, velocity);
349    }
350
351    private void snapBack(View animView, float velocity) {
352        if (mFadeAnimator != null) {
353            mFadeAnimator.cancel();
354        }
355        mHandler.removeCallbacks(mCheckForDrag);
356        mMenuSnappedTo = false;
357        mSnapping = true;
358        mSwipeHelper.snap(animView, 0 /* leftTarget */, velocity);
359    }
360
361    private void dismiss(View animView, float velocity) {
362        if (mFadeAnimator != null) {
363            mFadeAnimator.cancel();
364        }
365        mHandler.removeCallbacks(mCheckForDrag);
366        mMenuSnappedTo = false;
367        mDismissing = true;
368        mSwipeHelper.dismiss(animView, velocity);
369    }
370
371    /**
372     * @return whether the notification has been translated enough to show the menu and not enough
373     *         to be dismissed.
374     */
375    private boolean swipedEnoughToShowMenu() {
376        final float multiplier = mParent.canViewBeDismissed()
377                ? SWIPED_FAR_ENOUGH_MENU_FRACTION
378                : SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION;
379        final float minimumSwipeDistance = mHorizSpaceForIcon * multiplier;
380        return !mSwipeHelper.swipedFarEnough(0, 0) && isMenuVisible()
381                && (mOnLeft ? mTranslation > minimumSwipeDistance
382                        : mTranslation < -minimumSwipeDistance);
383    }
384
385    /**
386     * Returns whether the gesture is towards the menu location or not.
387     */
388    private boolean isTowardsMenu(float movement) {
389        return isMenuVisible()
390                && ((mOnLeft && movement <= 0)
391                        || (!mOnLeft && movement >= 0));
392    }
393
394    @Override
395    public void setAppName(String appName) {
396        if (appName == null) {
397            return;
398        }
399        Resources res = mContext.getResources();
400        final int count = mMenuItems.size();
401        for (int i = 0; i < count; i++) {
402            MenuItem item = mMenuItems.get(i);
403            String description = String.format(
404                    res.getString(R.string.notification_menu_accessibility),
405                    appName, item.getContentDescription());
406            View menuView = item.getMenuView();
407            if (menuView != null) {
408                menuView.setContentDescription(description);
409            }
410        }
411    }
412
413    @Override
414    public void onHeightUpdate() {
415        if (mParent == null || mMenuItems.size() == 0) {
416            return;
417        }
418        int parentHeight = mParent.getCollapsedHeight();
419        float translationY;
420        if (parentHeight < mVertSpaceForIcons) {
421            translationY = (parentHeight / 2) - (mHorizSpaceForIcon / 2);
422        } else {
423            translationY = (mVertSpaceForIcons - mHorizSpaceForIcon) / 2;
424        }
425        mMenuContainer.setTranslationY(translationY);
426    }
427
428    @Override
429    public void onTranslationUpdate(float translation) {
430        mTranslation = translation;
431        if (mAnimating || !mMenuFadedIn) {
432            // Don't adjust when animating, or if the menu hasn't been shown yet.
433            return;
434        }
435        final float fadeThreshold = mParent.getWidth() * 0.3f;
436        final float absTrans = Math.abs(translation);
437        float desiredAlpha = 0;
438        if (absTrans == 0) {
439            desiredAlpha = 0;
440        } else if (absTrans <= fadeThreshold) {
441            desiredAlpha = 1;
442        } else {
443            desiredAlpha = 1 - ((absTrans - fadeThreshold) / (mParent.getWidth() - fadeThreshold));
444        }
445        setMenuAlpha(desiredAlpha);
446    }
447
448    @Override
449    public void onClick(View v) {
450        if (mMenuListener == null) {
451            // Nothing to do
452            return;
453        }
454        v.getLocationOnScreen(mIconLocation);
455        mParent.getLocationOnScreen(mParentLocation);
456        final int centerX = (int) (mHorizSpaceForIcon / 2);
457        final int centerY = v.getHeight() / 2;
458        final int x = mIconLocation[0] - mParentLocation[0] + centerX;
459        final int y = mIconLocation[1] - mParentLocation[1] + centerY;
460        final int index = mMenuContainer.indexOfChild(v);
461        mMenuListener.onMenuClicked(mParent, x, y, mMenuItems.get(index));
462    }
463
464    private boolean isMenuLocationChange() {
465        boolean onLeft = mTranslation > mIconPadding;
466        boolean onRight = mTranslation < -mIconPadding;
467        if ((mOnLeft && onRight) || (!mOnLeft && onLeft)) {
468            return true;
469        }
470        return false;
471    }
472
473    private void setMenuLocation() {
474        boolean showOnLeft = mTranslation > 0;
475        if ((mIconsPlaced && showOnLeft == mOnLeft) || mSnapping
476                || !mMenuContainer.isAttachedToWindow()) {
477            // Do nothing
478            return;
479        }
480        final int count = mMenuContainer.getChildCount();
481        for (int i = 0; i < count; i++) {
482            final View v = mMenuContainer.getChildAt(i);
483            final float left = i * mHorizSpaceForIcon;
484            final float right = mParent.getWidth() - (mHorizSpaceForIcon * (i + 1));
485            v.setX(showOnLeft ? left : right);
486        }
487        mOnLeft = showOnLeft;
488        mIconsPlaced = true;
489    }
490
491    private void setMenuAlpha(float alpha) {
492        mAlpha = alpha;
493        if (mMenuContainer == null) {
494            return;
495        }
496        if (alpha == 0) {
497            mMenuFadedIn = false; // Can fade in again once it's gone.
498            mMenuContainer.setVisibility(View.INVISIBLE);
499        } else {
500            mMenuContainer.setVisibility(View.VISIBLE);
501        }
502        final int count = mMenuContainer.getChildCount();
503        for (int i = 0; i < count; i++) {
504            mMenuContainer.getChildAt(i).setAlpha(mAlpha);
505        }
506    }
507
508    /**
509     * Returns the horizontal space in pixels required to display the menu.
510     */
511    private float getSpaceForMenu() {
512        return mHorizSpaceForIcon * mMenuContainer.getChildCount();
513    }
514
515    private final class CheckForDrag implements Runnable {
516        @Override
517        public void run() {
518            final float absTransX = Math.abs(mTranslation);
519            final float bounceBackToMenuWidth = getSpaceForMenu();
520            final float notiThreshold = mParent.getWidth() * 0.4f;
521            if ((!isMenuVisible() || isMenuLocationChange())
522                    && absTransX >= bounceBackToMenuWidth * 0.4
523                    && absTransX < notiThreshold) {
524                fadeInMenu(notiThreshold);
525            }
526        }
527    }
528
529    private void fadeInMenu(final float notiThreshold) {
530        if (mDismissing || mAnimating) {
531            return;
532        }
533        if (isMenuLocationChange()) {
534            setMenuAlpha(0f);
535        }
536        final float transX = mTranslation;
537        final boolean fromLeft = mTranslation > 0;
538        setMenuLocation();
539        mFadeAnimator = ValueAnimator.ofFloat(mAlpha, 1);
540        mFadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
541            @Override
542            public void onAnimationUpdate(ValueAnimator animation) {
543                final float absTrans = Math.abs(transX);
544
545                boolean pastMenu = (fromLeft && transX <= notiThreshold)
546                        || (!fromLeft && absTrans <= notiThreshold);
547                if (pastMenu && !mMenuFadedIn) {
548                    setMenuAlpha((float) animation.getAnimatedValue());
549                }
550            }
551        });
552        mFadeAnimator.addListener(new AnimatorListenerAdapter() {
553            @Override
554            public void onAnimationStart(Animator animation) {
555                mAnimating = true;
556            }
557
558            @Override
559            public void onAnimationCancel(Animator animation) {
560                // TODO should animate back to 0f from current alpha
561                setMenuAlpha(0f);
562            }
563
564            @Override
565            public void onAnimationEnd(Animator animation) {
566                mAnimating = false;
567                mMenuFadedIn = mAlpha == 1;
568            }
569        });
570        mFadeAnimator.setInterpolator(Interpolators.ALPHA_IN);
571        mFadeAnimator.setDuration(ICON_ALPHA_ANIM_DURATION);
572        mFadeAnimator.start();
573    }
574
575    @Override
576    public void setMenuItems(ArrayList<MenuItem> items) {
577        // Do nothing we use our own for now.
578        // TODO -- handle / allow custom menu items!
579    }
580
581    public static MenuItem createSnoozeItem(Context context) {
582        Resources res = context.getResources();
583        NotificationSnooze content = (NotificationSnooze) LayoutInflater.from(context)
584                .inflate(R.layout.notification_snooze, null, false);
585        String snoozeDescription = res.getString(R.string.notification_menu_snooze_description);
586        MenuItem snooze = new NotificationMenuItem(context, snoozeDescription, content,
587                R.drawable.ic_snooze);
588        return snooze;
589    }
590
591    public static MenuItem createInfoItem(Context context) {
592        Resources res = context.getResources();
593        String infoDescription = res.getString(R.string.notification_menu_gear_description);
594        NotificationInfo infoContent = (NotificationInfo) LayoutInflater.from(context).inflate(
595                R.layout.notification_info, null, false);
596        MenuItem info = new NotificationMenuItem(context, infoDescription, infoContent,
597                R.drawable.ic_settings);
598        return info;
599    }
600
601    private void addMenuView(MenuItem item, ViewGroup parent) {
602        View menuView = item.getMenuView();
603        if (menuView != null) {
604            parent.addView(menuView);
605            menuView.setOnClickListener(this);
606            FrameLayout.LayoutParams lp = (LayoutParams) menuView.getLayoutParams();
607            lp.width = (int) mHorizSpaceForIcon;
608            lp.height = (int) mHorizSpaceForIcon;
609            menuView.setLayoutParams(lp);
610        }
611    }
612
613    public static class NotificationMenuItem implements MenuItem {
614        View mMenuView;
615        GutsContent mGutsContent;
616        String mContentDescription;
617
618        public NotificationMenuItem(Context context, String s, GutsContent content, int iconResId) {
619            Resources res = context.getResources();
620            int padding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding);
621            int tint = res.getColor(R.color.notification_gear_color);
622            AlphaOptimizedImageView iv = new AlphaOptimizedImageView(context);
623            iv.setPadding(padding, padding, padding, padding);
624            Drawable icon = context.getResources().getDrawable(iconResId);
625            iv.setImageDrawable(icon);
626            iv.setColorFilter(tint);
627            iv.setAlpha(1f);
628            mMenuView = iv;
629            mContentDescription = s;
630            mGutsContent = content;
631        }
632
633        @Override
634        public View getMenuView() {
635            return mMenuView;
636        }
637
638        @Override
639        public View getGutsView() {
640            return mGutsContent.getContentView();
641        }
642
643        @Override
644        public String getContentDescription() {
645            return mContentDescription;
646        }
647    }
648}
649