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 17 package com.android.contacts; 18 19 import android.app.Activity; 20 import android.app.AlertDialog; 21 import android.app.ProgressDialog; 22 import android.content.ContentResolver; 23 import android.content.Context; 24 import android.content.DialogInterface; 25 import android.content.DialogInterface.OnCancelListener; 26 import android.content.DialogInterface.OnClickListener; 27 import android.os.Bundle; 28 import android.os.Handler; 29 import android.os.PowerManager; 30 import android.syncml.pim.VBuilder; 31 import android.syncml.pim.VBuilderCollection; 32 import android.syncml.pim.VParser; 33 import android.syncml.pim.vcard.VCardDataBuilder; 34 import android.syncml.pim.vcard.VCardEntryCounter; 35 import android.syncml.pim.vcard.VCardException; 36 import android.syncml.pim.vcard.VCardNestedException; 37 import android.syncml.pim.vcard.VCardParser_V21; 38 import android.syncml.pim.vcard.VCardParser_V30; 39 import android.syncml.pim.vcard.VCardSourceDetector; 40 import android.syncml.pim.vcard.VCardVersionException; 41 import android.text.SpannableStringBuilder; 42 import android.text.Spanned; 43 import android.text.style.RelativeSizeSpan; 44 import android.util.Log; 45 46 import java.io.File; 47 import java.io.FileInputStream; 48 import java.io.IOException; 49 import java.text.DateFormat; 50 import java.text.SimpleDateFormat; 51 import java.util.Arrays; 52 import java.util.Date; 53 import java.util.HashSet; 54 import java.util.List; 55 import java.util.Set; 56 import java.util.Vector; 57 58 class VCardFile { 59 private String mName; 60 private String mCanonicalPath; 61 private long mLastModified; 62 VCardFile(String name, String canonicalPath, long lastModified)63 public VCardFile(String name, String canonicalPath, long lastModified) { 64 mName = name; 65 mCanonicalPath = canonicalPath; 66 mLastModified = lastModified; 67 } 68 getName()69 public String getName() { 70 return mName; 71 } 72 getCanonicalPath()73 public String getCanonicalPath() { 74 return mCanonicalPath; 75 } 76 getLastModified()77 public long getLastModified() { 78 return mLastModified; 79 } 80 } 81 82 /** 83 * Class for importing vCard. Several user interaction will be required while reading 84 * (selecting a file, waiting a moment, etc.) 85 */ 86 public class ImportVCardActivity extends Activity { 87 private static final String LOG_TAG = "ImportVCardActivity"; 88 private static final boolean DO_PERFORMANCE_PROFILE = false; 89 90 private ProgressDialog mProgressDialog; 91 private Handler mHandler = new Handler(); 92 private boolean mLastNameComesBeforeFirstName; 93 94 private class CancelListener 95 implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener { onClick(DialogInterface dialog, int which)96 public void onClick(DialogInterface dialog, int which) { 97 finish(); 98 } 99 onCancel(DialogInterface dialog)100 public void onCancel(DialogInterface dialog) { 101 finish(); 102 } 103 } 104 105 private CancelListener mCancelListener = new CancelListener(); 106 107 private class ErrorDisplayer implements Runnable { 108 private String mErrorMessage; 109 ErrorDisplayer(String errorMessage)110 public ErrorDisplayer(String errorMessage) { 111 mErrorMessage = errorMessage; 112 } 113 run()114 public void run() { 115 String message = 116 getString(R.string.reading_vcard_failed_message, mErrorMessage); 117 AlertDialog.Builder builder = 118 new AlertDialog.Builder(ImportVCardActivity.this) 119 .setTitle(getString(R.string.reading_vcard_failed_title)) 120 .setIcon(android.R.drawable.ic_dialog_alert) 121 .setMessage(message) 122 .setOnCancelListener(mCancelListener) 123 .setPositiveButton(android.R.string.ok, mCancelListener); 124 builder.show(); 125 } 126 } 127 128 private class VCardReadThread extends Thread 129 implements DialogInterface.OnCancelListener { 130 private String mCanonicalPath; 131 private List<VCardFile> mVCardFileList; 132 private ContentResolver mResolver; 133 private VCardParser_V21 mVCardParser; 134 private boolean mCanceled; 135 private PowerManager.WakeLock mWakeLock; 136 VCardReadThread(String canonicalPath)137 public VCardReadThread(String canonicalPath) { 138 mCanonicalPath = canonicalPath; 139 mVCardFileList = null; 140 init(); 141 } 142 VCardReadThread(List<VCardFile> vcardFileList)143 public VCardReadThread(List<VCardFile> vcardFileList) { 144 mCanonicalPath = null; 145 mVCardFileList = vcardFileList; 146 init(); 147 } 148 init()149 private void init() { 150 Context context = ImportVCardActivity.this; 151 mResolver = context.getContentResolver(); 152 PowerManager powerManager = (PowerManager)context.getSystemService( 153 Context.POWER_SERVICE); 154 mWakeLock = powerManager.newWakeLock( 155 PowerManager.SCREEN_DIM_WAKE_LOCK | 156 PowerManager.ON_AFTER_RELEASE, LOG_TAG); 157 } 158 159 @Override finalize()160 public void finalize() { 161 if (mWakeLock != null && mWakeLock.isHeld()) { 162 mWakeLock.release(); 163 } 164 } 165 166 @Override run()167 public void run() { 168 mWakeLock.acquire(); 169 // Some malicious vCard data may make this thread broken 170 // (e.g. OutOfMemoryError). 171 // Even in such cases, some should be done. 172 try { 173 if (mCanonicalPath != null) { 174 mProgressDialog.setProgressNumberFormat(""); 175 mProgressDialog.setProgress(0); 176 177 // Count the number of VCard entries 178 mProgressDialog.setIndeterminate(true); 179 long start; 180 if (DO_PERFORMANCE_PROFILE) { 181 start = System.currentTimeMillis(); 182 } 183 VCardEntryCounter counter = new VCardEntryCounter(); 184 VCardSourceDetector detector = new VCardSourceDetector(); 185 VBuilderCollection builderCollection = new VBuilderCollection( 186 Arrays.asList(counter, detector)); 187 boolean result; 188 try { 189 result = readOneVCard(mCanonicalPath, 190 VParser.DEFAULT_CHARSET, builderCollection, null, true); 191 } catch (VCardNestedException e) { 192 try { 193 // Assume that VCardSourceDetector was able to detect the source. 194 // Try again with the detector. 195 result = readOneVCard(mCanonicalPath, 196 VParser.DEFAULT_CHARSET, counter, detector, false); 197 } catch (VCardNestedException e2) { 198 result = false; 199 Log.e(LOG_TAG, "Must not reach here. " + e2); 200 } 201 } 202 if (DO_PERFORMANCE_PROFILE) { 203 long time = System.currentTimeMillis() - start; 204 Log.d(LOG_TAG, "time for counting the number of vCard entries: " + 205 time + " ms"); 206 } 207 if (!result) { 208 return; 209 } 210 211 mProgressDialog.setProgressNumberFormat( 212 getString(R.string.reading_vcard_contacts)); 213 mProgressDialog.setIndeterminate(false); 214 mProgressDialog.setMax(counter.getCount()); 215 String charset = detector.getEstimatedCharset(); 216 doActuallyReadOneVCard(charset, true, detector); 217 } else { 218 mProgressDialog.setProgressNumberFormat( 219 getString(R.string.reading_vcard_files)); 220 mProgressDialog.setMax(mVCardFileList.size()); 221 mProgressDialog.setProgress(0); 222 for (VCardFile vcardFile : mVCardFileList) { 223 if (mCanceled) { 224 return; 225 } 226 String canonicalPath = vcardFile.getCanonicalPath(); 227 228 VCardSourceDetector detector = new VCardSourceDetector(); 229 try { 230 if (!readOneVCard(canonicalPath, VParser.DEFAULT_CHARSET, detector, 231 null, true)) { 232 continue; 233 } 234 } catch (VCardNestedException e) { 235 // Assume that VCardSourceDetector was able to detect the source. 236 } 237 String charset = detector.getEstimatedCharset(); 238 doActuallyReadOneVCard(charset, false, detector); 239 mProgressDialog.incrementProgressBy(1); 240 } 241 } 242 } finally { 243 mWakeLock.release(); 244 mProgressDialog.dismiss(); 245 finish(); 246 } 247 } 248 doActuallyReadOneVCard(String charset, boolean doIncrementProgress, VCardSourceDetector detector)249 private void doActuallyReadOneVCard(String charset, boolean doIncrementProgress, 250 VCardSourceDetector detector) { 251 VCardDataBuilder builder; 252 final Context context = ImportVCardActivity.this; 253 if (charset != null) { 254 builder = new VCardDataBuilder(mResolver, 255 mProgressDialog, 256 context.getString(R.string.reading_vcard_message), 257 mHandler, 258 charset, 259 charset, 260 false, 261 mLastNameComesBeforeFirstName); 262 } else { 263 builder = new VCardDataBuilder(mResolver, 264 mProgressDialog, 265 context.getString(R.string.reading_vcard_message), 266 mHandler, 267 null, 268 null, 269 false, 270 mLastNameComesBeforeFirstName); 271 charset = VParser.DEFAULT_CHARSET; 272 } 273 if (doIncrementProgress) { 274 builder.setOnProgressRunnable(new Runnable() { 275 public void run() { 276 mProgressDialog.incrementProgressBy(1); 277 } 278 }); 279 } 280 try { 281 readOneVCard(mCanonicalPath, charset, builder, detector, false); 282 } catch (VCardNestedException e) { 283 Log.e(LOG_TAG, "Must not reach here."); 284 } 285 builder.showDebugInfo(); 286 } 287 readOneVCard(String canonicalPath, String charset, VBuilder builder, VCardSourceDetector detector, boolean throwNestedException)288 private boolean readOneVCard(String canonicalPath, String charset, VBuilder builder, 289 VCardSourceDetector detector, boolean throwNestedException) 290 throws VCardNestedException { 291 FileInputStream is; 292 try { 293 is = new FileInputStream(canonicalPath); 294 mVCardParser = new VCardParser_V21(detector); 295 296 try { 297 mVCardParser.parse(is, charset, builder, mCanceled); 298 } catch (VCardVersionException e1) { 299 try { 300 is.close(); 301 } catch (IOException e) { 302 } 303 is = new FileInputStream(canonicalPath); 304 305 try { 306 mVCardParser = new VCardParser_V30(); 307 mVCardParser.parse(is, charset, builder, mCanceled); 308 } catch (VCardVersionException e2) { 309 throw new VCardException("vCard with unspported version."); 310 } 311 } finally { 312 if (is != null) { 313 try { 314 is.close(); 315 } catch (IOException e) { 316 } 317 } 318 } 319 mVCardParser.showDebugInfo(); 320 } catch (IOException e) { 321 Log.e(LOG_TAG, "IOException was emitted: " + e); 322 323 mProgressDialog.dismiss(); 324 325 mHandler.post(new ErrorDisplayer( 326 getString(R.string.fail_reason_io_error) + 327 " (" + e.getMessage() + ")")); 328 return false; 329 } catch (VCardNestedException e) { 330 if (throwNestedException) { 331 throw e; 332 } else { 333 Log.e(LOG_TAG, "VCardNestedException was emitted: " + e); 334 mHandler.post(new ErrorDisplayer( 335 getString(R.string.fail_reason_vcard_parse_error) + 336 " (" + e.getMessage() + ")")); 337 return false; 338 } 339 } catch (VCardException e) { 340 Log.e(LOG_TAG, "VCardException was emitted: " + e); 341 342 mHandler.post(new ErrorDisplayer( 343 getString(R.string.fail_reason_vcard_parse_error) + 344 " (" + e.getMessage() + ")")); 345 return false; 346 } 347 return true; 348 } 349 onCancel(DialogInterface dialog)350 public void onCancel(DialogInterface dialog) { 351 mCanceled = true; 352 if (mVCardParser != null) { 353 mVCardParser.cancel(); 354 } 355 } 356 } 357 358 private class ImportTypeSelectedListener implements 359 DialogInterface.OnClickListener { 360 public static final int IMPORT_ALL = 0; 361 public static final int IMPORT_ONE = 1; 362 363 private List<VCardFile> mVCardFileList; 364 private int mCurrentIndex; 365 ImportTypeSelectedListener(List<VCardFile> vcardFileList)366 public ImportTypeSelectedListener(List<VCardFile> vcardFileList) { 367 mVCardFileList = vcardFileList; 368 } 369 onClick(DialogInterface dialog, int which)370 public void onClick(DialogInterface dialog, int which) { 371 if (which == DialogInterface.BUTTON_POSITIVE) { 372 if (mCurrentIndex == IMPORT_ALL) { 373 importAllVCardFromSDCard(mVCardFileList); 374 } else { 375 showVCardFileSelectDialog(mVCardFileList); 376 } 377 } else if (which == DialogInterface.BUTTON_NEGATIVE) { 378 finish(); 379 } else { 380 mCurrentIndex = which; 381 } 382 } 383 } 384 385 private class VCardSelectedListener implements DialogInterface.OnClickListener { 386 private List<VCardFile> mVCardFileList; 387 private int mCurrentIndex; 388 VCardSelectedListener(List<VCardFile> vcardFileList)389 public VCardSelectedListener(List<VCardFile> vcardFileList) { 390 mVCardFileList = vcardFileList; 391 mCurrentIndex = 0; 392 } 393 onClick(DialogInterface dialog, int which)394 public void onClick(DialogInterface dialog, int which) { 395 if (which == DialogInterface.BUTTON_POSITIVE) { 396 importOneVCardFromSDCard(mVCardFileList.get(mCurrentIndex).getCanonicalPath()); 397 } else if (which == DialogInterface.BUTTON_NEGATIVE) { 398 finish(); 399 } else { 400 // Some file is selected. 401 mCurrentIndex = which; 402 } 403 } 404 } 405 406 /** 407 * Thread scanning VCard from SDCard. After scanning, the dialog which lets a user select 408 * a vCard file is shown. After the choice, VCardReadThread starts running. 409 */ 410 private class VCardScanThread extends Thread implements OnCancelListener, OnClickListener { 411 private boolean mCanceled; 412 private boolean mGotIOException; 413 private File mRootDirectory; 414 415 // null when search operation is canceled. 416 private List<VCardFile> mVCardFiles; 417 418 // To avoid recursive link. 419 private Set<String> mCheckedPaths; 420 private PowerManager.WakeLock mWakeLock; 421 422 private class CanceledException extends Exception { 423 } 424 VCardScanThread(File sdcardDirectory)425 public VCardScanThread(File sdcardDirectory) { 426 mCanceled = false; 427 mGotIOException = false; 428 mRootDirectory = sdcardDirectory; 429 mCheckedPaths = new HashSet<String>(); 430 mVCardFiles = new Vector<VCardFile>(); 431 PowerManager powerManager = (PowerManager)ImportVCardActivity.this.getSystemService( 432 Context.POWER_SERVICE); 433 mWakeLock = powerManager.newWakeLock( 434 PowerManager.SCREEN_DIM_WAKE_LOCK | 435 PowerManager.ON_AFTER_RELEASE, LOG_TAG); 436 } 437 438 @Override run()439 public void run() { 440 try { 441 mWakeLock.acquire(); 442 getVCardFileRecursively(mRootDirectory); 443 } catch (CanceledException e) { 444 mCanceled = true; 445 } catch (IOException e) { 446 mGotIOException = true; 447 } finally { 448 mWakeLock.release(); 449 } 450 451 if (mCanceled) { 452 mVCardFiles = null; 453 } 454 455 mProgressDialog.dismiss(); 456 457 if (mGotIOException) { 458 mHandler.post(new Runnable() { 459 public void run() { 460 String message = (getString(R.string.scanning_sdcard_failed_message, 461 getString(R.string.fail_reason_io_error))); 462 463 AlertDialog.Builder builder = 464 new AlertDialog.Builder(ImportVCardActivity.this) 465 .setTitle(R.string.scanning_sdcard_failed_title) 466 .setIcon(android.R.drawable.ic_dialog_alert) 467 .setMessage(message) 468 .setOnCancelListener(mCancelListener) 469 .setPositiveButton(android.R.string.ok, mCancelListener); 470 builder.show(); 471 } 472 }); 473 } else if (mCanceled) { 474 finish(); 475 } else { 476 mHandler.post(new Runnable() { 477 public void run() { 478 int size = mVCardFiles.size(); 479 final Context context = ImportVCardActivity.this; 480 if (size == 0) { 481 String message = (getString(R.string.scanning_sdcard_failed_message, 482 getString(R.string.fail_reason_no_vcard_file))); 483 484 AlertDialog.Builder builder = 485 new AlertDialog.Builder(context) 486 .setTitle(R.string.scanning_sdcard_failed_title) 487 .setMessage(message) 488 .setOnCancelListener(mCancelListener) 489 .setPositiveButton(android.R.string.ok, mCancelListener); 490 builder.show(); 491 return; 492 } else if (context.getResources().getBoolean( 493 R.bool.config_import_all_vcard_from_sdcard_automatically)) { 494 importAllVCardFromSDCard(mVCardFiles); 495 } else if (size == 1) { 496 importOneVCardFromSDCard(mVCardFiles.get(0).getCanonicalPath()); 497 } else if (context.getResources().getBoolean( 498 R.bool.config_allow_users_select_all_vcard_import)) { 499 showSelectImportTypeDialog(mVCardFiles); 500 } else { 501 showVCardFileSelectDialog(mVCardFiles); 502 } 503 } 504 }); 505 } 506 } 507 getVCardFileRecursively(File directory)508 private void getVCardFileRecursively(File directory) 509 throws CanceledException, IOException { 510 if (mCanceled) { 511 throw new CanceledException(); 512 } 513 514 for (File file : directory.listFiles()) { 515 if (mCanceled) { 516 throw new CanceledException(); 517 } 518 String canonicalPath = file.getCanonicalPath(); 519 if (mCheckedPaths.contains(canonicalPath)) { 520 continue; 521 } 522 523 mCheckedPaths.add(canonicalPath); 524 525 if (file.isDirectory()) { 526 getVCardFileRecursively(file); 527 } else if (canonicalPath.toLowerCase().endsWith(".vcf") && 528 file.canRead()){ 529 String fileName = file.getName(); 530 VCardFile vcardFile = new VCardFile( 531 fileName, canonicalPath, file.lastModified()); 532 mVCardFiles.add(vcardFile); 533 } 534 } 535 } 536 onCancel(DialogInterface dialog)537 public void onCancel(DialogInterface dialog) { 538 mCanceled = true; 539 } 540 onClick(DialogInterface dialog, int which)541 public void onClick(DialogInterface dialog, int which) { 542 if (which == DialogInterface.BUTTON_NEGATIVE) { 543 mCanceled = true; 544 } 545 } 546 } 547 548 importOneVCardFromSDCard(String canonicalPath)549 private void importOneVCardFromSDCard(String canonicalPath) { 550 VCardReadThread thread = new VCardReadThread(canonicalPath); 551 showReadingVCardDialog(thread); 552 thread.start(); 553 } 554 importAllVCardFromSDCard(List<VCardFile> vcardFileList)555 private void importAllVCardFromSDCard(List<VCardFile> vcardFileList) { 556 VCardReadThread thread = new VCardReadThread(vcardFileList); 557 showReadingVCardDialog(thread); 558 thread.start(); 559 } 560 showSelectImportTypeDialog(List<VCardFile> vcardFileList)561 private void showSelectImportTypeDialog(List<VCardFile> vcardFileList) { 562 DialogInterface.OnClickListener listener = 563 new ImportTypeSelectedListener(vcardFileList); 564 AlertDialog.Builder builder = 565 new AlertDialog.Builder(ImportVCardActivity.this) 566 .setTitle(R.string.select_vcard_title) 567 .setPositiveButton(android.R.string.ok, listener) 568 .setOnCancelListener(mCancelListener) 569 .setNegativeButton(android.R.string.cancel, mCancelListener); 570 571 String[] items = new String[2]; 572 items[ImportTypeSelectedListener.IMPORT_ALL] = 573 getString(R.string.import_all_vcard_string); 574 items[ImportTypeSelectedListener.IMPORT_ONE] = 575 getString(R.string.import_one_vcard_string); 576 builder.setSingleChoiceItems(items, 577 ImportTypeSelectedListener.IMPORT_ALL, listener); 578 builder.show(); 579 } 580 showVCardFileSelectDialog(List<VCardFile> vcardFileList)581 private void showVCardFileSelectDialog(List<VCardFile> vcardFileList) { 582 int size = vcardFileList.size(); 583 DialogInterface.OnClickListener listener = 584 new VCardSelectedListener(vcardFileList); 585 AlertDialog.Builder builder = 586 new AlertDialog.Builder(this) 587 .setTitle(R.string.select_vcard_title) 588 .setPositiveButton(android.R.string.ok, listener) 589 .setOnCancelListener(mCancelListener) 590 .setNegativeButton(android.R.string.cancel, mCancelListener); 591 592 CharSequence[] items = new CharSequence[size]; 593 DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); 594 for (int i = 0; i < size; i++) { 595 VCardFile vcardFile = vcardFileList.get(i); 596 SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); 597 stringBuilder.append(vcardFile.getName()); 598 stringBuilder.append('\n'); 599 int indexToBeSpanned = stringBuilder.length(); 600 // Smaller date text looks better, since each file name becomes easier to read. 601 // The value set to RelativeSizeSpan is arbitrary. You can change it to any other 602 // value (but the value bigger than 1.0f would not make nice appearance :) 603 stringBuilder.append( 604 "(" + dateFormat.format(new Date(vcardFile.getLastModified())) + ")"); 605 stringBuilder.setSpan( 606 new RelativeSizeSpan(0.7f), indexToBeSpanned, stringBuilder.length(), 607 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 608 items[i] = stringBuilder; 609 } 610 builder.setSingleChoiceItems(items, 0, listener); 611 builder.show(); 612 } 613 showReadingVCardDialog(DialogInterface.OnCancelListener listener)614 private void showReadingVCardDialog(DialogInterface.OnCancelListener listener) { 615 String title = getString(R.string.reading_vcard_title); 616 String message = getString(R.string.reading_vcard_message); 617 mProgressDialog = new ProgressDialog(this); 618 mProgressDialog.setTitle(title); 619 mProgressDialog.setMessage(message); 620 mProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); 621 mProgressDialog.setOnCancelListener(listener); 622 mProgressDialog.show(); 623 } 624 625 @Override onCreate(Bundle bundle)626 protected void onCreate(Bundle bundle) { 627 super.onCreate(bundle); 628 629 mLastNameComesBeforeFirstName = getResources().getBoolean( 630 com.android.internal.R.bool.config_lastname_comes_before_firstname); 631 632 startImportVCardFromSdCard(); 633 } 634 635 /** 636 * Tries to start importing VCard. If there's no SDCard available, 637 * an error dialog is shown. If there is, start scanning using another thread 638 * and shows a progress dialog. Several interactions will occur. 639 * This method should be called from a thread with a looper (like Activity). 640 */ startImportVCardFromSdCard()641 public void startImportVCardFromSdCard() { 642 File file = new File("/sdcard"); 643 if (!file.exists() || !file.isDirectory() || !file.canRead()) { 644 new AlertDialog.Builder(this) 645 .setTitle(R.string.no_sdcard_title) 646 .setIcon(android.R.drawable.ic_dialog_alert) 647 .setMessage(R.string.no_sdcard_message) 648 .setOnCancelListener(mCancelListener) 649 .setPositiveButton(android.R.string.ok, mCancelListener) 650 .show(); 651 } else { 652 String title = getString(R.string.searching_vcard_title); 653 String message = getString(R.string.searching_vcard_message); 654 655 mProgressDialog = ProgressDialog.show(this, title, message, true, false); 656 VCardScanThread thread = new VCardScanThread(file); 657 mProgressDialog.setOnCancelListener(thread); 658 thread.start(); 659 } 660 } 661 } 662