TwoPaneController.java revision f84fe2eec91f437f02b6944e4a4981d3907652a0
1/*******************************************************************************
2 *      Copyright (C) 2012 Google Inc.
3 *      Licensed to The Android Open Source Project.
4 *
5 *      Licensed under the Apache License, Version 2.0 (the "License");
6 *      you may not use this file except in compliance with the License.
7 *      You may obtain a copy of the License at
8 *
9 *           http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *      Unless required by applicable law or agreed to in writing, software
12 *      distributed under the License is distributed on an "AS IS" BASIS,
13 *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *      See the License for the specific language governing permissions and
15 *      limitations under the License.
16 *******************************************************************************/
17
18package com.android.mail.ui;
19
20import android.app.Activity;
21import android.app.Fragment;
22import android.app.FragmentManager;
23import android.app.FragmentTransaction;
24import android.content.Intent;
25import android.os.Bundle;
26import android.support.annotation.IdRes;
27import android.support.annotation.LayoutRes;
28import android.support.v7.app.ActionBar;
29import android.view.KeyEvent;
30import android.view.View;
31import android.widget.ListView;
32
33import com.android.mail.ConversationListContext;
34import com.android.mail.R;
35import com.android.mail.providers.Account;
36import com.android.mail.providers.Conversation;
37import com.android.mail.providers.Folder;
38import com.android.mail.providers.UIProvider.ConversationListIcon;
39import com.android.mail.utils.LogUtils;
40import com.android.mail.utils.Utils;
41
42/**
43 * Controller for two-pane Mail activity. Two Pane is used for tablets, where screen real estate
44 * abounds.
45 */
46public final class TwoPaneController extends AbstractActivityController implements
47        ConversationViewFrame.DownEventListener {
48
49    private static final String SAVED_MISCELLANEOUS_VIEW = "saved-miscellaneous-view";
50    private static final String SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID =
51            "saved-miscellaneous-view-transaction-id";
52
53    private TwoPaneLayout mLayout;
54    @Deprecated
55    private Conversation mConversationToShow;
56
57    /**
58     * 2-pane, in wider configurations, allows peeking at a conversation view without having the
59     * conversation marked-as-read as far as read/unread state goes.<br>
60     * <br>
61     * This flag applies to {@link AbstractActivityController#mCurrentConversation} and indicates
62     * that the current conversation, if set, is in a 'peeking' state. If there is no current
63     * conversation, peeking is implied (in certain view configurations) and this value is
64     * meaningless.
65     */
66    // TODO: save in instance state
67    private boolean mCurrentConversationJustPeeking;
68
69    /**
70     * Used to determine whether onViewModeChanged should skip a potential
71     * fragment transaction that would remove a miscellaneous view.
72     */
73    private boolean mSavedMiscellaneousView = false;
74
75    private boolean mIsTabletLandscape;
76
77    private TwoPaneLayout.ConversationListLayoutListener mConversationListLayoutListener;
78
79    public TwoPaneController(MailActivity activity, ViewMode viewMode) {
80        super(activity, viewMode);
81    }
82
83    public boolean isCurrentConversationJustPeeking() {
84        return mCurrentConversationJustPeeking;
85    }
86
87    private boolean isConversationOnlyMode() {
88        return getCurrentConversation() != null && !isCurrentConversationJustPeeking()
89                && !mLayout.shouldShowPreviewPanel();
90    }
91
92    /**
93     * Display the conversation list fragment.
94     */
95    private void initializeConversationListFragment() {
96        if (Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())) {
97            if (shouldEnterSearchConvMode()) {
98                mViewMode.enterSearchResultsConversationMode();
99            } else {
100                mViewMode.enterSearchResultsListMode();
101            }
102        }
103        renderConversationList();
104    }
105
106    /**
107     * Render the conversation list in the correct pane.
108     */
109    private void renderConversationList() {
110        if (mActivity == null) {
111            return;
112        }
113        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
114        // Use cross fading animation.
115        fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
116        final ConversationListFragment conversationListFragment =
117                ConversationListFragment.newInstance(mConvListContext);
118        fragmentTransaction.replace(R.id.conversation_list_pane, conversationListFragment,
119                TAG_CONVERSATION_LIST);
120        fragmentTransaction.commitAllowingStateLoss();
121        // Set default navigation here once the ConversationListFragment is created.
122        conversationListFragment.setNextFocusLeftId(
123                getClfNextFocusLeftId(getFolderListFragment().isMinimized()));
124    }
125
126    @Override
127    public boolean doesActionChangeConversationListVisibility(final int action) {
128        if (action == R.id.settings
129                || action == R.id.compose
130                || action == R.id.help_info_menu_item
131                || action == R.id.feedback_menu_item) {
132            return true;
133        }
134
135        return false;
136    }
137
138    @Override
139    protected boolean isConversationListVisible() {
140        return !mLayout.isConversationListCollapsed();
141    }
142
143    @Override
144    protected void showConversationList(ConversationListContext listContext) {
145        initializeConversationListFragment();
146    }
147
148    @Override
149    public @LayoutRes int getContentViewResource() {
150        return R.layout.two_pane_activity;
151    }
152
153    @Override
154    public boolean onCreate(Bundle savedState) {
155        mLayout = (TwoPaneLayout) mActivity.findViewById(R.id.two_pane_activity);
156        if (mLayout == null) {
157            // We need the layout for everything. Crash/Return early if it is null.
158            LogUtils.wtf(LOG_TAG, "mLayout is null!");
159            return false;
160        }
161        mLayout.setConversationListLayoutListener(mConversationListLayoutListener);
162        mLayout.setController(this, Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()));
163        mActivity.getWindow().setBackgroundDrawable(null);
164        mIsTabletLandscape = !mActivity.getResources().getBoolean(R.bool.list_collapsible);
165
166        final FolderListFragment flf = getFolderListFragment();
167        flf.setMiniDrawerEnabled(true);
168        flf.setMinimized(true);
169
170        if (savedState != null) {
171            mSavedMiscellaneousView = savedState.getBoolean(SAVED_MISCELLANEOUS_VIEW, false);
172            mMiscellaneousViewTransactionId =
173                    savedState.getInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, -1);
174        }
175
176        // 2-pane layout is the main listener of view mode changes, and issues secondary
177        // notifications upon animation completion:
178        // (onConversationVisibilityChanged, onConversationListVisibilityChanged)
179        mViewMode.addListener(mLayout);
180        return super.onCreate(savedState);
181    }
182
183    @Override
184    public void onSaveInstanceState(Bundle outState) {
185        super.onSaveInstanceState(outState);
186
187        outState.putBoolean(SAVED_MISCELLANEOUS_VIEW, mMiscellaneousViewTransactionId >= 0);
188        outState.putInt(SAVED_MISCELLANEOUS_VIEW_TRANSACTION_ID, mMiscellaneousViewTransactionId);
189    }
190
191    @Override
192    public void onWindowFocusChanged(boolean hasFocus) {
193        if (hasFocus && !mLayout.isConversationListCollapsed()) {
194            // The conversation list is visible.
195            informCursorVisiblity(true);
196        }
197    }
198
199    @Override
200    public void switchToDefaultInboxOrChangeAccount(Account account) {
201        if (mViewMode.isSearchMode()) {
202            // We are in an activity on top of the main navigation activity.
203            // We need to return to it with a result code that indicates it should navigate to
204            // a different folder.
205            final Intent intent = new Intent();
206            intent.putExtra(AbstractActivityController.EXTRA_ACCOUNT, account);
207            mActivity.setResult(Activity.RESULT_OK, intent);
208            mActivity.finish();
209            return;
210        }
211        if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) {
212            mViewMode.enterConversationListMode();
213        }
214        super.switchToDefaultInboxOrChangeAccount(account);
215    }
216
217    @Override
218    public void onFolderSelected(Folder folder) {
219        // It's possible that we are not in conversation list mode
220        if (mViewMode.isSearchMode()) {
221            // We are in an activity on top of the main navigation activity.
222            // We need to return to it with a result code that indicates it should navigate to
223            // a different folder.
224            final Intent intent = new Intent();
225            intent.putExtra(AbstractActivityController.EXTRA_FOLDER, folder);
226            mActivity.setResult(Activity.RESULT_OK, intent);
227            mActivity.finish();
228            return;
229        } else if (mViewMode.getMode() != ViewMode.CONVERSATION_LIST) {
230            mViewMode.enterConversationListMode();
231        }
232
233        setHierarchyFolder(folder);
234        super.onFolderSelected(folder);
235    }
236
237    public boolean isDrawerOpen() {
238        final FolderListFragment flf = getFolderListFragment();
239        return flf != null && !flf.isMinimized();
240    }
241
242    @Override
243    protected void toggleDrawerState() {
244        final FolderListFragment flf = getFolderListFragment();
245        if (flf == null) {
246            LogUtils.w(LOG_TAG, "no drawer to toggle open/closed");
247            return;
248        }
249        flf.setMinimized(!flf.isMinimized());
250        mLayout.requestLayout();
251        resetActionBarIcon();
252
253        final ConversationListFragment clf = getConversationListFragment();
254        if (clf != null) {
255            clf.setNextFocusLeftId(getClfNextFocusLeftId(flf.isMinimized()));
256
257            final SwipeableListView list = clf.getListView();
258            if (list != null) {
259                if (flf.isMinimized()) {
260                    list.stopPreventingSwipes();
261                } else {
262                    list.preventSwipesEntirely();
263                }
264            }
265        }
266    }
267
268    @Override
269    public boolean shouldPreventListSwipesEntirely() {
270        return isDrawerOpen();
271    }
272
273    @Override
274    public void onViewModeChanged(int newMode) {
275        if (!mSavedMiscellaneousView && mMiscellaneousViewTransactionId >= 0) {
276            final FragmentManager fragmentManager = mActivity.getFragmentManager();
277            fragmentManager.popBackStackImmediate(mMiscellaneousViewTransactionId,
278                    FragmentManager.POP_BACK_STACK_INCLUSIVE);
279            mMiscellaneousViewTransactionId = -1;
280        }
281        mSavedMiscellaneousView = false;
282
283        super.onViewModeChanged(newMode);
284        if (newMode != ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION) {
285            // Clear the wait fragment
286            hideWaitForInitialization();
287        }
288        // In conversation mode, if the conversation list is not visible, then the user cannot
289        // see the selected conversations. Disable the CAB mode while leaving the selected set
290        // untouched.
291        // When the conversation list is made visible again, try to enable the CAB
292        // mode if any conversations are selected.
293        if (newMode == ViewMode.CONVERSATION || newMode == ViewMode.CONVERSATION_LIST
294                || ViewMode.isAdMode(newMode)) {
295            enableOrDisableCab();
296        }
297    }
298
299    private @IdRes int getClfNextFocusLeftId(boolean drawerMinimized) {
300        return (drawerMinimized) ? R.id.current_account_avatar : android.R.id.list;
301    }
302
303    @Override
304    public void onConversationVisibilityChanged(boolean visible) {
305        super.onConversationVisibilityChanged(visible);
306        if (!visible) {
307            mPagerController.hide(false /* changeVisibility */);
308        } else if (mConversationToShow != null) {
309            mPagerController.show(mAccount, mFolder, mConversationToShow,
310                    false /* changeVisibility */);
311            mConversationToShow = null;
312        }
313    }
314
315    @Override
316    public void onConversationListVisibilityChanged(boolean visible) {
317        super.onConversationListVisibilityChanged(visible);
318        enableOrDisableCab();
319    }
320
321    @Override
322    public void resetActionBarIcon() {
323        final ActionBar ab = mActivity.getSupportActionBar();
324        final boolean isChildFolder = getFolder() != null && !Utils.isEmpty(getFolder().parent);
325        if (isConversationOnlyMode() || isChildFolder) {
326            ab.setHomeAsUpIndicator(R.drawable.ic_arrow_back_wht_24dp);
327            ab.setHomeActionContentDescription(0 /* system default */);
328        } else {
329            ab.setHomeAsUpIndicator(R.drawable.ic_drawer);
330            ab.setHomeActionContentDescription(
331                    isDrawerOpen() ? R.string.drawer_close : R.string.drawer_open);
332        }
333    }
334
335    @Override
336    protected boolean isComposeVisible(int mode) {
337        return super.isComposeVisible(mode) ||
338                (mIsTabletLandscape && mode == ViewMode.CONVERSATION);
339    }
340
341    /**
342     * Enable or disable the CAB mode based on the visibility of the conversation list fragment.
343     */
344    private void enableOrDisableCab() {
345        if (mLayout.isConversationListCollapsed()) {
346            disableCabMode();
347        } else {
348            enableCabMode();
349        }
350    }
351
352    @Override
353    public void onSetPopulated(ConversationSelectionSet set) {
354        super.onSetPopulated(set);
355
356        boolean showSenderImage =
357                (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
358        if (!showSenderImage && mViewMode.isListMode()) {
359            getConversationListFragment().setChoiceNone();
360        }
361    }
362
363    @Override
364    public void onSetEmpty() {
365        super.onSetEmpty();
366
367        boolean showSenderImage =
368                (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
369        if (!showSenderImage && mViewMode.isListMode()) {
370            getConversationListFragment().revertChoiceMode();
371        }
372    }
373
374    @Override
375    protected void showConversation(Conversation conversation, boolean markAsRead) {
376        super.showConversation(conversation, markAsRead);
377
378        // 2-pane can ignore inLoaderCallbacks because it doesn't use
379        // FragmentManager.popBackStack().
380
381        if (mActivity == null) {
382            return;
383        }
384        if (conversation == null) {
385            handleBackPress();
386            return;
387        }
388        // If conversation list is not visible, then the user cannot see the CAB mode, so exit it.
389        // This is needed here (in addition to during viewmode changes) because orientation changes
390        // while viewing a conversation don't change the viewmode: the mode stays
391        // ViewMode.CONVERSATION and yet the conversation list goes in and out of visibility.
392        enableOrDisableCab();
393
394        // close the drawer, if open
395        if (isDrawerOpen()) {
396            toggleDrawerState();
397        }
398
399        // When a mode change is required, wait for onConversationVisibilityChanged(), the signal
400        // that the mode change animation has finished, before rendering the conversation.
401        mConversationToShow = conversation;
402        mCurrentConversationJustPeeking = !markAsRead;
403
404        final int mode = mViewMode.getMode();
405        LogUtils.i(LOG_TAG, "IN TPC.showConv, oldMode=%s conv=%s", mode, mConversationToShow);
406        if (mode == ViewMode.SEARCH_RESULTS_LIST || mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
407            mViewMode.enterSearchResultsConversationMode();
408        } else {
409            mViewMode.enterConversationMode();
410        }
411        // load the conversation immediately if we're already in conversation mode
412        if (!mLayout.isModeChangePending()) {
413            onConversationVisibilityChanged(true);
414        } else {
415            LogUtils.i(LOG_TAG, "TPC.showConversation will wait for TPL.animationEnd to show!");
416        }
417    }
418
419    @Override
420    public void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) {
421        super.onConversationSelected(conversation, inLoaderCallbacks);
422        if (!mCurrentConversationJustPeeking) {
423            // Shift the focus to the conversation in landscape mode.
424            mPagerController.focusPager();
425        }
426    }
427
428    @Override
429    public void onConversationFocused(Conversation conversation) {
430        if (mIsTabletLandscape) {
431            showConversation(conversation, false /* markAsRead */);
432        }
433    }
434
435    @Override
436    public void setCurrentConversation(Conversation conversation) {
437        // Order is important! We want to calculate different *before* the superclass changes
438        // mCurrentConversation, so before super.setCurrentConversation().
439        final long oldId = mCurrentConversation != null ? mCurrentConversation.id : -1;
440        final long newId = conversation != null ? conversation.id : -1;
441        final boolean different = oldId != newId;
442
443        // This call might change mCurrentConversation.
444        super.setCurrentConversation(conversation);
445
446        final ConversationListFragment convList = getConversationListFragment();
447        if (convList != null && conversation != null) {
448            convList.setSelected(conversation.position, different);
449        }
450    }
451
452    @Override
453    protected void showWaitForInitialization() {
454        super.showWaitForInitialization();
455
456        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
457        fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
458        fragmentTransaction.replace(R.id.conversation_list_pane, getWaitFragment(), TAG_WAIT);
459        fragmentTransaction.commitAllowingStateLoss();
460    }
461
462    @Override
463    protected void hideWaitForInitialization() {
464        final WaitFragment waitFragment = getWaitFragment();
465        if (waitFragment == null) {
466            // We aren't showing a wait fragment: nothing to do
467            return;
468        }
469        // Remove the existing wait fragment from the back stack.
470        final FragmentTransaction fragmentTransaction =
471                mActivity.getFragmentManager().beginTransaction();
472        fragmentTransaction.remove(waitFragment);
473        fragmentTransaction.commitAllowingStateLoss();
474        super.hideWaitForInitialization();
475        if (mViewMode.isWaitingForSync()) {
476            // We should come out of wait mode and display the account inbox.
477            loadAccountInbox();
478        }
479    }
480
481    /**
482     * Up works as follows:
483     * 1) If the user is in a conversation and:
484     *  a) the conversation list is hidden (portrait mode), shows the conv list and
485     *  stays in conversation view mode.
486     *  b) the conversation list is shown, goes back to conversation list mode.
487     * 2) If the user is in search results, up exits search.
488     * mode and returns the user to whatever view they were in when they began search.
489     * 3) If the user is in conversation list mode, there is no up.
490     */
491    @Override
492    public boolean handleUpPress() {
493        if (isConversationOnlyMode()) {
494            handleBackPress();
495        } else {
496            toggleDrawerState();
497        }
498
499        return true;
500    }
501
502    @Override
503    public boolean handleBackPress() {
504        // Clear any visible undo bars.
505        mToastBar.hide(false, false /* actionClicked */);
506        if (isDrawerOpen()) {
507            toggleDrawerState();
508        } else {
509            popView(false);
510        }
511        return true;
512    }
513
514    /**
515     * Pops the "view stack" to the last screen the user was viewing.
516     *
517     * @param preventClose Whether to prevent closing the app if the stack is empty.
518     */
519    protected void popView(boolean preventClose) {
520        // If the user is in search query entry mode, or the user is viewing
521        // search results, exit
522        // the mode.
523        int mode = mViewMode.getMode();
524        if (mode == ViewMode.SEARCH_RESULTS_LIST) {
525            mActivity.finish();
526        } else if (mode == ViewMode.CONVERSATION || mViewMode.isAdMode()) {
527            // Go to conversation list.
528            mViewMode.enterConversationListMode();
529        } else if (mode == ViewMode.SEARCH_RESULTS_CONVERSATION) {
530            mViewMode.enterSearchResultsListMode();
531        } else {
532            // The Folder List fragment can be null for monkeys where we get a back before the
533            // folder list has had a chance to initialize.
534            final FolderListFragment folderList = getFolderListFragment();
535            if (mode == ViewMode.CONVERSATION_LIST && folderList != null
536                    && !Folder.isRoot(mFolder)) {
537                // If the user navigated via the left folders list into a child folder,
538                // back should take the user up to the parent folder's conversation list.
539                navigateUpFolderHierarchy();
540            // Otherwise, if we are in the conversation list but not in the default
541            // inbox and not on expansive layouts, we want to switch back to the default
542            // inbox. This fixes b/9006969 so that on smaller tablets where we have this
543            // hybrid one and two-pane mode, we will return to the inbox. On larger tablets,
544            // we will instead exit the app.
545            } else if (!preventClose) {
546                // There is nothing else to pop off the stack.
547                mActivity.finish();
548            }
549        }
550    }
551
552    @Override
553    public boolean shouldShowFirstConversation() {
554        return Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())
555                && shouldEnterSearchConvMode();
556    }
557
558    @Override
559    public void onUndoAvailable(ToastBarOperation op) {
560        final int mode = mViewMode.getMode();
561        final ConversationListFragment convList = getConversationListFragment();
562
563        switch (mode) {
564            case ViewMode.SEARCH_RESULTS_LIST:
565            case ViewMode.CONVERSATION_LIST:
566            case ViewMode.SEARCH_RESULTS_CONVERSATION:
567            case ViewMode.CONVERSATION:
568                if (convList != null) {
569                    mToastBar.show(getUndoClickedListener(convList.getAnimatedAdapter()),
570                            Utils.convertHtmlToPlainText
571                                (op.getDescription(mActivity.getActivityContext())),
572                            R.string.undo,
573                            true,  /* replaceVisibleToast */
574                            op);
575                }
576        }
577    }
578
579    @Override
580    public void onError(final Folder folder, boolean replaceVisibleToast) {
581        showErrorToast(folder, replaceVisibleToast);
582    }
583
584    @Override
585    public boolean isDrawerEnabled() {
586        // two-pane has its own drawer-like thing that expands inline from a minimized state.
587        return false;
588    }
589
590    @Override
591    public int getFolderListViewChoiceMode() {
592        // By default, we want to allow one item to be selected in the folder list
593        return ListView.CHOICE_MODE_SINGLE;
594    }
595
596    private int mMiscellaneousViewTransactionId = -1;
597
598    @Override
599    public void launchFragment(final Fragment fragment, final int selectPosition) {
600        final int containerViewId = TwoPaneLayout.MISCELLANEOUS_VIEW_ID;
601
602        final FragmentManager fragmentManager = mActivity.getFragmentManager();
603        if (fragmentManager.findFragmentByTag(TAG_CUSTOM_FRAGMENT) == null) {
604            final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
605            fragmentTransaction.addToBackStack(null);
606            fragmentTransaction.replace(containerViewId, fragment, TAG_CUSTOM_FRAGMENT);
607            mMiscellaneousViewTransactionId = fragmentTransaction.commitAllowingStateLoss();
608            fragmentManager.executePendingTransactions();
609        }
610
611        if (selectPosition >= 0) {
612            getConversationListFragment().setRawSelected(selectPosition, true);
613        }
614    }
615
616    @Override
617    public boolean onInterceptCVDownEvent() {
618        // handle a down event on CV by closing the drawer if open
619        if (isDrawerOpen()) {
620            toggleDrawerState();
621            return true;
622        }
623        return false;
624    }
625
626    @Override
627    public boolean onInterceptKeyFromCV(int keyCode, KeyEvent keyEvent, boolean navigateAway) {
628        // Override left/right key presses in landscape mode.
629        if (navigateAway) {
630            if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
631                ConversationListFragment clf = getConversationListFragment();
632                if (clf != null) {
633                    clf.getListView().requestFocus();
634                }
635            }
636            return true;
637        }
638        return false;
639    }
640
641    @Override
642    public boolean isTwoPaneLandscape() {
643        return mIsTabletLandscape;
644    }
645
646    @Override
647    public boolean shouldShowSearchBarByDefault() {
648        final int mode = mViewMode.getMode();
649        return mode == ViewMode.SEARCH_RESULTS_LIST ||
650                (mIsTabletLandscape && mode == ViewMode.SEARCH_RESULTS_CONVERSATION);
651    }
652
653    @Override
654    public boolean shouldShowSearchMenuItem() {
655        final int mode = mViewMode.getMode();
656        return mode == ViewMode.CONVERSATION_LIST ||
657                (mIsTabletLandscape && mode == ViewMode.CONVERSATION);
658    }
659
660    @Override
661    public void setConversationListLayoutListener(
662            TwoPaneLayout.ConversationListLayoutListener listener) {
663        mConversationListLayoutListener = listener;
664        if (mLayout != null) {
665            mLayout.setConversationListLayoutListener(listener);
666        }
667    }
668}
669