EmailServiceUtils.java revision e4ac06080ba8b8e57531982ea290354b1a9a4305
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.email.service; 18 19import android.accounts.AccountManager; 20import android.accounts.AccountManagerCallback; 21import android.accounts.AccountManagerFuture; 22import android.accounts.AuthenticatorException; 23import android.accounts.OperationCanceledException; 24import android.app.Service; 25import android.content.ContentProviderClient; 26import android.content.ContentResolver; 27import android.content.ContentUris; 28import android.content.ContentValues; 29import android.content.Context; 30import android.content.Intent; 31import android.content.res.Resources; 32import android.content.res.TypedArray; 33import android.content.res.XmlResourceParser; 34import android.database.Cursor; 35import android.net.Uri; 36import android.os.Bundle; 37import android.os.IBinder; 38import android.os.RemoteException; 39import android.provider.CalendarContract; 40import android.provider.CalendarContract.Calendars; 41import android.provider.CalendarContract.SyncState; 42import android.provider.ContactsContract; 43import android.provider.SyncStateContract; 44 45import com.android.email.R; 46import com.android.emailcommon.Api; 47import com.android.emailcommon.Logging; 48import com.android.emailcommon.provider.Account; 49import com.android.emailcommon.provider.EmailContent; 50import com.android.emailcommon.provider.EmailContent.AccountColumns; 51import com.android.emailcommon.provider.HostAuth; 52import com.android.emailcommon.service.EmailServiceProxy; 53import com.android.emailcommon.service.IEmailService; 54import com.android.emailcommon.service.IEmailServiceCallback; 55import com.android.emailcommon.service.SearchParams; 56import com.android.emailcommon.service.SyncWindow; 57import com.android.mail.utils.LogUtils; 58import com.google.common.collect.ImmutableMap; 59 60import org.xmlpull.v1.XmlPullParserException; 61 62import java.io.IOException; 63import java.util.Collection; 64import java.util.Map; 65 66/** 67 * Utility functions for EmailService support. 68 */ 69public class EmailServiceUtils { 70 private static Map<String, EmailServiceInfo> sServiceMap = null; 71 72 /** 73 * Starts an EmailService by protocol 74 */ 75 public static void startService(Context context, String protocol) { 76 EmailServiceInfo info = getServiceInfo(context, protocol); 77 if (info != null && info.intentAction != null) { 78 final Intent serviceIntent = getServiceIntent(info); 79 context.startService(serviceIntent); 80 } 81 } 82 83 /** 84 * Starts all remote services 85 */ 86 public static void startRemoteServices(Context context) { 87 for (EmailServiceInfo info: getServiceInfoList(context)) { 88 if (info.intentAction != null) { 89 final Intent serviceIntent = getServiceIntent(info); 90 context.startService(serviceIntent); 91 } 92 } 93 } 94 95 /** 96 * Returns whether or not remote services are present on device 97 */ 98 public static boolean areRemoteServicesInstalled(Context context) { 99 for (EmailServiceInfo info: getServiceInfoList(context)) { 100 if (info.intentAction != null) { 101 return true; 102 } 103 } 104 return false; 105 } 106 107 /** 108 * Starts all remote services 109 */ 110 public static void setRemoteServicesLogging(Context context, int debugBits) { 111 for (EmailServiceInfo info: getServiceInfoList(context)) { 112 if (info.intentAction != null) { 113 EmailServiceProxy service = 114 EmailServiceUtils.getService(context, info.protocol); 115 if (service != null) { 116 try { 117 service.setLogging(debugBits); 118 } catch (RemoteException e) { 119 // Move along, nothing to see 120 } 121 } 122 } 123 } 124 } 125 126 /** 127 * Determine if the EmailService is available 128 */ 129 public static boolean isServiceAvailable(Context context, String protocol) { 130 EmailServiceInfo info = getServiceInfo(context, protocol); 131 if (info == null) return false; 132 if (info.klass != null) return true; 133 final Intent serviceIntent = getServiceIntent(info); 134 return new EmailServiceProxy(context, serviceIntent).test(); 135 } 136 137 private static Intent getServiceIntent(EmailServiceInfo info) { 138 final Intent serviceIntent = new Intent(info.intentAction); 139 serviceIntent.setPackage(info.intentPackage); 140 return serviceIntent; 141 } 142 143 /** 144 * For a given account id, return a service proxy if applicable, or null. 145 * 146 * @param accountId the message of interest 147 * @result service proxy, or null if n/a 148 */ 149 public static EmailServiceProxy getServiceForAccount(Context context, long accountId) { 150 return getService(context, Account.getProtocol(context, accountId)); 151 } 152 153 /** 154 * Holder of service information (currently just name and class/intent); if there is a class 155 * member, this is a (local, i.e. same process) service; otherwise, this is a remote service 156 */ 157 public static class EmailServiceInfo { 158 public String protocol; 159 public String name; 160 public String accountType; 161 Class<? extends Service> klass; 162 String intentAction; 163 String intentPackage; 164 public int port; 165 public int portSsl; 166 public boolean defaultSsl; 167 public boolean offerTls; 168 public boolean offerCerts; 169 public boolean usesSmtp; 170 public boolean offerLocalDeletes; 171 public int defaultLocalDeletes; 172 public boolean offerPrefix; 173 public boolean usesAutodiscover; 174 public boolean offerLookback; 175 public int defaultLookback; 176 public boolean syncChanges; 177 public boolean syncContacts; 178 public boolean syncCalendar; 179 public boolean offerAttachmentPreload; 180 public CharSequence[] syncIntervalStrings; 181 public CharSequence[] syncIntervals; 182 public int defaultSyncInterval; 183 public String inferPrefix; 184 public boolean offerLoadMore; 185 public boolean requiresSetup; 186 public boolean hide; 187 188 @Override 189 public String toString() { 190 StringBuilder sb = new StringBuilder("Protocol: "); 191 sb.append(protocol); 192 sb.append(", "); 193 sb.append(klass != null ? "Local" : "Remote"); 194 sb.append(" , Account Type: "); 195 sb.append(accountType); 196 return sb.toString(); 197 } 198 } 199 200 public static EmailServiceProxy getService(Context context, String protocol) { 201 EmailServiceInfo info = null; 202 // Handle the degenerate case here (account might have been deleted) 203 if (protocol != null) { 204 info = getServiceInfo(context, protocol); 205 } 206 if (info == null) { 207 LogUtils.w(Logging.LOG_TAG, "Returning NullService for " + protocol); 208 return new EmailServiceProxy(context, NullService.class); 209 } else { 210 return getServiceFromInfo(context, info); 211 } 212 } 213 214 public static EmailServiceProxy getServiceFromInfo(Context context, EmailServiceInfo info) { 215 if (info.klass != null) { 216 return new EmailServiceProxy(context, info.klass); 217 } else { 218 final Intent serviceIntent = getServiceIntent(info); 219 return new EmailServiceProxy(context, serviceIntent); 220 } 221 } 222 223 public static EmailServiceInfo getServiceInfoForAccount(Context context, long accountId) { 224 String protocol = Account.getProtocol(context, accountId); 225 return getServiceInfo(context, protocol); 226 } 227 228 public static synchronized EmailServiceInfo getServiceInfo(Context context, String protocol) { 229 if (sServiceMap == null) { 230 findServices(context); 231 } 232 return sServiceMap.get(protocol); 233 } 234 235 public static synchronized Collection<EmailServiceInfo> getServiceInfoList(Context context) { 236 if (sServiceMap == null) { 237 findServices(context); 238 } 239 return sServiceMap.values(); 240 } 241 242 private static void finishAccountManagerBlocker(AccountManagerFuture<?> future) { 243 try { 244 // Note: All of the potential errors are simply logged 245 // here, as there is nothing to actually do about them. 246 future.getResult(); 247 } catch (OperationCanceledException e) { 248 LogUtils.w(Logging.LOG_TAG, e.toString()); 249 } catch (AuthenticatorException e) { 250 LogUtils.w(Logging.LOG_TAG, e.toString()); 251 } catch (IOException e) { 252 LogUtils.w(Logging.LOG_TAG, e.toString()); 253 } 254 } 255 256 /** 257 * Add an account to the AccountManager. 258 * @param context Our {@link Context}. 259 * @param account The {@link Account} we're adding. 260 * @param email Whether the user wants to sync email on this account. 261 * @param calendar Whether the user wants to sync calendar on this account. 262 * @param contacts Whether the user wants to sync contacts on this account. 263 * @param callback A callback for when the AccountManager is done. 264 * @return The result of {@link AccountManager#addAccount}. 265 */ 266 public static AccountManagerFuture<Bundle> setupAccountManagerAccount(final Context context, 267 final Account account, final boolean email, final boolean calendar, 268 final boolean contacts, final AccountManagerCallback<Bundle> callback) { 269 final Bundle options = new Bundle(5); 270 final HostAuth hostAuthRecv = 271 HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv); 272 if (hostAuthRecv == null) { 273 return null; 274 } 275 // Set up username/password 276 options.putString(EasAuthenticatorService.OPTIONS_USERNAME, account.mEmailAddress); 277 options.putString(EasAuthenticatorService.OPTIONS_PASSWORD, hostAuthRecv.mPassword); 278 options.putBoolean(EasAuthenticatorService.OPTIONS_CONTACTS_SYNC_ENABLED, contacts); 279 options.putBoolean(EasAuthenticatorService.OPTIONS_CALENDAR_SYNC_ENABLED, calendar); 280 options.putBoolean(EasAuthenticatorService.OPTIONS_EMAIL_SYNC_ENABLED, email); 281 final EmailServiceInfo info = getServiceInfo(context, hostAuthRecv.mProtocol); 282 return AccountManager.get(context).addAccount(info.accountType, null, null, options, null, 283 callback, null); 284 } 285 286 public static void updateAccountManagerType(Context context, 287 android.accounts.Account amAccount, final Map<String, String> protocolMap) { 288 final ContentResolver resolver = context.getContentResolver(); 289 final Cursor c = resolver.query(Account.CONTENT_URI, Account.CONTENT_PROJECTION, 290 AccountColumns.EMAIL_ADDRESS + "=?", new String[] { amAccount.name }, null); 291 // That's odd, isn't it? 292 if (c == null) return; 293 try { 294 if (c.moveToNext()) { 295 // Get the EmailProvider Account/HostAuth 296 final Account account = new Account(); 297 account.restore(c); 298 final HostAuth hostAuth = 299 HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv); 300 if (hostAuth == null) { 301 return; 302 } 303 304 final String newProtocol = protocolMap.get(hostAuth.mProtocol); 305 if (newProtocol == null) { 306 // This account doesn't need updating. 307 return; 308 } 309 310 LogUtils.w(Logging.LOG_TAG, "Converting " + amAccount.name + " to " 311 + newProtocol); 312 313 final ContentValues accountValues = new ContentValues(); 314 int oldFlags = account.mFlags; 315 316 // Mark the provider account incomplete so it can't get reconciled away 317 account.mFlags |= Account.FLAGS_INCOMPLETE; 318 accountValues.put(AccountColumns.FLAGS, account.mFlags); 319 final Uri accountUri = ContentUris.withAppendedId(Account.CONTENT_URI, account.mId); 320 resolver.update(accountUri, accountValues, null, null); 321 322 // Change the HostAuth to reference the new protocol; this has to be done before 323 // trying to create the AccountManager account (below) 324 final ContentValues hostValues = new ContentValues(); 325 hostValues.put(HostAuth.PROTOCOL, newProtocol); 326 resolver.update(ContentUris.withAppendedId(HostAuth.CONTENT_URI, hostAuth.mId), 327 hostValues, null, null); 328 LogUtils.w(Logging.LOG_TAG, "Updated HostAuths"); 329 330 try { 331 // Get current settings for the existing AccountManager account 332 boolean email = ContentResolver.getSyncAutomatically(amAccount, 333 EmailContent.AUTHORITY); 334 if (!email) { 335 // Try our old provider name 336 email = ContentResolver.getSyncAutomatically(amAccount, 337 "com.android.email.provider"); 338 } 339 final boolean contacts = ContentResolver.getSyncAutomatically(amAccount, 340 ContactsContract.AUTHORITY); 341 final boolean calendar = ContentResolver.getSyncAutomatically(amAccount, 342 CalendarContract.AUTHORITY); 343 LogUtils.w(Logging.LOG_TAG, "Email: " + email + ", Contacts: " + contacts + "," 344 + " Calendar: " + calendar); 345 346 // Get sync keys for calendar/contacts 347 final String amName = amAccount.name; 348 final String oldType = amAccount.type; 349 ContentProviderClient client = context.getContentResolver() 350 .acquireContentProviderClient(CalendarContract.CONTENT_URI); 351 byte[] calendarSyncKey = null; 352 try { 353 calendarSyncKey = SyncStateContract.Helpers.get(client, 354 asCalendarSyncAdapter(SyncState.CONTENT_URI, amName, oldType), 355 new android.accounts.Account(amName, oldType)); 356 } catch (RemoteException e) { 357 LogUtils.w(Logging.LOG_TAG, "Get calendar key FAILED"); 358 } finally { 359 client.release(); 360 } 361 client = context.getContentResolver() 362 .acquireContentProviderClient(ContactsContract.AUTHORITY_URI); 363 byte[] contactsSyncKey = null; 364 try { 365 contactsSyncKey = SyncStateContract.Helpers.get(client, 366 ContactsContract.SyncState.CONTENT_URI, 367 new android.accounts.Account(amName, oldType)); 368 } catch (RemoteException e) { 369 LogUtils.w(Logging.LOG_TAG, "Get contacts key FAILED"); 370 } finally { 371 client.release(); 372 } 373 if (calendarSyncKey != null) { 374 LogUtils.w(Logging.LOG_TAG, "Got calendar key: " 375 + new String(calendarSyncKey)); 376 } 377 if (contactsSyncKey != null) { 378 LogUtils.w(Logging.LOG_TAG, "Got contacts key: " 379 + new String(contactsSyncKey)); 380 } 381 382 // Set up a new AccountManager account with new type and old settings 383 AccountManagerFuture<?> amFuture = setupAccountManagerAccount(context, account, 384 email, calendar, contacts, null); 385 finishAccountManagerBlocker(amFuture); 386 LogUtils.w(Logging.LOG_TAG, "Created new AccountManager account"); 387 388 // Delete the AccountManager account 389 amFuture = AccountManager.get(context) 390 .removeAccount(amAccount, null, null); 391 finishAccountManagerBlocker(amFuture); 392 LogUtils.w(Logging.LOG_TAG, "Deleted old AccountManager account"); 393 394 // Restore sync keys for contacts/calendar 395 // TODO: Clean up how we determine the type. 396 final String accountType = protocolMap.get(hostAuth.mProtocol + "_type"); 397 if (accountType != null && 398 calendarSyncKey != null && calendarSyncKey.length != 0) { 399 client = context.getContentResolver() 400 .acquireContentProviderClient(CalendarContract.CONTENT_URI); 401 try { 402 SyncStateContract.Helpers.set(client, 403 asCalendarSyncAdapter(SyncState.CONTENT_URI, amName, 404 accountType), 405 new android.accounts.Account(amName, accountType), 406 calendarSyncKey); 407 LogUtils.w(Logging.LOG_TAG, "Set calendar key..."); 408 } catch (RemoteException e) { 409 LogUtils.w(Logging.LOG_TAG, "Set calendar key FAILED"); 410 } finally { 411 client.release(); 412 } 413 } 414 if (accountType != null && 415 contactsSyncKey != null && contactsSyncKey.length != 0) { 416 client = context.getContentResolver() 417 .acquireContentProviderClient(ContactsContract.AUTHORITY_URI); 418 try { 419 SyncStateContract.Helpers.set(client, 420 ContactsContract.SyncState.CONTENT_URI, 421 new android.accounts.Account(amName, accountType), 422 contactsSyncKey); 423 LogUtils.w(Logging.LOG_TAG, "Set contacts key..."); 424 } catch (RemoteException e) { 425 LogUtils.w(Logging.LOG_TAG, "Set contacts key FAILED"); 426 } 427 } 428 429 // That's all folks! 430 LogUtils.w(Logging.LOG_TAG, "Account update completed."); 431 } finally { 432 // Clear the incomplete flag on the provider account 433 accountValues.put(AccountColumns.FLAGS, oldFlags); 434 resolver.update(accountUri, accountValues, null, null); 435 LogUtils.w(Logging.LOG_TAG, "[Incomplete flag cleared]"); 436 } 437 } 438 } finally { 439 c.close(); 440 } 441 } 442 443 /** 444 * Parse services.xml file to find our available email services 445 */ 446 @SuppressWarnings("unchecked") 447 private static synchronized void findServices(final Context context) { 448 if (sServiceMap != null) { 449 return; 450 } 451 452 final ImmutableMap.Builder<String, EmailServiceInfo> builder = ImmutableMap.builder(); 453 454 try { 455 final Resources res = context.getResources(); 456 final XmlResourceParser xml = res.getXml(R.xml.services); 457 int xmlEventType; 458 // walk through senders.xml file. 459 while ((xmlEventType = xml.next()) != XmlResourceParser.END_DOCUMENT) { 460 if (xmlEventType == XmlResourceParser.START_TAG && 461 "emailservice".equals(xml.getName())) { 462 final EmailServiceInfo info = new EmailServiceInfo(); 463 final TypedArray ta = res.obtainAttributes(xml, R.styleable.EmailServiceInfo); 464 info.protocol = ta.getString(R.styleable.EmailServiceInfo_protocol); 465 info.accountType = ta.getString(R.styleable.EmailServiceInfo_accountType); 466 info.name = ta.getString(R.styleable.EmailServiceInfo_name); 467 info.hide = ta.getBoolean(R.styleable.EmailServiceInfo_hide, false); 468 final String klass = ta.getString(R.styleable.EmailServiceInfo_serviceClass); 469 info.intentAction = ta.getString(R.styleable.EmailServiceInfo_intent); 470 info.intentPackage = ta.getString(R.styleable.EmailServiceInfo_intentPackage); 471 info.defaultSsl = ta.getBoolean(R.styleable.EmailServiceInfo_defaultSsl, false); 472 info.port = ta.getInteger(R.styleable.EmailServiceInfo_port, 0); 473 info.portSsl = ta.getInteger(R.styleable.EmailServiceInfo_portSsl, 0); 474 info.offerTls = ta.getBoolean(R.styleable.EmailServiceInfo_offerTls, false); 475 info.offerCerts = ta.getBoolean(R.styleable.EmailServiceInfo_offerCerts, false); 476 info.offerLocalDeletes = 477 ta.getBoolean(R.styleable.EmailServiceInfo_offerLocalDeletes, false); 478 info.defaultLocalDeletes = 479 ta.getInteger(R.styleable.EmailServiceInfo_defaultLocalDeletes, 480 Account.DELETE_POLICY_ON_DELETE); 481 info.offerPrefix = 482 ta.getBoolean(R.styleable.EmailServiceInfo_offerPrefix, false); 483 info.usesSmtp = ta.getBoolean(R.styleable.EmailServiceInfo_usesSmtp, false); 484 info.usesAutodiscover = 485 ta.getBoolean(R.styleable.EmailServiceInfo_usesAutodiscover, false); 486 info.offerLookback = 487 ta.getBoolean(R.styleable.EmailServiceInfo_offerLookback, false); 488 info.defaultLookback = 489 ta.getInteger(R.styleable.EmailServiceInfo_defaultLookback, 490 SyncWindow.SYNC_WINDOW_3_DAYS); 491 info.syncChanges = 492 ta.getBoolean(R.styleable.EmailServiceInfo_syncChanges, false); 493 info.syncContacts = 494 ta.getBoolean(R.styleable.EmailServiceInfo_syncContacts, false); 495 info.syncCalendar = 496 ta.getBoolean(R.styleable.EmailServiceInfo_syncCalendar, false); 497 info.offerAttachmentPreload = 498 ta.getBoolean(R.styleable.EmailServiceInfo_offerAttachmentPreload, false); 499 info.syncIntervalStrings = 500 ta.getTextArray(R.styleable.EmailServiceInfo_syncIntervalStrings); 501 info.syncIntervals = 502 ta.getTextArray(R.styleable.EmailServiceInfo_syncIntervals); 503 info.defaultSyncInterval = 504 ta.getInteger(R.styleable.EmailServiceInfo_defaultSyncInterval, 15); 505 info.inferPrefix = ta.getString(R.styleable.EmailServiceInfo_inferPrefix); 506 info.offerLoadMore = 507 ta.getBoolean(R.styleable.EmailServiceInfo_offerLoadMore, false); 508 info.requiresSetup = 509 ta.getBoolean(R.styleable.EmailServiceInfo_requiresSetup, false); 510 511 // Must have either "class" (local) or "intent" (remote) 512 if (klass != null) { 513 try { 514 info.klass = (Class<? extends Service>) Class.forName(klass); 515 } catch (ClassNotFoundException e) { 516 throw new IllegalStateException( 517 "Class not found in service descriptor: " + klass); 518 } 519 } 520 if (info.klass == null && info.intentAction == null) { 521 throw new IllegalStateException( 522 "No class or intent action specified in service descriptor"); 523 } 524 if (info.klass != null && info.intentAction != null) { 525 throw new IllegalStateException( 526 "Both class and intent action specified in service descriptor"); 527 } 528 builder.put(info.protocol, info); 529 } 530 } 531 } catch (XmlPullParserException e) { 532 // ignore 533 } catch (IOException e) { 534 // ignore 535 } 536 sServiceMap = builder.build(); 537 } 538 539 private static Uri asCalendarSyncAdapter(Uri uri, String account, String accountType) { 540 return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") 541 .appendQueryParameter(Calendars.ACCOUNT_NAME, account) 542 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); 543 } 544 545 /** 546 * A no-op service that can be returned for non-existent/null protocols 547 */ 548 class NullService implements IEmailService { 549 @Override 550 public IBinder asBinder() { 551 return null; 552 } 553 554 @Override 555 public Bundle validate(HostAuth hostauth) throws RemoteException { 556 return null; 557 } 558 559 @Override 560 public void startSync(long mailboxId, boolean userRequest, int deltaMessageCount) 561 throws RemoteException { 562 } 563 564 @Override 565 public void stopSync(long mailboxId) throws RemoteException { 566 } 567 568 @Override 569 public void loadMore(long messageId) throws RemoteException { 570 } 571 572 @Override 573 public void loadAttachment(final IEmailServiceCallback cb, final long attachmentId, 574 final boolean background) throws RemoteException { 575 } 576 577 @Override 578 public void updateFolderList(long accountId) throws RemoteException { 579 } 580 581 @Override 582 public boolean createFolder(long accountId, String name) throws RemoteException { 583 return false; 584 } 585 586 @Override 587 public boolean deleteFolder(long accountId, String name) throws RemoteException { 588 return false; 589 } 590 591 @Override 592 public boolean renameFolder(long accountId, String oldName, String newName) 593 throws RemoteException { 594 return false; 595 } 596 597 @Override 598 public void setLogging(int on) throws RemoteException { 599 } 600 601 @Override 602 public void hostChanged(long accountId) throws RemoteException { 603 } 604 605 @Override 606 public Bundle autoDiscover(String userName, String password) throws RemoteException { 607 return null; 608 } 609 610 @Override 611 public void sendMeetingResponse(long messageId, int response) throws RemoteException { 612 } 613 614 @Override 615 public void deleteAccountPIMData(final String emailAddress) throws RemoteException { 616 } 617 618 @Override 619 public int getApiLevel() throws RemoteException { 620 return Api.LEVEL; 621 } 622 623 @Override 624 public int searchMessages(long accountId, SearchParams params, long destMailboxId) 625 throws RemoteException { 626 return 0; 627 } 628 629 @Override 630 public void sendMail(long accountId) throws RemoteException { 631 } 632 633 @Override 634 public void serviceUpdated(String emailAddress) throws RemoteException { 635 } 636 637 @Override 638 public int getCapabilities(Account acct) throws RemoteException { 639 return 0; 640 } 641 } 642} 643