AlbumSetPage.java revision e8c1e69f85efb8673d0606f3aca729a366038753
1/*
2 * Copyright (C) 2010 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.gallery3d.app;
18
19import android.app.Activity;
20import android.content.Context;
21import android.content.Intent;
22import android.graphics.Rect;
23import android.os.Bundle;
24import android.os.Handler;
25import android.os.Message;
26import android.os.Vibrator;
27import android.provider.MediaStore;
28import android.view.ActionMode;
29import android.view.Menu;
30import android.view.MenuInflater;
31import android.view.MenuItem;
32import android.widget.Toast;
33
34import com.android.gallery3d.R;
35import com.android.gallery3d.common.Utils;
36import com.android.gallery3d.data.DataManager;
37import com.android.gallery3d.data.MediaDetails;
38import com.android.gallery3d.data.MediaObject;
39import com.android.gallery3d.data.MediaSet;
40import com.android.gallery3d.data.Path;
41import com.android.gallery3d.picasasource.PicasaSource;
42import com.android.gallery3d.settings.GallerySettings;
43import com.android.gallery3d.ui.ActionModeHandler;
44import com.android.gallery3d.ui.ActionModeHandler.ActionModeListener;
45import com.android.gallery3d.ui.AlbumSetSlotRenderer;
46import com.android.gallery3d.ui.DetailsHelper;
47import com.android.gallery3d.ui.DetailsHelper.CloseListener;
48import com.android.gallery3d.ui.FadeTexture;
49import com.android.gallery3d.ui.GLCanvas;
50import com.android.gallery3d.ui.GLRoot;
51import com.android.gallery3d.ui.GLView;
52import com.android.gallery3d.ui.SelectionManager;
53import com.android.gallery3d.ui.SlotView;
54import com.android.gallery3d.ui.SynchronizedHandler;
55import com.android.gallery3d.util.Future;
56import com.android.gallery3d.util.GalleryUtils;
57import com.android.gallery3d.util.HelpUtils;
58
59public class AlbumSetPage extends ActivityState implements
60        SelectionManager.SelectionListener, GalleryActionBar.ClusterRunner,
61        EyePosition.EyePositionListener, MediaSet.SyncListener {
62    @SuppressWarnings("unused")
63    private static final String TAG = "AlbumSetPage";
64
65    private static final int MSG_PICK_ALBUM = 1;
66
67    public static final String KEY_MEDIA_PATH = "media-path";
68    public static final String KEY_SET_TITLE = "set-title";
69    public static final String KEY_SET_SUBTITLE = "set-subtitle";
70    public static final String KEY_SELECTED_CLUSTER_TYPE = "selected-cluster";
71
72    private static final int DATA_CACHE_SIZE = 256;
73    private static final int REQUEST_DO_ANIMATION = 1;
74
75    private static final int BIT_LOADING_RELOAD = 1;
76    private static final int BIT_LOADING_SYNC = 2;
77
78    private boolean mIsActive = false;
79    private SlotView mSlotView;
80    private AlbumSetSlotRenderer mAlbumSetView;
81
82    private MediaSet mMediaSet;
83    private String mTitle;
84    private String mSubtitle;
85    private boolean mShowClusterMenu;
86    private GalleryActionBar mActionBar;
87    private int mSelectedAction;
88    private Vibrator mVibrator;
89
90    protected SelectionManager mSelectionManager;
91    private AlbumSetDataLoader mAlbumSetDataAdapter;
92
93    private boolean mGetContent;
94    private boolean mGetAlbum;
95    private ActionMode mActionMode;
96    private ActionModeHandler mActionModeHandler;
97    private DetailsHelper mDetailsHelper;
98    private MyDetailsSource mDetailsSource;
99    private boolean mShowDetails;
100    private EyePosition mEyePosition;
101    private Handler mHandler;
102
103    // The eyes' position of the user, the origin is at the center of the
104    // device and the unit is in pixels.
105    private float mX;
106    private float mY;
107    private float mZ;
108
109    private Future<Integer> mSyncTask = null;
110
111    private int mLoadingBits = 0;
112    private boolean mInitialSynced = false;
113
114    private final GLView mRootPane = new GLView() {
115        private final float mMatrix[] = new float[16];
116
117        @Override
118        protected void renderBackground(GLCanvas view) {
119            view.clearBuffer();
120        }
121
122        @Override
123        protected void onLayout(
124                boolean changed, int left, int top, int right, int bottom) {
125            mEyePosition.resetPosition();
126
127            int slotViewTop = mActionBar.getHeight();
128            int slotViewBottom = bottom - top;
129            int slotViewRight = right - left;
130
131            if (mShowDetails) {
132                mDetailsHelper.layout(left, slotViewTop, right, bottom);
133            } else {
134                mAlbumSetView.setHighlightItemPath(null);
135            }
136
137            mSlotView.layout(0, slotViewTop, slotViewRight, slotViewBottom);
138        }
139
140        @Override
141        protected void render(GLCanvas canvas) {
142            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
143            GalleryUtils.setViewPointMatrix(mMatrix,
144                    getWidth() / 2 + mX, getHeight() / 2 + mY, mZ);
145            canvas.multiplyMatrix(mMatrix, 0);
146            super.render(canvas);
147            canvas.restore();
148        }
149    };
150
151    @Override
152    public void onEyePositionChanged(float x, float y, float z) {
153        mRootPane.lockRendering();
154        mX = x;
155        mY = y;
156        mZ = z;
157        mRootPane.unlockRendering();
158        mRootPane.invalidate();
159    }
160
161    @Override
162    public void onBackPressed() {
163        if (mShowDetails) {
164            hideDetails();
165        } else if (mSelectionManager.inSelectionMode()) {
166            mSelectionManager.leaveSelectionMode();
167        } else {
168            super.onBackPressed();
169        }
170    }
171
172    private void getSlotCenter(int slotIndex, int center[]) {
173        Rect offset = new Rect();
174        mRootPane.getBoundsOf(mSlotView, offset);
175        Rect r = mSlotView.getSlotRect(slotIndex);
176        int scrollX = mSlotView.getScrollX();
177        int scrollY = mSlotView.getScrollY();
178        center[0] = offset.left + (r.left + r.right) / 2 - scrollX;
179        center[1] = offset.top + (r.top + r.bottom) / 2 - scrollY;
180    }
181
182    public void onSingleTapUp(int slotIndex) {
183        if (!mIsActive) return;
184
185        if (mSelectionManager.inSelectionMode()) {
186            MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex);
187            if (targetSet == null) return; // Content is dirty, we shall reload soon
188            mSelectionManager.toggle(targetSet.getPath());
189            mSlotView.invalidate();
190        } else {
191            // Show pressed-up animation for the single-tap.
192            mAlbumSetView.setPressedIndex(slotIndex);
193            mAlbumSetView.setPressedUp();
194            mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_PICK_ALBUM, slotIndex, 0),
195                    FadeTexture.DURATION);
196        }
197    }
198
199    private void pickAlbum(int slotIndex) {
200        if (!mIsActive) return;
201
202        MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex);
203        if (targetSet == null) return; // Content is dirty, we shall reload soon
204        String mediaPath = targetSet.getPath().toString();
205
206        Bundle data = new Bundle(getData());
207        int[] center = new int[2];
208        getSlotCenter(slotIndex, center);
209        data.putIntArray(AlbumPage.KEY_SET_CENTER, center);
210        if (mGetAlbum && targetSet.isLeafAlbum()) {
211            Activity activity = (Activity) mActivity;
212            Intent result = new Intent()
213                    .putExtra(AlbumPicker.KEY_ALBUM_PATH, targetSet.getPath().toString());
214            activity.setResult(Activity.RESULT_OK, result);
215            activity.finish();
216        } else if (targetSet.getSubMediaSetCount() > 0) {
217            data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath);
218            mActivity.getStateManager().startStateForResult(
219                    AlbumSetPage.class, REQUEST_DO_ANIMATION, data);
220        } else {
221            if (!mGetContent && (targetSet.getSupportedOperations()
222                    & MediaObject.SUPPORT_IMPORT) != 0) {
223                data.putBoolean(AlbumPage.KEY_AUTO_SELECT_ALL, true);
224            }
225            data.putString(AlbumPage.KEY_MEDIA_PATH, mediaPath);
226            boolean inAlbum = mActivity.getStateManager().hasStateClass(AlbumPage.class);
227            // We only show cluster menu in the first AlbumPage in stack
228            data.putBoolean(AlbumPage.KEY_SHOW_CLUSTER_MENU, !inAlbum);
229            mActivity.getStateManager().startStateForResult(
230                    AlbumPage.class, REQUEST_DO_ANIMATION, data);
231        }
232    }
233
234    private void onDown(int index) {
235        mAlbumSetView.setPressedIndex(index);
236    }
237
238    private void onUp(boolean followedByLongPress) {
239        if (followedByLongPress) {
240            // Avoid showing press-up animations for long-press.
241            mAlbumSetView.setPressedIndex(-1);
242        } else {
243            mAlbumSetView.setPressedUp();
244        }
245    }
246
247    public void onLongTap(int slotIndex) {
248        if (mGetContent || mGetAlbum) return;
249        MediaSet set = mAlbumSetDataAdapter.getMediaSet(slotIndex);
250        if (set == null) return;
251        mSelectionManager.setAutoLeaveSelectionMode(true);
252        mSelectionManager.toggle(set.getPath());
253        mDetailsSource.findIndex(slotIndex);
254        mSlotView.invalidate();
255    }
256
257    @Override
258    public void doCluster(int clusterType) {
259        String basePath = mMediaSet.getPath().toString();
260        String newPath = FilterUtils.switchClusterPath(basePath, clusterType);
261        Bundle data = new Bundle(getData());
262        data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath);
263        data.putInt(KEY_SELECTED_CLUSTER_TYPE, clusterType);
264        mActivity.getStateManager().switchState(this, AlbumSetPage.class, data);
265    }
266
267    @Override
268    public void onCreate(Bundle data, Bundle restoreState) {
269        initializeViews();
270        initializeData(data);
271        Context context = mActivity.getAndroidContext();
272        mGetContent = data.getBoolean(Gallery.KEY_GET_CONTENT, false);
273        mGetAlbum = data.getBoolean(Gallery.KEY_GET_ALBUM, false);
274        mTitle = data.getString(AlbumSetPage.KEY_SET_TITLE);
275        mSubtitle = data.getString(AlbumSetPage.KEY_SET_SUBTITLE);
276        mEyePosition = new EyePosition(context, this);
277        mDetailsSource = new MyDetailsSource();
278        mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
279        mActionBar = mActivity.getGalleryActionBar();
280        mSelectedAction = data.getInt(AlbumSetPage.KEY_SELECTED_CLUSTER_TYPE,
281                FilterUtils.CLUSTER_BY_ALBUM);
282
283        mHandler = new SynchronizedHandler(mActivity.getGLRoot()) {
284            @Override
285            public void handleMessage(Message message) {
286                switch (message.what) {
287                    case MSG_PICK_ALBUM: {
288                        pickAlbum(message.arg1);
289                        break;
290                    }
291                    default: throw new AssertionError(message.what);
292                }
293            }
294        };
295    }
296
297    private void clearLoadingBit(int loadingBit) {
298        mLoadingBits &= ~loadingBit;
299        if (mLoadingBits == 0 && mIsActive) {
300            // Only show toast when there's no album and we are going to finish
301            // the page. Toast is redundant if we are going to stay on this page.
302            if ((mAlbumSetDataAdapter.size() == 0)) {
303                if (mActivity.getStateManager().getStateCount() > 1) {
304                    Toast.makeText((Context) mActivity,
305                            R.string.empty_album, Toast.LENGTH_LONG).show();
306                    mActivity.getStateManager().finishState(this);
307                }
308            }
309        }
310    }
311
312    private void setLoadingBit(int loadingBit) {
313        mLoadingBits |= loadingBit;
314    }
315
316    @Override
317    public void onPause() {
318        super.onPause();
319        mIsActive = false;
320        mActionModeHandler.pause();
321        mAlbumSetDataAdapter.pause();
322        mAlbumSetView.pause();
323        mEyePosition.pause();
324        DetailsHelper.pause();
325        // Call disableClusterMenu to avoid receiving callback after paused.
326        // Don't hide menu here otherwise the list menu will disappear earlier than
327        // the action bar, which is janky and unwanted behavior.
328        mActionBar.disableClusterMenu(false);
329        if (mSyncTask != null) {
330            mSyncTask.cancel();
331            mSyncTask = null;
332            clearLoadingBit(BIT_LOADING_SYNC);
333        }
334    }
335
336    @Override
337    public void onResume() {
338        super.onResume();
339        mIsActive = true;
340        setContentPane(mRootPane);
341
342        // Set the reload bit here to prevent it exit this page in clearLoadingBit().
343        setLoadingBit(BIT_LOADING_RELOAD);
344        mAlbumSetDataAdapter.resume();
345
346        mAlbumSetView.resume();
347        mEyePosition.resume();
348        mActionModeHandler.resume();
349        if (mShowClusterMenu) {
350            mActionBar.enableClusterMenu(mSelectedAction, this);
351        }
352        if (!mInitialSynced) {
353            setLoadingBit(BIT_LOADING_SYNC);
354            mSyncTask = mMediaSet.requestSync(AlbumSetPage.this);
355        }
356    }
357
358    private void initializeData(Bundle data) {
359        String mediaPath = data.getString(AlbumSetPage.KEY_MEDIA_PATH);
360        mMediaSet = mActivity.getDataManager().getMediaSet(mediaPath);
361        mSelectionManager.setSourceMediaSet(mMediaSet);
362        mAlbumSetDataAdapter = new AlbumSetDataLoader(
363                mActivity, mMediaSet, DATA_CACHE_SIZE);
364        mAlbumSetDataAdapter.setLoadingListener(new MyLoadingListener());
365        mAlbumSetView.setModel(mAlbumSetDataAdapter);
366    }
367
368    private void initializeViews() {
369        mSelectionManager = new SelectionManager(mActivity, true);
370        mSelectionManager.setSelectionListener(this);
371
372        Config.AlbumSetPage config = Config.AlbumSetPage.get((Context) mActivity);
373        mSlotView = new SlotView(mActivity, config.slotViewSpec);
374        mAlbumSetView = new AlbumSetSlotRenderer(
375                mActivity, mSelectionManager, mSlotView, config.labelSpec);
376        mSlotView.setSlotRenderer(mAlbumSetView);
377        mSlotView.setListener(new SlotView.SimpleListener() {
378            @Override
379            public void onDown(int index) {
380                AlbumSetPage.this.onDown(index);
381            }
382
383            @Override
384            public void onUp(boolean followedByLongPress) {
385                AlbumSetPage.this.onUp(followedByLongPress);
386            }
387
388            @Override
389            public void onSingleTapUp(int slotIndex) {
390                AlbumSetPage.this.onSingleTapUp(slotIndex);
391            }
392
393            @Override
394            public void onLongTap(int slotIndex) {
395                AlbumSetPage.this.onLongTap(slotIndex);
396            }
397        });
398
399        mActionModeHandler = new ActionModeHandler(mActivity, mSelectionManager);
400        mActionModeHandler.setActionModeListener(new ActionModeListener() {
401            @Override
402            public boolean onActionItemClicked(MenuItem item) {
403                return onItemSelected(item);
404            }
405        });
406        mRootPane.addComponent(mSlotView);
407    }
408
409    @Override
410    protected boolean onCreateActionBar(Menu menu) {
411        Activity activity = (Activity) mActivity;
412        MenuInflater inflater = activity.getMenuInflater();
413
414        final boolean inAlbum = mActivity.getStateManager().hasStateClass(
415                AlbumPage.class);
416
417        if (mGetContent) {
418            inflater.inflate(R.menu.pickup, menu);
419            int typeBits = mData.getInt(
420                    Gallery.KEY_TYPE_BITS, DataManager.INCLUDE_IMAGE);
421            int id = R.string.select_image;
422            if ((typeBits & DataManager.INCLUDE_VIDEO) != 0) {
423                id = (typeBits & DataManager.INCLUDE_IMAGE) == 0
424                        ? R.string.select_video
425                        : R.string.select_item;
426            }
427            mActionBar.setTitle(id);
428        } else  if (mGetAlbum) {
429            inflater.inflate(R.menu.pickup, menu);
430            mActionBar.setTitle(R.string.select_album);
431        } else {
432            mShowClusterMenu = !inAlbum;
433            inflater.inflate(R.menu.albumset, menu);
434            MenuItem selectItem = menu.findItem(R.id.action_select);
435
436            if (selectItem != null) {
437                boolean selectAlbums = !inAlbum &&
438                        mActionBar.getClusterTypeAction() == FilterUtils.CLUSTER_BY_ALBUM;
439                if (selectAlbums) {
440                    selectItem.setTitle(R.string.select_album);
441                } else {
442                    selectItem.setTitle(R.string.select_group);
443                }
444            }
445
446            FilterUtils.setupMenuItems(mActionBar, mMediaSet.getPath(), false);
447            MenuItem switchCamera = menu.findItem(R.id.action_camera);
448            if (switchCamera != null) {
449                switchCamera.setVisible(GalleryUtils.isCameraAvailable(activity));
450            }
451            final MenuItem helpMenu = menu.findItem(R.id.action_general_help);
452            HelpUtils.prepareHelpMenuItem(mActivity.getAndroidContext(),
453                    helpMenu, R.string.help_url_gallery_main);
454
455            mActionBar.setTitle(mTitle);
456            mActionBar.setSubtitle(mSubtitle);
457        }
458        return true;
459    }
460
461    @Override
462    protected boolean onItemSelected(MenuItem item) {
463        Activity activity = (Activity) mActivity;
464        switch (item.getItemId()) {
465            case R.id.action_cancel:
466                activity.setResult(Activity.RESULT_CANCELED);
467                activity.finish();
468                return true;
469            case R.id.action_select:
470                mSelectionManager.setAutoLeaveSelectionMode(false);
471                mSelectionManager.enterSelectionMode();
472                return true;
473            case R.id.action_details:
474                if (mAlbumSetDataAdapter.size() != 0) {
475                    if (mShowDetails) {
476                        hideDetails();
477                    } else {
478                        showDetails();
479                    }
480                } else {
481                    Toast.makeText(activity,
482                            activity.getText(R.string.no_albums_alert),
483                            Toast.LENGTH_SHORT).show();
484                }
485                return true;
486            case R.id.action_camera: {
487                GalleryUtils.startCameraActivity(activity);
488                return true;
489            }
490            case R.id.action_manage_offline: {
491                Bundle data = new Bundle();
492                String mediaPath = mActivity.getDataManager().getTopSetPath(
493                    DataManager.INCLUDE_ALL);
494                data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath);
495                mActivity.getStateManager().startState(ManageCachePage.class, data);
496                return true;
497            }
498            case R.id.action_sync_picasa_albums: {
499                PicasaSource.requestSync(activity);
500                return true;
501            }
502            case R.id.action_settings: {
503                activity.startActivity(new Intent(activity, GallerySettings.class));
504                return true;
505            }
506            default:
507                return false;
508        }
509    }
510
511    @Override
512    protected void onStateResult(int requestCode, int resultCode, Intent data) {
513        switch (requestCode) {
514            case REQUEST_DO_ANIMATION: {
515                mSlotView.startRisingAnimation();
516            }
517        }
518    }
519
520    private String getSelectedString() {
521        int count = mSelectionManager.getSelectedCount();
522        int action = mActionBar.getClusterTypeAction();
523        int string = action == FilterUtils.CLUSTER_BY_ALBUM
524                ? R.plurals.number_of_albums_selected
525                : R.plurals.number_of_groups_selected;
526        String format = mActivity.getResources().getQuantityString(string, count);
527        return String.format(format, count);
528    }
529
530    @Override
531    public void onSelectionModeChange(int mode) {
532        switch (mode) {
533            case SelectionManager.ENTER_SELECTION_MODE: {
534                mActionBar.disableClusterMenu(true);
535                mActionMode = mActionModeHandler.startActionMode();
536                mVibrator.vibrate(100);
537                break;
538            }
539            case SelectionManager.LEAVE_SELECTION_MODE: {
540                mActionMode.finish();
541                if (mShowClusterMenu) {
542                    mActionBar.enableClusterMenu(mSelectedAction, this);
543                }
544                mRootPane.invalidate();
545                break;
546            }
547            case SelectionManager.SELECT_ALL_MODE: {
548                mActionModeHandler.updateSupportedOperation();
549                mRootPane.invalidate();
550                break;
551            }
552        }
553    }
554
555    @Override
556    public void onSelectionChange(Path path, boolean selected) {
557        Utils.assertTrue(mActionMode != null);
558        mActionModeHandler.setTitle(getSelectedString());
559        mActionModeHandler.updateSupportedOperation(path, selected);
560    }
561
562    private void hideDetails() {
563        mShowDetails = false;
564        mDetailsHelper.hide();
565        mAlbumSetView.setHighlightItemPath(null);
566        mSlotView.invalidate();
567    }
568
569    private void showDetails() {
570        mShowDetails = true;
571        if (mDetailsHelper == null) {
572            mDetailsHelper = new DetailsHelper(mActivity, mRootPane, mDetailsSource);
573            mDetailsHelper.setCloseListener(new CloseListener() {
574                @Override
575                public void onClose() {
576                    hideDetails();
577                }
578            });
579        }
580        mDetailsHelper.show();
581    }
582
583    @Override
584    public void onSyncDone(final MediaSet mediaSet, final int resultCode) {
585        if (resultCode == MediaSet.SYNC_RESULT_ERROR) {
586            Log.d(TAG, "onSyncDone: " + Utils.maskDebugInfo(mediaSet.getName()) + " result="
587                    + resultCode);
588        }
589        ((Activity) mActivity).runOnUiThread(new Runnable() {
590            @Override
591            public void run() {
592                GLRoot root = mActivity.getGLRoot();
593                root.lockRenderThread();
594                try {
595                    if (resultCode == MediaSet.SYNC_RESULT_SUCCESS) {
596                        mInitialSynced = true;
597                    }
598                    clearLoadingBit(BIT_LOADING_SYNC);
599                    if (resultCode == MediaSet.SYNC_RESULT_ERROR && mIsActive) {
600                        Log.w(TAG, "failed to load album set");
601                    }
602                } finally {
603                    root.unlockRenderThread();
604                }
605            }
606        });
607    }
608
609    private class MyLoadingListener implements LoadingListener {
610        @Override
611        public void onLoadingStarted() {
612            setLoadingBit(BIT_LOADING_RELOAD);
613        }
614
615        @Override
616        public void onLoadingFinished() {
617            clearLoadingBit(BIT_LOADING_RELOAD);
618        }
619    }
620
621    private class MyDetailsSource implements DetailsHelper.DetailsSource {
622        private int mIndex;
623
624        @Override
625        public int size() {
626            return mAlbumSetDataAdapter.size();
627        }
628
629        @Override
630        public int getIndex() {
631            return mIndex;
632        }
633
634        // If requested index is out of active window, suggest a valid index.
635        // If there is no valid index available, return -1.
636        @Override
637        public int findIndex(int indexHint) {
638            if (mAlbumSetDataAdapter.isActive(indexHint)) {
639                mIndex = indexHint;
640            } else {
641                mIndex = mAlbumSetDataAdapter.getActiveStart();
642                if (!mAlbumSetDataAdapter.isActive(mIndex)) {
643                    return -1;
644                }
645            }
646            return mIndex;
647        }
648
649        @Override
650        public MediaDetails getDetails() {
651            MediaObject item = mAlbumSetDataAdapter.getMediaSet(mIndex);
652            if (item != null) {
653                mAlbumSetView.setHighlightItemPath(item.getPath());
654                return item.getDetails();
655            } else {
656                return null;
657            }
658        }
659    }
660}
661