SearchActivity.java revision fb8ce18922dae59db424fce906b5c113797fe81e
1/* 2 * Copyright (C) 2009 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.quicksearchbox; 18 19import com.android.common.Search; 20import com.android.quicksearchbox.ui.SearchActivityView; 21import com.android.quicksearchbox.ui.SuggestionClickListener; 22import com.android.quicksearchbox.ui.SuggestionsAdapter; 23import com.android.quicksearchbox.util.Consumer; 24import com.android.quicksearchbox.util.Consumers; 25import com.google.common.annotations.VisibleForTesting; 26import com.google.common.base.CharMatcher; 27 28import android.app.Activity; 29import android.app.SearchManager; 30import android.content.DialogInterface; 31import android.content.Intent; 32import android.database.DataSetObserver; 33import android.net.Uri; 34import android.os.Bundle; 35import android.os.Debug; 36import android.os.Handler; 37import android.text.TextUtils; 38import android.util.Log; 39import android.view.Menu; 40import android.view.View; 41import android.widget.Toast; 42 43import java.io.File; 44import java.util.ArrayList; 45import java.util.Collection; 46import java.util.List; 47import java.util.Set; 48 49/** 50 * The main activity for Quick Search Box. Shows the search UI. 51 * 52 */ 53public class SearchActivity extends Activity { 54 55 private static final boolean DBG = false; 56 private static final String TAG = "QSB.SearchActivity"; 57 private static final boolean TRACE = false; 58 59 private static final String SCHEME_CORPUS = "qsb.corpus"; 60 61 public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS 62 = "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS"; 63 64 // Keys for the saved instance state. 65 private static final String INSTANCE_KEY_CORPUS = "corpus"; 66 private static final String INSTANCE_KEY_QUERY = "query"; 67 68 // Measures time from for last onCreate()/onNewIntent() call. 69 private LatencyTracker mStartLatencyTracker; 70 // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume(). 71 private boolean mStarting; 72 // True if the user has taken some action, e.g. launching a search, voice search, 73 // or suggestions, since QSB was last started. 74 private boolean mTookAction; 75 76 private SearchActivityView mSearchActivityView; 77 78 private CorporaObserver mCorporaObserver; 79 80 private Bundle mAppSearchData; 81 82 private final Handler mHandler = new Handler(); 83 private final Runnable mUpdateSuggestionsTask = new Runnable() { 84 public void run() { 85 updateSuggestions(); 86 } 87 }; 88 89 private final Runnable mShowInputMethodTask = new Runnable() { 90 public void run() { 91 mSearchActivityView.showInputMethodForQuery(); 92 } 93 }; 94 95 private OnDestroyListener mDestroyListener; 96 97 /** Called when the activity is first created. */ 98 @Override 99 public void onCreate(Bundle savedInstanceState) { 100 if (TRACE) startMethodTracing(); 101 recordStartTime(); 102 if (DBG) Log.d(TAG, "onCreate()"); 103 super.onCreate(savedInstanceState); 104 105 mSearchActivityView = setupContentView(); 106 107 if (getConfig().showScrollingSuggestions()) { 108 mSearchActivityView.setMaxPromotedSuggestions(getConfig().getMaxPromotedSuggestions()); 109 } else { 110 mSearchActivityView.limitSuggestionsToViewHeight(); 111 } 112 if (getConfig().showScrollingResults()) { 113 mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedSuggestions()); 114 } else { 115 mSearchActivityView.limitResultsToViewHeight(); 116 } 117 118 mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() { 119 public boolean onSearchClicked(int method) { 120 return SearchActivity.this.onSearchClicked(method); 121 } 122 }); 123 124 mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() { 125 public void onQueryChanged() { 126 updateSuggestionsBuffered(); 127 } 128 }); 129 130 mSearchActivityView.setSuggestionClickListener(new ClickHandler()); 131 132 mSearchActivityView.setSettingsButtonClickListener(new View.OnClickListener() { 133 public void onClick(View v) { 134 onSettingsClicked(); 135 } 136 }); 137 138 mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() { 139 public void onClick(View view) { 140 onVoiceSearchClicked(); 141 } 142 }); 143 144 mSearchActivityView.setExitClickListener(new View.OnClickListener() { 145 public void onClick(View v) { 146 finish(); 147 } 148 }); 149 150 // First get setup from intent 151 Intent intent = getIntent(); 152 setupFromIntent(intent); 153 // Then restore any saved instance state 154 restoreInstanceState(savedInstanceState); 155 156 // Do this at the end, to avoid updating the list view when setSource() 157 // is called. 158 mSearchActivityView.start(); 159 160 mCorporaObserver = new CorporaObserver(); 161 getCorpora().registerDataSetObserver(mCorporaObserver); 162 } 163 164 protected SearchActivityView setupContentView() { 165 setContentView(R.layout.search_activity); 166 return (SearchActivityView) findViewById(R.id.search_activity_view); 167 } 168 169 protected SearchActivityView getSearchActivityView() { 170 return mSearchActivityView; 171 } 172 173 private void startMethodTracing() { 174 File traceDir = getDir("traces", 0); 175 String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath(); 176 Debug.startMethodTracing(traceFile); 177 } 178 179 @Override 180 protected void onNewIntent(Intent intent) { 181 if (DBG) Log.d(TAG, "onNewIntent()"); 182 recordStartTime(); 183 setIntent(intent); 184 setupFromIntent(intent); 185 } 186 187 private void recordStartTime() { 188 mStartLatencyTracker = new LatencyTracker(); 189 mStarting = true; 190 mTookAction = false; 191 } 192 193 protected void restoreInstanceState(Bundle savedInstanceState) { 194 if (savedInstanceState == null) return; 195 String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS); 196 String query = savedInstanceState.getString(INSTANCE_KEY_QUERY); 197 setCorpus(corpusName); 198 setQuery(query, false); 199 } 200 201 @Override 202 protected void onSaveInstanceState(Bundle outState) { 203 super.onSaveInstanceState(outState); 204 // We don't save appSearchData, since we always get the value 205 // from the intent and the user can't change it. 206 207 outState.putString(INSTANCE_KEY_CORPUS, getCorpusName()); 208 outState.putString(INSTANCE_KEY_QUERY, getQuery()); 209 } 210 211 private void setupFromIntent(Intent intent) { 212 if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")"); 213 String corpusName = getCorpusNameFromUri(intent.getData()); 214 String query = intent.getStringExtra(SearchManager.QUERY); 215 Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA); 216 boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false); 217 218 setCorpus(corpusName); 219 setQuery(query, selectAll); 220 mAppSearchData = appSearchData; 221 222 if (startedIntoCorpusSelectionDialog()) { 223 mSearchActivityView.showCorpusSelectionDialog(); 224 } 225 } 226 227 public boolean startedIntoCorpusSelectionDialog() { 228 return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction()); 229 } 230 231 /** 232 * Removes corpus selector intent action, so that BACK works normally after 233 * dismissing and reopening the corpus selector. 234 */ 235 public void clearStartedIntoCorpusSelectionDialog() { 236 Intent oldIntent = getIntent(); 237 if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) { 238 Intent newIntent = new Intent(oldIntent); 239 newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH); 240 setIntent(newIntent); 241 } 242 } 243 244 public static Uri getCorpusUri(Corpus corpus) { 245 if (corpus == null) return null; 246 return new Uri.Builder() 247 .scheme(SCHEME_CORPUS) 248 .authority(corpus.getName()) 249 .build(); 250 } 251 252 private String getCorpusNameFromUri(Uri uri) { 253 if (uri == null) return null; 254 if (!SCHEME_CORPUS.equals(uri.getScheme())) return null; 255 return uri.getAuthority(); 256 } 257 258 private Corpus getCorpus() { 259 return mSearchActivityView.getCorpus(); 260 } 261 262 private String getCorpusName() { 263 return mSearchActivityView.getCorpusName(); 264 } 265 266 private void setCorpus(String name) { 267 mSearchActivityView.setCorpus(name); 268 } 269 270 private QsbApplication getQsbApplication() { 271 return QsbApplication.get(this); 272 } 273 274 private Config getConfig() { 275 return getQsbApplication().getConfig(); 276 } 277 278 protected SearchSettings getSettings() { 279 return getQsbApplication().getSettings(); 280 } 281 282 private Corpora getCorpora() { 283 return getQsbApplication().getCorpora(); 284 } 285 286 private CorpusRanker getCorpusRanker() { 287 return getQsbApplication().getCorpusRanker(); 288 } 289 290 private ShortcutRepository getShortcutRepository() { 291 return getQsbApplication().getShortcutRepository(); 292 } 293 294 private SuggestionsProvider getSuggestionsProvider() { 295 return getQsbApplication().getSuggestionsProvider(); 296 } 297 298 private Logger getLogger() { 299 return getQsbApplication().getLogger(); 300 } 301 302 @VisibleForTesting 303 public void setOnDestroyListener(OnDestroyListener l) { 304 mDestroyListener = l; 305 } 306 307 @Override 308 protected void onDestroy() { 309 if (DBG) Log.d(TAG, "onDestroy()"); 310 getCorpora().unregisterDataSetObserver(mCorporaObserver); 311 mSearchActivityView.destroy(); 312 super.onDestroy(); 313 if (mDestroyListener != null) { 314 mDestroyListener.onDestroyed(); 315 } 316 } 317 318 @Override 319 protected void onStop() { 320 if (DBG) Log.d(TAG, "onStop()"); 321 if (!mTookAction) { 322 // TODO: This gets logged when starting other activities, e.g. by opening the search 323 // settings, or clicking a notification in the status bar. 324 // TODO we should log both sets of suggestions in 2-pane mode 325 getLogger().logExit(getCurrentSuggestions(), getQuery().length()); 326 } 327 // Close all open suggestion cursors. The query will be redone in onResume() 328 // if we come back to this activity. 329 mSearchActivityView.clearSuggestions(); 330 getQsbApplication().getShortcutRefresher().reset(); 331 mSearchActivityView.onStop(); 332 super.onStop(); 333 } 334 335 @Override 336 protected void onRestart() { 337 if (DBG) Log.d(TAG, "onRestart()"); 338 super.onRestart(); 339 } 340 341 @Override 342 protected void onResume() { 343 if (DBG) Log.d(TAG, "onResume()"); 344 super.onResume(); 345 updateSuggestionsBuffered(); 346 mSearchActivityView.onResume(); 347 if (TRACE) Debug.stopMethodTracing(); 348 } 349 350 @Override 351 public boolean onCreateOptionsMenu(Menu menu) { 352 super.onCreateOptionsMenu(menu); 353 getSettings().addMenuItems(menu); 354 return true; 355 } 356 357 @Override 358 public boolean onPrepareOptionsMenu(Menu menu) { 359 super.onPrepareOptionsMenu(menu); 360 getSettings().updateMenuItems(menu); 361 return true; 362 } 363 364 @Override 365 public void onWindowFocusChanged(boolean hasFocus) { 366 super.onWindowFocusChanged(hasFocus); 367 if (hasFocus) { 368 // Launch the IME after a bit 369 mHandler.postDelayed(mShowInputMethodTask, 0); 370 } 371 } 372 373 protected String getQuery() { 374 return mSearchActivityView.getQuery(); 375 } 376 377 protected void setQuery(String query, boolean selectAll) { 378 mSearchActivityView.setQuery(query, selectAll); 379 } 380 381 public CorpusSelectionDialog getCorpusSelectionDialog() { 382 CorpusSelectionDialog dialog = createCorpusSelectionDialog(); 383 dialog.setOwnerActivity(this); 384 dialog.setOnDismissListener(new CorpusSelectorDismissListener()); 385 return dialog; 386 } 387 388 protected CorpusSelectionDialog createCorpusSelectionDialog() { 389 return new CorpusSelectionDialog(this, getSettings()); 390 } 391 392 /** 393 * @return true if a search was performed as a result of this click, false otherwise. 394 */ 395 protected boolean onSearchClicked(int method) { 396 String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' '); 397 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 398 399 // Don't do empty queries 400 if (TextUtils.getTrimmedLength(query) == 0) return false; 401 402 Corpus searchCorpus = getSearchCorpus(); 403 if (searchCorpus == null) return false; 404 405 mTookAction = true; 406 407 // Log search start 408 getLogger().logSearch(getCorpus(), method, query.length()); 409 410 // Create shortcut 411 SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query); 412 if (searchShortcut != null) { 413 ListSuggestionCursor cursor = new ListSuggestionCursor(query); 414 cursor.add(searchShortcut); 415 getShortcutRepository().reportClick(cursor, 0); 416 } 417 418 // Start search 419 startSearch(searchCorpus, query); 420 return true; 421 } 422 423 protected void startSearch(Corpus searchCorpus, String query) { 424 Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData); 425 launchIntent(intent); 426 } 427 428 protected void onVoiceSearchClicked() { 429 if (DBG) Log.d(TAG, "Voice Search clicked"); 430 Corpus searchCorpus = getSearchCorpus(); 431 if (searchCorpus == null) return; 432 433 mTookAction = true; 434 435 // Log voice search start 436 getLogger().logVoiceSearch(searchCorpus); 437 438 // Start voice search 439 Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData); 440 launchIntent(intent); 441 } 442 443 protected void onSettingsClicked() { 444 startActivity(getSettings().getSearchSettingsIntent()); 445 } 446 447 protected Corpus getSearchCorpus() { 448 return mSearchActivityView.getSearchCorpus(); 449 } 450 451 protected SuggestionCursor getCurrentSuggestions() { 452 return mSearchActivityView.getCurrentSuggestions(); 453 } 454 455 protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) { 456 SuggestionCursor suggestions = adapter.getCurrentSuggestions(); 457 if (suggestions == null) { 458 return null; 459 } 460 int count = suggestions.getCount(); 461 if (position < 0 || position >= count) { 462 Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count); 463 return null; 464 } 465 suggestions.moveTo(position); 466 return suggestions; 467 } 468 469 protected Set<Corpus> getCurrentIncludedCorpora() { 470 Suggestions suggestions = mSearchActivityView.getSuggestions(); 471 return suggestions == null ? null : suggestions.getIncludedCorpora(); 472 } 473 474 protected void launchIntent(Intent intent) { 475 if (DBG) Log.d(TAG, "launchIntent " + intent); 476 if (intent == null) { 477 return; 478 } 479 try { 480 startActivity(intent); 481 if (!getConfig().keepSearchActivityInBackStack()) { 482 finish(); 483 } 484 } catch (RuntimeException ex) { 485 // Since the intents for suggestions specified by suggestion providers, 486 // guard against them not being handled, not allowed, etc. 487 Log.e(TAG, "Failed to start " + intent.toUri(0), ex); 488 } 489 } 490 491 private boolean launchSuggestion(SuggestionsAdapter adapter, int position) { 492 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 493 if (suggestions == null) return false; 494 495 if (DBG) Log.d(TAG, "Launching suggestion " + position); 496 mTookAction = true; 497 498 // Log suggestion click 499 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 500 Logger.SUGGESTION_CLICK_TYPE_LAUNCH); 501 502 // Create shortcut 503 getShortcutRepository().reportClick(suggestions, position); 504 505 // Launch intent 506 launchSuggestion(suggestions, position); 507 508 return true; 509 } 510 511 protected void launchSuggestion(SuggestionCursor suggestions, int position) { 512 suggestions.moveTo(position); 513 Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData); 514 launchIntent(intent); 515 } 516 517 protected void removeFromHistory(SuggestionsAdapter adapter, int position) { 518 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 519 if (suggestions == null) return; 520 removeFromHistory(suggestions, position); 521 // TODO: Log to event log? 522 } 523 524 protected void removeFromHistory(SuggestionCursor suggestions, int position) { 525 removeShortcut(suggestions, position); 526 removeFromHistoryDone(true); 527 } 528 529 protected void removeFromHistoryDone(boolean ok) { 530 Log.i(TAG, "Removed query from history, success=" + ok); 531 updateSuggestionsBuffered(); 532 if (!ok) { 533 Toast.makeText(this, R.string.remove_from_history_failed, Toast.LENGTH_SHORT).show(); 534 } 535 } 536 537 protected void removeShortcut(SuggestionCursor suggestions, int position) { 538 if (suggestions.isSuggestionShortcut()) { 539 if (DBG) Log.d(TAG, "Removing suggestion " + position + " from shortcuts"); 540 getShortcutRepository().removeFromHistory(suggestions, position); 541 } 542 } 543 544 protected void clickedQuickContact(SuggestionsAdapter adapter, int position) { 545 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 546 if (suggestions == null) return; 547 548 if (DBG) Log.d(TAG, "Used suggestion " + position); 549 mTookAction = true; 550 551 // Log suggestion click 552 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 553 Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT); 554 555 // Create shortcut 556 getShortcutRepository().reportClick(suggestions, position); 557 } 558 559 protected void refineSuggestion(SuggestionsAdapter adapter, int position) { 560 if (DBG) Log.d(TAG, "query refine clicked, pos " + position); 561 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 562 if (suggestions == null) { 563 return; 564 } 565 String query = suggestions.getSuggestionQuery(); 566 if (TextUtils.isEmpty(query)) { 567 return; 568 } 569 570 // Log refine click 571 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 572 Logger.SUGGESTION_CLICK_TYPE_REFINE); 573 574 // Put query + space in query text view 575 String queryWithSpace = query + ' '; 576 setQuery(queryWithSpace, false); 577 updateSuggestions(); 578 mSearchActivityView.focusQueryTextView(); 579 } 580 581 private void updateSuggestionsBuffered() { 582 if (DBG) Log.d(TAG, "updateSuggestionsBuffered()"); 583 mHandler.removeCallbacks(mUpdateSuggestionsTask); 584 long delay = getConfig().getTypingUpdateSuggestionsDelayMillis(); 585 mHandler.postDelayed(mUpdateSuggestionsTask, delay); 586 } 587 588 private void gotSuggestions(Suggestions suggestions) { 589 if (mStarting) { 590 mStarting = false; 591 String source = getIntent().getStringExtra(Search.SOURCE); 592 int latency = mStartLatencyTracker.getLatency(); 593 getLogger().logStart(latency, source, getCorpus(), 594 suggestions == null ? null : suggestions.getExpectedCorpora()); 595 getQsbApplication().onStartupComplete(); 596 } 597 } 598 599 private void getCorporaToQuery(Consumer<List<Corpus>> consumer) { 600 Corpus corpus = getCorpus(); 601 if (corpus == null) { 602 getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer)); 603 } else { 604 List<Corpus> corpora = new ArrayList<Corpus>(); 605 Corpus searchCorpus = getSearchCorpus(); 606 if (searchCorpus != null) corpora.add(searchCorpus); 607 consumer.consume(corpora); 608 } 609 } 610 611 protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery, 612 final Suggestions suggestions) { 613 ShortcutRepository shortcutRepo = getShortcutRepository(); 614 if (shortcutRepo == null) return; 615 if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) { 616 return; 617 } 618 Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler, 619 new Consumer<ShortcutCursor>() { 620 public boolean consume(ShortcutCursor shortcuts) { 621 suggestions.setShortcuts(shortcuts); 622 return true; 623 } 624 }); 625 shortcutRepo.getShortcutsForQuery(query, corporaToQuery, 626 getSettings().allowWebSearchShortcuts(), consumer); 627 } 628 629 public void updateSuggestions() { 630 if (DBG) Log.d(TAG, "updateSuggestions()"); 631 final String query = CharMatcher.WHITESPACE.trimLeadingFrom(getQuery()); 632 getQsbApplication().getSourceTaskExecutor().cancelPendingTasks(); 633 getCorporaToQuery(new Consumer<List<Corpus>>(){ 634 @Override 635 public boolean consume(List<Corpus> corporaToQuery) { 636 updateSuggestions(query, corporaToQuery); 637 return true; 638 } 639 }); 640 } 641 642 protected void updateSuggestions(String query, List<Corpus> corporaToQuery) { 643 if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + corporaToQuery + ")"); 644 Suggestions suggestions = getSuggestionsProvider().getSuggestions( 645 query, corporaToQuery); 646 getShortcutsForQuery(query, corporaToQuery, suggestions); 647 648 // Log start latency if this is the first suggestions update 649 gotSuggestions(suggestions); 650 651 showSuggestions(suggestions); 652 } 653 654 protected void showSuggestions(Suggestions suggestions) { 655 mSearchActivityView.setSuggestions(suggestions); 656 } 657 658 private class ClickHandler implements SuggestionClickListener { 659 660 public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) { 661 clickedQuickContact(adapter, position); 662 } 663 664 public void onSuggestionClicked(SuggestionsAdapter adapter, int position) { 665 launchSuggestion(adapter, position); 666 } 667 668 public void onSuggestionRemoveFromHistoryClicked(SuggestionsAdapter adapter, int position) { 669 removeFromHistory(adapter, position); 670 } 671 672 public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) { 673 refineSuggestion(adapter, position); 674 } 675 } 676 677 private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener { 678 public void onDismiss(DialogInterface dialog) { 679 if (DBG) Log.d(TAG, "Corpus selector dismissed"); 680 clearStartedIntoCorpusSelectionDialog(); 681 } 682 } 683 684 private class CorporaObserver extends DataSetObserver { 685 @Override 686 public void onChanged() { 687 setCorpus(getCorpusName()); 688 updateSuggestions(); 689 } 690 } 691 692 public interface OnDestroyListener { 693 void onDestroyed(); 694 } 695 696} 697