ImsPhoneConnection.java revision 04b0216a5f64b70ec4b06f129946fff4ef359878
1/* 2 * Copyright (C) 2013 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.internal.telephony.imsphone; 18 19import android.content.Context; 20import android.net.Uri; 21import android.os.AsyncResult; 22import android.os.Handler; 23import android.os.Looper; 24import android.os.Message; 25import android.os.PowerManager; 26import android.os.Registrant; 27import android.os.SystemClock; 28import android.telecom.Log; 29import android.telephony.DisconnectCause; 30import android.telephony.PhoneNumberUtils; 31import android.telephony.Rlog; 32 33import com.android.ims.ImsException; 34import com.android.ims.ImsStreamMediaProfile; 35import com.android.internal.telephony.CallStateException; 36import com.android.internal.telephony.Connection; 37import com.android.internal.telephony.Phone; 38import com.android.internal.telephony.PhoneConstants; 39import com.android.internal.telephony.UUSInfo; 40 41import com.android.ims.ImsCall; 42import com.android.ims.ImsCallProfile; 43 44/** 45 * {@hide} 46 */ 47public class ImsPhoneConnection extends Connection { 48 private static final String LOG_TAG = "ImsPhoneConnection"; 49 private static final boolean DBG = true; 50 51 //***** Instance Variables 52 53 private ImsPhoneCallTracker mOwner; 54 private ImsPhoneCall mParent; 55 private ImsCall mImsCall; 56 57 private String mPostDialString; // outgoing calls only 58 private boolean mDisconnected; 59 60 /* 61 int mIndex; // index in ImsPhoneCallTracker.connections[], -1 if unassigned 62 // The GSM index is 1 + this 63 */ 64 65 /* 66 * These time/timespan values are based on System.currentTimeMillis(), 67 * i.e., "wall clock" time. 68 */ 69 private long mDisconnectTime; 70 71 private int mNextPostDialChar; // index into postDialString 72 73 private int mCause = DisconnectCause.NOT_DISCONNECTED; 74 private PostDialState mPostDialState = PostDialState.NOT_STARTED; 75 private UUSInfo mUusInfo; 76 private Handler mHandler; 77 78 private PowerManager.WakeLock mPartialWakeLock; 79 80 // The cached connect time of the connection when it turns into a conference. 81 private long mConferenceConnectTime = 0; 82 83 //***** Event Constants 84 private static final int EVENT_DTMF_DONE = 1; 85 private static final int EVENT_PAUSE_DONE = 2; 86 private static final int EVENT_NEXT_POST_DIAL = 3; 87 private static final int EVENT_WAKE_LOCK_TIMEOUT = 4; 88 89 //***** Constants 90 private static final int PAUSE_DELAY_MILLIS = 3 * 1000; 91 private static final int WAKE_LOCK_TIMEOUT_MILLIS = 60*1000; 92 93 //***** Inner Classes 94 95 class MyHandler extends Handler { 96 MyHandler(Looper l) {super(l);} 97 98 @Override 99 public void 100 handleMessage(Message msg) { 101 102 switch (msg.what) { 103 case EVENT_NEXT_POST_DIAL: 104 case EVENT_DTMF_DONE: 105 case EVENT_PAUSE_DONE: 106 processNextPostDialChar(); 107 break; 108 case EVENT_WAKE_LOCK_TIMEOUT: 109 releaseWakeLock(); 110 break; 111 } 112 } 113 } 114 115 //***** Constructors 116 117 /** This is probably an MT call */ 118 /*package*/ 119 ImsPhoneConnection(Context context, ImsCall imsCall, ImsPhoneCallTracker ct, ImsPhoneCall parent) { 120 createWakeLock(context); 121 acquireWakeLock(); 122 123 mOwner = ct; 124 mHandler = new MyHandler(mOwner.getLooper()); 125 mImsCall = imsCall; 126 127 if ((imsCall != null) && (imsCall.getCallProfile() != null)) { 128 mAddress = imsCall.getCallProfile().getCallExtra(ImsCallProfile.EXTRA_OI); 129 mCnapName = imsCall.getCallProfile().getCallExtra(ImsCallProfile.EXTRA_CNA); 130 mNumberPresentation = ImsCallProfile.OIRToPresentation( 131 imsCall.getCallProfile().getCallExtraInt(ImsCallProfile.EXTRA_OIR)); 132 mCnapNamePresentation = ImsCallProfile.OIRToPresentation( 133 imsCall.getCallProfile().getCallExtraInt(ImsCallProfile.EXTRA_CNAP)); 134 135 updateMediaCapabilities(imsCall); 136 } else { 137 mNumberPresentation = PhoneConstants.PRESENTATION_UNKNOWN; 138 mCnapNamePresentation = PhoneConstants.PRESENTATION_UNKNOWN; 139 } 140 141 mIsIncoming = true; 142 mCreateTime = System.currentTimeMillis(); 143 mUusInfo = null; 144 145 //mIndex = index; 146 147 updateWifiState(); 148 149 mParent = parent; 150 mParent.attach(this, ImsPhoneCall.State.INCOMING); 151 } 152 153 /** This is an MO call, created when dialing */ 154 /*package*/ 155 ImsPhoneConnection(Context context, String dialString, ImsPhoneCallTracker ct, ImsPhoneCall parent) { 156 createWakeLock(context); 157 acquireWakeLock(); 158 159 mOwner = ct; 160 mHandler = new MyHandler(mOwner.getLooper()); 161 162 mDialString = dialString; 163 164 mAddress = PhoneNumberUtils.extractNetworkPortionAlt(dialString); 165 mPostDialString = PhoneNumberUtils.extractPostDialPortion(dialString); 166 167 //mIndex = -1; 168 169 mIsIncoming = false; 170 mCnapName = null; 171 mCnapNamePresentation = PhoneConstants.PRESENTATION_ALLOWED; 172 mNumberPresentation = PhoneConstants.PRESENTATION_ALLOWED; 173 mCreateTime = System.currentTimeMillis(); 174 175 mParent = parent; 176 parent.attachFake(this, ImsPhoneCall.State.DIALING); 177 } 178 179 public void dispose() { 180 } 181 182 static boolean 183 equalsHandlesNulls (Object a, Object b) { 184 return (a == null) ? (b == null) : a.equals (b); 185 } 186 187 @Override 188 public String getOrigDialString(){ 189 return mDialString; 190 } 191 192 @Override 193 public ImsPhoneCall getCall() { 194 return mParent; 195 } 196 197 @Override 198 public long getDisconnectTime() { 199 return mDisconnectTime; 200 } 201 202 @Override 203 public long getHoldingStartTime() { 204 return mHoldingStartTime; 205 } 206 207 @Override 208 public long getHoldDurationMillis() { 209 if (getState() != ImsPhoneCall.State.HOLDING) { 210 // If not holding, return 0 211 return 0; 212 } else { 213 return SystemClock.elapsedRealtime() - mHoldingStartTime; 214 } 215 } 216 217 @Override 218 public int getDisconnectCause() { 219 return mCause; 220 } 221 222 public void setDisconnectCause(int cause) { 223 mCause = cause; 224 } 225 226 public ImsPhoneCallTracker getOwner () { 227 return mOwner; 228 } 229 230 @Override 231 public ImsPhoneCall.State getState() { 232 if (mDisconnected) { 233 return ImsPhoneCall.State.DISCONNECTED; 234 } else { 235 return super.getState(); 236 } 237 } 238 239 @Override 240 public void hangup() throws CallStateException { 241 if (!mDisconnected) { 242 mOwner.hangup(this); 243 } else { 244 throw new CallStateException ("disconnected"); 245 } 246 } 247 248 @Override 249 public void separate() throws CallStateException { 250 throw new CallStateException ("not supported"); 251 } 252 253 @Override 254 public PostDialState getPostDialState() { 255 return mPostDialState; 256 } 257 258 @Override 259 public void proceedAfterWaitChar() { 260 if (mPostDialState != PostDialState.WAIT) { 261 Rlog.w(LOG_TAG, "ImsPhoneConnection.proceedAfterWaitChar(): Expected " 262 + "getPostDialState() to be WAIT but was " + mPostDialState); 263 return; 264 } 265 266 setPostDialState(PostDialState.STARTED); 267 268 processNextPostDialChar(); 269 } 270 271 @Override 272 public void proceedAfterWildChar(String str) { 273 if (mPostDialState != PostDialState.WILD) { 274 Rlog.w(LOG_TAG, "ImsPhoneConnection.proceedAfterWaitChar(): Expected " 275 + "getPostDialState() to be WILD but was " + mPostDialState); 276 return; 277 } 278 279 setPostDialState(PostDialState.STARTED); 280 281 // make a new postDialString, with the wild char replacement string 282 // at the beginning, followed by the remaining postDialString. 283 284 StringBuilder buf = new StringBuilder(str); 285 buf.append(mPostDialString.substring(mNextPostDialChar)); 286 mPostDialString = buf.toString(); 287 mNextPostDialChar = 0; 288 if (Phone.DEBUG_PHONE) { 289 Rlog.d(LOG_TAG, "proceedAfterWildChar: new postDialString is " + 290 mPostDialString); 291 } 292 293 processNextPostDialChar(); 294 } 295 296 @Override 297 public void cancelPostDial() { 298 setPostDialState(PostDialState.CANCELLED); 299 } 300 301 /** 302 * Called when this Connection is being hung up locally (eg, user pressed "end") 303 */ 304 void 305 onHangupLocal() { 306 mCause = DisconnectCause.LOCAL; 307 } 308 309 /** Called when the connection has been disconnected */ 310 public boolean 311 onDisconnect(int cause) { 312 Rlog.d(LOG_TAG, "onDisconnect: cause=" + cause); 313 if (mCause != DisconnectCause.LOCAL) mCause = cause; 314 return onDisconnect(); 315 } 316 317 /*package*/ boolean 318 onDisconnect() { 319 boolean changed = false; 320 321 if (!mDisconnected) { 322 //mIndex = -1; 323 324 mDisconnectTime = System.currentTimeMillis(); 325 mDuration = SystemClock.elapsedRealtime() - mConnectTimeReal; 326 mDisconnected = true; 327 328 mOwner.mPhone.notifyDisconnect(this); 329 330 if (mParent != null) { 331 changed = mParent.connectionDisconnected(this); 332 } else { 333 Rlog.d(LOG_TAG, "onDisconnect: no parent"); 334 } 335 if (mImsCall != null) mImsCall.close(); 336 mImsCall = null; 337 } 338 releaseWakeLock(); 339 return changed; 340 } 341 342 /** 343 * An incoming or outgoing call has connected 344 */ 345 void 346 onConnectedInOrOut() { 347 mConnectTime = System.currentTimeMillis(); 348 mConnectTimeReal = SystemClock.elapsedRealtime(); 349 mDuration = 0; 350 351 if (Phone.DEBUG_PHONE) { 352 Rlog.d(LOG_TAG, "onConnectedInOrOut: connectTime=" + mConnectTime); 353 } 354 355 if (!mIsIncoming) { 356 // outgoing calls only 357 processNextPostDialChar(); 358 } 359 releaseWakeLock(); 360 } 361 362 /*package*/ void 363 onStartedHolding() { 364 mHoldingStartTime = SystemClock.elapsedRealtime(); 365 } 366 /** 367 * Performs the appropriate action for a post-dial char, but does not 368 * notify application. returns false if the character is invalid and 369 * should be ignored 370 */ 371 private boolean 372 processPostDialChar(char c) { 373 if (PhoneNumberUtils.is12Key(c)) { 374 mOwner.sendDtmf(c, mHandler.obtainMessage(EVENT_DTMF_DONE)); 375 } else if (c == PhoneNumberUtils.PAUSE) { 376 // From TS 22.101: 377 // It continues... 378 // Upon the called party answering the UE shall send the DTMF digits 379 // automatically to the network after a delay of 3 seconds( 20 ). 380 // The digits shall be sent according to the procedures and timing 381 // specified in 3GPP TS 24.008 [13]. The first occurrence of the 382 // "DTMF Control Digits Separator" shall be used by the ME to 383 // distinguish between the addressing digits (i.e. the phone number) 384 // and the DTMF digits. Upon subsequent occurrences of the 385 // separator, 386 // the UE shall pause again for 3 seconds ( 20 ) before sending 387 // any further DTMF digits. 388 mHandler.sendMessageDelayed(mHandler.obtainMessage(EVENT_PAUSE_DONE), 389 PAUSE_DELAY_MILLIS); 390 } else if (c == PhoneNumberUtils.WAIT) { 391 setPostDialState(PostDialState.WAIT); 392 } else if (c == PhoneNumberUtils.WILD) { 393 setPostDialState(PostDialState.WILD); 394 } else { 395 return false; 396 } 397 398 return true; 399 } 400 401 @Override 402 public String 403 getRemainingPostDialString() { 404 if (mPostDialState == PostDialState.CANCELLED 405 || mPostDialState == PostDialState.COMPLETE 406 || mPostDialString == null 407 || mPostDialString.length() <= mNextPostDialChar 408 ) { 409 return ""; 410 } 411 412 return mPostDialString.substring(mNextPostDialChar); 413 } 414 415 @Override 416 protected void finalize() 417 { 418 releaseWakeLock(); 419 } 420 421 private void 422 processNextPostDialChar() { 423 char c = 0; 424 Registrant postDialHandler; 425 426 if (mPostDialState == PostDialState.CANCELLED) { 427 //Rlog.d(LOG_TAG, "##### processNextPostDialChar: postDialState == CANCELLED, bail"); 428 return; 429 } 430 431 if (mPostDialString == null || mPostDialString.length() <= mNextPostDialChar) { 432 setPostDialState(PostDialState.COMPLETE); 433 434 // notifyMessage.arg1 is 0 on complete 435 c = 0; 436 } else { 437 boolean isValid; 438 439 setPostDialState(PostDialState.STARTED); 440 441 c = mPostDialString.charAt(mNextPostDialChar++); 442 443 isValid = processPostDialChar(c); 444 445 if (!isValid) { 446 // Will call processNextPostDialChar 447 mHandler.obtainMessage(EVENT_NEXT_POST_DIAL).sendToTarget(); 448 // Don't notify application 449 Rlog.e(LOG_TAG, "processNextPostDialChar: c=" + c + " isn't valid!"); 450 return; 451 } 452 } 453 454 notifyPostDialListenersNextChar(c); 455 456 // TODO: remove the following code since the handler no longer executes anything. 457 postDialHandler = mOwner.mPhone.mPostDialHandler; 458 459 Message notifyMessage; 460 461 if (postDialHandler != null 462 && (notifyMessage = postDialHandler.messageForRegistrant()) != null) { 463 // The AsyncResult.result is the Connection object 464 PostDialState state = mPostDialState; 465 AsyncResult ar = AsyncResult.forMessage(notifyMessage); 466 ar.result = this; 467 ar.userObj = state; 468 469 // arg1 is the character that was/is being processed 470 notifyMessage.arg1 = c; 471 472 //Rlog.v(LOG_TAG, "##### processNextPostDialChar: send msg to postDialHandler, arg1=" + c); 473 notifyMessage.sendToTarget(); 474 } 475 } 476 477 /** 478 * Set post dial state and acquire wake lock while switching to "started" 479 * state, the wake lock will be released if state switches out of "started" 480 * state or after WAKE_LOCK_TIMEOUT_MILLIS. 481 * @param s new PostDialState 482 */ 483 private void setPostDialState(PostDialState s) { 484 if (mPostDialState != PostDialState.STARTED 485 && s == PostDialState.STARTED) { 486 acquireWakeLock(); 487 Message msg = mHandler.obtainMessage(EVENT_WAKE_LOCK_TIMEOUT); 488 mHandler.sendMessageDelayed(msg, WAKE_LOCK_TIMEOUT_MILLIS); 489 } else if (mPostDialState == PostDialState.STARTED 490 && s != PostDialState.STARTED) { 491 mHandler.removeMessages(EVENT_WAKE_LOCK_TIMEOUT); 492 releaseWakeLock(); 493 } 494 mPostDialState = s; 495 notifyPostDialListeners(); 496 } 497 498 private void 499 createWakeLock(Context context) { 500 PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); 501 mPartialWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOG_TAG); 502 } 503 504 private void 505 acquireWakeLock() { 506 Rlog.d(LOG_TAG, "acquireWakeLock"); 507 mPartialWakeLock.acquire(); 508 } 509 510 void 511 releaseWakeLock() { 512 synchronized(mPartialWakeLock) { 513 if (mPartialWakeLock.isHeld()) { 514 Rlog.d(LOG_TAG, "releaseWakeLock"); 515 mPartialWakeLock.release(); 516 } 517 } 518 } 519 520 @Override 521 public int getNumberPresentation() { 522 return mNumberPresentation; 523 } 524 525 @Override 526 public UUSInfo getUUSInfo() { 527 return mUusInfo; 528 } 529 530 @Override 531 public Connection getOrigConnection() { 532 return null; 533 } 534 535 @Override 536 public boolean isMultiparty() { 537 return mImsCall != null && mImsCall.isMultiparty(); 538 } 539 540 /*package*/ ImsCall getImsCall() { 541 return mImsCall; 542 } 543 544 /*package*/ void setImsCall(ImsCall imsCall) { 545 mImsCall = imsCall; 546 } 547 548 /*package*/ void changeParent(ImsPhoneCall parent) { 549 mParent = parent; 550 } 551 552 /** 553 * @return {@code true} if the {@link ImsPhoneConnection} or its media capabilities have been 554 * changed, and {@code false} otherwise. 555 */ 556 /*package*/ boolean update(ImsCall imsCall, ImsPhoneCall.State state) { 557 if (state == ImsPhoneCall.State.ACTIVE) { 558 if (mParent.getState().isRinging() || mParent.getState().isDialing()) { 559 onConnectedInOrOut(); 560 } 561 562 if (mParent.getState().isRinging() || mParent == mOwner.mBackgroundCall) { 563 //mForegroundCall should be IDLE 564 //when accepting WAITING call 565 //before accept WAITING call, 566 //the ACTIVE call should be held ahead 567 mParent.detach(this); 568 mParent = mOwner.mForegroundCall; 569 mParent.attach(this); 570 } 571 } else if (state == ImsPhoneCall.State.HOLDING) { 572 onStartedHolding(); 573 } 574 575 boolean updateParent = mParent.update(this, imsCall, state); 576 boolean updateMediaCapabilities = updateMediaCapabilities(imsCall); 577 boolean updateWifiState = updateWifiState(); 578 return updateParent || updateMediaCapabilities || updateWifiState; 579 } 580 581 @Override 582 public int getPreciseDisconnectCause() { 583 return 0; 584 } 585 586 /** 587 * Notifies this Connection of a request to disconnect a participant of the conference managed 588 * by the connection. 589 * 590 * @param endpoint the {@link android.net.Uri} of the participant to disconnect. 591 */ 592 @Override 593 public void onDisconnectConferenceParticipant(Uri endpoint) { 594 ImsCall imsCall = getImsCall(); 595 if (imsCall == null) { 596 return; 597 } 598 try { 599 imsCall.removeParticipants(new String[]{endpoint.toString()}); 600 } catch (ImsException e) { 601 // No session in place -- no change 602 Rlog.e(LOG_TAG, "onDisconnectConferenceParticipant: no session in place. "+ 603 "Failed to disconnect endpoint = " + endpoint); 604 } 605 } 606 607 /** 608 * Sets the conference connect time. Used when an {@code ImsConference} is created to out of 609 * this phone connection. 610 * 611 * @param conferenceConnectTime The conference connect time. 612 */ 613 public void setConferenceConnectTime(long conferenceConnectTime) { 614 mConferenceConnectTime = conferenceConnectTime; 615 } 616 617 /** 618 * @return The conference connect time. 619 */ 620 public long getConferenceConnectTime() { 621 return mConferenceConnectTime; 622 } 623 624 /** 625 * Check for a change in the video capabilities and audio quality for the {@link ImsCall}, and 626 * update the {@link ImsPhoneConnection} with this information. 627 * 628 * @param imsCall The call to check for changes in media capabilities. 629 * @return Whether the media capabilities have been changed. 630 */ 631 private boolean updateMediaCapabilities(ImsCall imsCall) { 632 if (imsCall == null) { 633 return false; 634 } 635 636 boolean changed = false; 637 638 try { 639 // The actual call profile (negotiated between local and peer). 640 ImsCallProfile negotiatedCallProfile = imsCall.getCallProfile(); 641 // The capabilities of the local device. 642 ImsCallProfile localCallProfile = imsCall.getLocalCallProfile(); 643 // The capabilities of the peer device. 644 ImsCallProfile remoteCallProfile = imsCall.getRemoteCallProfile(); 645 646 if (negotiatedCallProfile != null) { 647 int callType = negotiatedCallProfile.mCallType; 648 649 int newVideoState = ImsCallProfile.getVideoStateFromCallType(callType); 650 if (getVideoState() != newVideoState) { 651 setVideoState(newVideoState); 652 changed = true; 653 } 654 } 655 656 if (localCallProfile != null) { 657 int callType = localCallProfile.mCallType; 658 659 boolean newLocalVideoCapable = callType == ImsCallProfile.CALL_TYPE_VT; 660 if (isLocalVideoCapable() != newLocalVideoCapable) { 661 setLocalVideoCapable(newLocalVideoCapable); 662 changed = true; 663 } 664 } 665 666 int newAudioQuality = 667 getAudioQualityFromCallProfile(localCallProfile, remoteCallProfile); 668 if (getAudioQuality() != newAudioQuality) { 669 setAudioQuality(newAudioQuality); 670 changed = true; 671 } 672 } catch (ImsException e) { 673 // No session in place -- no change 674 } 675 676 return changed; 677 } 678 679 /** 680 * Check for a change in the wifi state of the ImsPhoneCallTracker and update the 681 * {@link ImsPhoneConnection} with this information. 682 * 683 * @return Whether the ImsPhoneCallTracker's usage of wifi has been changed. 684 */ 685 public boolean updateWifiState() { 686 Rlog.d(LOG_TAG, "updateWifiState: " + mOwner.isVowifiEnabled()); 687 if (isWifi() != mOwner.isVowifiEnabled()) { 688 setWifi(mOwner.isVowifiEnabled()); 689 return true; 690 } 691 return false; 692 } 693 694 /** 695 * Determines the {@link ImsPhoneConnection} audio quality based on the local and remote 696 * {@link ImsCallProfile}. If indicate a HQ audio call if the local stream profile 697 * indicates AMR_WB or EVRC_WB and there is no remote restrict cause. 698 * 699 * @param localCallProfile The local call profile. 700 * @param remoteCallProfile The remote call profile. 701 * @return The audio quality. 702 */ 703 private int getAudioQualityFromCallProfile( 704 ImsCallProfile localCallProfile, ImsCallProfile remoteCallProfile) { 705 if (localCallProfile == null || remoteCallProfile == null 706 || localCallProfile.mMediaProfile == null) { 707 return AUDIO_QUALITY_STANDARD; 708 } 709 710 boolean isHighDef = (localCallProfile.mMediaProfile.mAudioQuality 711 == ImsStreamMediaProfile.AUDIO_QUALITY_AMR_WB 712 || localCallProfile.mMediaProfile.mAudioQuality 713 == ImsStreamMediaProfile.AUDIO_QUALITY_EVRC_WB) 714 && remoteCallProfile.mRestrictCause == ImsCallProfile.CALL_RESTRICT_CAUSE_NONE; 715 return isHighDef ? AUDIO_QUALITY_HIGH_DEFINITION : AUDIO_QUALITY_STANDARD; 716 } 717 718 /** 719 * Provides a string representation of the {@link ImsPhoneConnection}. Primarily intended for 720 * use in log statements. 721 * 722 * @return String representation of call. 723 */ 724 @Override 725 public String toString() { 726 StringBuilder sb = new StringBuilder(); 727 sb.append("[ImsPhoneConnection objId: "); 728 sb.append(System.identityHashCode(this)); 729 sb.append(" address:"); 730 sb.append(Log.pii(getAddress())); 731 sb.append(" ImsCall:"); 732 if (mImsCall == null) { 733 sb.append("null"); 734 } else { 735 sb.append(mImsCall); 736 } 737 sb.append("]"); 738 return sb.toString(); 739 } 740} 741 742