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 17 package android.webkit; 18 19 import android.os.Bundle; 20 import android.provider.Settings; 21 import android.text.TextUtils; 22 import android.text.TextUtils.SimpleStringSplitter; 23 import android.util.Log; 24 import android.view.KeyEvent; 25 import android.view.accessibility.AccessibilityEvent; 26 import android.view.accessibility.AccessibilityManager; 27 import android.view.accessibility.AccessibilityNodeInfo; 28 import android.webkit.WebViewCore.EventHub; 29 30 import com.android.internal.os.SomeArgs; 31 32 import java.util.ArrayList; 33 34 /** 35 * This class injects accessibility into WebViews with disabled JavaScript or 36 * WebViews with enabled JavaScript but for which we have no accessibility 37 * script to inject. 38 * </p> 39 * Note: To avoid changes in the framework upon changing the available 40 * navigation axis, or reordering the navigation axis, or changing 41 * the key bindings, or defining sequence of actions to be bound to 42 * a given key this class is navigation axis agnostic. It is only 43 * aware of one navigation axis which is in fact the default behavior 44 * of webViews while using the DPAD/TrackBall. 45 * </p> 46 * In general a key binding is a mapping from modifiers + key code to 47 * a sequence of actions. For more detail how to specify key bindings refer to 48 * {@link android.provider.Settings.Secure#ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS}. 49 * </p> 50 * The possible actions are invocations to 51 * {@link #setCurrentAxis(int, boolean, String)}, or 52 * {@link #traverseGivenAxis(int, int, boolean, String, boolean)} 53 * {@link #performAxisTransition(int, int, boolean, String)} 54 * referred via the values of: 55 * {@link #ACTION_SET_CURRENT_AXIS}, 56 * {@link #ACTION_TRAVERSE_CURRENT_AXIS}, 57 * {@link #ACTION_TRAVERSE_GIVEN_AXIS}, 58 * {@link #ACTION_PERFORM_AXIS_TRANSITION}, 59 * respectively. 60 * The arguments for the action invocation are specified as offset 61 * hexademical pairs. Note the last argument of the invocation 62 * should NOT be specified in the binding as it is provided by 63 * this class. For details about the key binding implementation 64 * refer to {@link AccessibilityWebContentKeyBinding}. 65 */ 66 class AccessibilityInjectorFallback { 67 private static final String LOG_TAG = "AccessibilityInjector"; 68 69 private static final boolean DEBUG = true; 70 71 private static final int ACTION_SET_CURRENT_AXIS = 0; 72 private static final int ACTION_TRAVERSE_CURRENT_AXIS = 1; 73 private static final int ACTION_TRAVERSE_GIVEN_AXIS = 2; 74 private static final int ACTION_PERFORM_AXIS_TRANSITION = 3; 75 private static final int ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS = 4; 76 77 /** Timeout after which asynchronous granular movement is aborted. */ 78 private static final int MODIFY_SELECTION_TIMEOUT = 500; 79 80 // WebView navigation axes from WebViewCore.h, plus an additional axis for 81 // the default behavior. 82 private static final int NAVIGATION_AXIS_CHARACTER = 0; 83 private static final int NAVIGATION_AXIS_WORD = 1; 84 private static final int NAVIGATION_AXIS_SENTENCE = 2; 85 @SuppressWarnings("unused") 86 private static final int NAVIGATION_AXIS_HEADING = 3; 87 @SuppressWarnings("unused") 88 private static final int NAVIGATION_AXIS_SIBLING = 4; 89 @SuppressWarnings("unused") 90 private static final int NAVIGATION_AXIS_PARENT_FIRST_CHILD = 5; 91 private static final int NAVIGATION_AXIS_DOCUMENT = 6; 92 private static final int NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR = 7; 93 94 // WebView navigation directions from WebViewCore.h. 95 private static final int NAVIGATION_DIRECTION_BACKWARD = 0; 96 private static final int NAVIGATION_DIRECTION_FORWARD = 1; 97 98 // these are the same for all instances so make them process wide 99 private static ArrayList<AccessibilityWebContentKeyBinding> sBindings = 100 new ArrayList<AccessibilityWebContentKeyBinding>(); 101 102 // handle to the WebViewClassic this injector is associated with. 103 private final WebViewClassic mWebView; 104 private final WebView mWebViewInternal; 105 106 // Event scheduled for sending as soon as we receive the selected text. 107 private AccessibilityEvent mScheduledEvent; 108 109 // Token required to send the scheduled event. 110 private int mScheduledToken = 0; 111 112 // the current traversal axis 113 private int mCurrentAxis = 2; // sentence 114 115 // we need to consume the up if we have handled the last down 116 private boolean mLastDownEventHandled; 117 118 // getting two empty selection strings in a row we let the WebView handle the event 119 private boolean mIsLastSelectionStringNull; 120 121 // keep track of last direction 122 private int mLastDirection; 123 124 // Lock used for asynchronous selection callback. 125 private final Object mCallbackLock = new Object(); 126 127 // Whether the asynchronous selection callback was received. 128 private boolean mCallbackReceived; 129 130 // Whether the asynchronous selection callback succeeded. 131 private boolean mCallbackResult; 132 133 /** 134 * Creates a new injector associated with a given {@link WebViewClassic}. 135 * 136 * @param webView The associated WebViewClassic. 137 */ 138 public AccessibilityInjectorFallback(WebViewClassic webView) { 139 mWebView = webView; 140 mWebViewInternal = mWebView.getWebView(); 141 ensureWebContentKeyBindings(); 142 } 143 144 /** 145 * Processes a key down <code>event</code>. 146 * 147 * @return True if the event was processed. 148 */ 149 public boolean onKeyEvent(KeyEvent event) { 150 // We do not handle ENTER in any circumstances. 151 if (isEnterActionKey(event.getKeyCode())) { 152 return false; 153 } 154 155 if (event.getAction() == KeyEvent.ACTION_UP) { 156 return mLastDownEventHandled; 157 } 158 159 mLastDownEventHandled = false; 160 161 AccessibilityWebContentKeyBinding binding = null; 162 for (AccessibilityWebContentKeyBinding candidate : sBindings) { 163 if (event.getKeyCode() == candidate.getKeyCode() 164 && event.hasModifiers(candidate.getModifiers())) { 165 binding = candidate; 166 break; 167 } 168 } 169 170 if (binding == null) { 171 return false; 172 } 173 174 for (int i = 0, count = binding.getActionCount(); i < count; i++) { 175 int actionCode = binding.getActionCode(i); 176 String contentDescription = Integer.toHexString(binding.getAction(i)); 177 switch (actionCode) { 178 case ACTION_SET_CURRENT_AXIS: 179 int axis = binding.getFirstArgument(i); 180 boolean sendEvent = (binding.getSecondArgument(i) == 1); 181 setCurrentAxis(axis, sendEvent, contentDescription); 182 mLastDownEventHandled = true; 183 break; 184 case ACTION_TRAVERSE_CURRENT_AXIS: 185 int direction = binding.getFirstArgument(i); 186 // on second null selection string in same direction - WebView handles the event 187 if (direction == mLastDirection && mIsLastSelectionStringNull) { 188 mIsLastSelectionStringNull = false; 189 return false; 190 } 191 mLastDirection = direction; 192 sendEvent = (binding.getSecondArgument(i) == 1); 193 mLastDownEventHandled = traverseGivenAxis( 194 direction, mCurrentAxis, sendEvent, contentDescription, false); 195 break; 196 case ACTION_TRAVERSE_GIVEN_AXIS: 197 direction = binding.getFirstArgument(i); 198 // on second null selection string in same direction => WebView handle the event 199 if (direction == mLastDirection && mIsLastSelectionStringNull) { 200 mIsLastSelectionStringNull = false; 201 return false; 202 } 203 mLastDirection = direction; 204 axis = binding.getSecondArgument(i); 205 sendEvent = (binding.getThirdArgument(i) == 1); 206 traverseGivenAxis(direction, axis, sendEvent, contentDescription, false); 207 mLastDownEventHandled = true; 208 break; 209 case ACTION_PERFORM_AXIS_TRANSITION: 210 int fromAxis = binding.getFirstArgument(i); 211 int toAxis = binding.getSecondArgument(i); 212 sendEvent = (binding.getThirdArgument(i) == 1); 213 performAxisTransition(fromAxis, toAxis, sendEvent, contentDescription); 214 mLastDownEventHandled = true; 215 break; 216 case ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS: 217 // This is a special case since we treat the default WebView navigation 218 // behavior as one of the possible navigation axis the user can use. 219 // If we are not on the default WebView navigation axis this is NOP. 220 if (mCurrentAxis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) { 221 // While WebVew handles navigation we do not get null selection 222 // strings so do not check for that here as the cases above. 223 mLastDirection = binding.getFirstArgument(i); 224 sendEvent = (binding.getSecondArgument(i) == 1); 225 traverseGivenAxis(mLastDirection, NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR, 226 sendEvent, contentDescription, false); 227 mLastDownEventHandled = false; 228 } else { 229 mLastDownEventHandled = true; 230 } 231 break; 232 default: 233 Log.w(LOG_TAG, "Unknown action code: " + actionCode); 234 } 235 } 236 237 return mLastDownEventHandled; 238 } 239 240 /** 241 * Set the current navigation axis. 242 * 243 * @param axis The axis to set. 244 * @param sendEvent Whether to send an accessibility event to 245 * announce the change. 246 */ 247 private void setCurrentAxis(int axis, boolean sendEvent, String contentDescription) { 248 mCurrentAxis = axis; 249 if (sendEvent) { 250 final AccessibilityEvent event = getPartialyPopulatedAccessibilityEvent( 251 AccessibilityEvent.TYPE_ANNOUNCEMENT); 252 event.getText().add(String.valueOf(axis)); 253 event.setContentDescription(contentDescription); 254 sendAccessibilityEvent(event); 255 } 256 } 257 258 /** 259 * Performs conditional transition one axis to another. 260 * 261 * @param fromAxis The axis which must be the current for the transition to occur. 262 * @param toAxis The axis to which to transition. 263 * @param sendEvent Flag if to send an event to announce successful transition. 264 * @param contentDescription A description of the performed action. 265 */ 266 private void performAxisTransition(int fromAxis, int toAxis, boolean sendEvent, 267 String contentDescription) { 268 if (mCurrentAxis == fromAxis) { 269 setCurrentAxis(toAxis, sendEvent, contentDescription); 270 } 271 } 272 273 boolean performAccessibilityAction(int action, Bundle arguments) { 274 switch (action) { 275 case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: 276 case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: { 277 final int direction = getDirectionForAction(action); 278 final int axis = getAxisForGranularity(arguments.getInt( 279 AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT)); 280 return traverseGivenAxis(direction, axis, true, null, true); 281 } 282 case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: 283 case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: { 284 final int direction = getDirectionForAction(action); 285 // TODO: Add support for moving by object. 286 final int axis = NAVIGATION_AXIS_SENTENCE; 287 return traverseGivenAxis(direction, axis, true, null, true); 288 } 289 default: 290 return false; 291 } 292 } 293 294 /** 295 * Returns the {@link WebView}-defined direction for the given 296 * {@link AccessibilityNodeInfo}-defined action. 297 * 298 * @param action An accessibility action identifier. 299 * @return A web view navigation direction. 300 */ 301 private static int getDirectionForAction(int action) { 302 switch (action) { 303 case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: 304 case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: 305 return NAVIGATION_DIRECTION_FORWARD; 306 case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: 307 case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: 308 return NAVIGATION_DIRECTION_BACKWARD; 309 default: 310 return -1; 311 } 312 } 313 314 /** 315 * Returns the {@link WebView}-defined axis for the given 316 * {@link AccessibilityNodeInfo}-defined granularity. 317 * 318 * @param granularity An accessibility granularity identifier. 319 * @return A web view navigation axis. 320 */ 321 private static int getAxisForGranularity(int granularity) { 322 switch (granularity) { 323 case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: 324 return NAVIGATION_AXIS_CHARACTER; 325 case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: 326 return NAVIGATION_AXIS_WORD; 327 case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: 328 return NAVIGATION_AXIS_SENTENCE; 329 case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: 330 // TODO: This should map to object once we implement it. 331 return NAVIGATION_AXIS_SENTENCE; 332 case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: 333 return NAVIGATION_AXIS_DOCUMENT; 334 default: 335 return -1; 336 } 337 } 338 339 /** 340 * Traverse the document along the given navigation axis. 341 * 342 * @param direction The direction of traversal. 343 * @param axis The axis along which to traverse. 344 * @param sendEvent Whether to send an accessibility event to 345 * announce the change. 346 * @param contentDescription A description of the performed action. 347 */ 348 private boolean traverseGivenAxis(int direction, int axis, boolean sendEvent, 349 String contentDescription, boolean sychronous) { 350 final WebViewCore webViewCore = mWebView.getWebViewCore(); 351 if (webViewCore == null) { 352 return false; 353 } 354 355 if (sendEvent) { 356 final AccessibilityEvent event = getPartialyPopulatedAccessibilityEvent( 357 AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY); 358 // The text will be set upon receiving the selection string. 359 event.setContentDescription(contentDescription); 360 mScheduledEvent = event; 361 mScheduledToken++; 362 } 363 364 // if the axis is the default let WebView handle the event which will 365 // result in cursor ring movement and selection of its content 366 if (axis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) { 367 return false; 368 } 369 370 final SomeArgs args = SomeArgs.obtain(); 371 args.argi1 = direction; 372 args.argi2 = axis; 373 args.argi3 = mScheduledToken; 374 375 // If we don't need synchronous results, just return true. 376 if (!sychronous) { 377 webViewCore.sendMessage(EventHub.MODIFY_SELECTION, args); 378 return true; 379 } 380 381 final boolean callbackResult; 382 383 synchronized (mCallbackLock) { 384 mCallbackReceived = false; 385 386 // Asynchronously changes the selection in WebView, which responds by 387 // calling onSelectionStringChanged(). 388 webViewCore.sendMessage(EventHub.MODIFY_SELECTION, args); 389 390 try { 391 mCallbackLock.wait(MODIFY_SELECTION_TIMEOUT); 392 } catch (InterruptedException e) { 393 // Do nothing. 394 } 395 396 callbackResult = mCallbackResult; 397 } 398 399 return (mCallbackReceived && callbackResult); 400 } 401 402 /* package */ void onSelectionStringChangedWebCoreThread( 403 final String selection, final int token) { 404 synchronized (mCallbackLock) { 405 mCallbackReceived = true; 406 mCallbackResult = (selection != null); 407 mCallbackLock.notifyAll(); 408 } 409 410 // Managing state and sending events must take place on the UI thread. 411 mWebViewInternal.post(new Runnable() { 412 @Override 413 public void run() { 414 onSelectionStringChangedMainThread(selection, token); 415 } 416 }); 417 } 418 419 private void onSelectionStringChangedMainThread(String selection, int token) { 420 if (DEBUG) { 421 Log.d(LOG_TAG, "Selection string: " + selection); 422 } 423 424 if (token != mScheduledToken) { 425 if (DEBUG) { 426 Log.d(LOG_TAG, "Selection string has incorrect token: " + token); 427 } 428 return; 429 } 430 431 mIsLastSelectionStringNull = (selection == null); 432 433 final AccessibilityEvent event = mScheduledEvent; 434 mScheduledEvent = null; 435 436 if ((event != null) && (selection != null)) { 437 event.getText().add(selection); 438 event.setFromIndex(0); 439 event.setToIndex(selection.length()); 440 sendAccessibilityEvent(event); 441 } 442 } 443 444 /** 445 * Sends an {@link AccessibilityEvent}. 446 * 447 * @param event The event to send. 448 */ 449 private void sendAccessibilityEvent(AccessibilityEvent event) { 450 if (DEBUG) { 451 Log.d(LOG_TAG, "Dispatching: " + event); 452 } 453 // accessibility may be disabled while waiting for the selection string 454 AccessibilityManager accessibilityManager = 455 AccessibilityManager.getInstance(mWebView.getContext()); 456 if (accessibilityManager.isEnabled()) { 457 accessibilityManager.sendAccessibilityEvent(event); 458 } 459 } 460 461 /** 462 * @return An accessibility event whose members are populated except its 463 * text and content description. 464 */ 465 private AccessibilityEvent getPartialyPopulatedAccessibilityEvent(int eventType) { 466 AccessibilityEvent event = AccessibilityEvent.obtain(eventType); 467 mWebViewInternal.onInitializeAccessibilityEvent(event); 468 return event; 469 } 470 471 /** 472 * Ensures that the Web content key bindings are loaded. 473 */ 474 private void ensureWebContentKeyBindings() { 475 if (sBindings.size() > 0) { 476 return; 477 } 478 479 String webContentKeyBindingsString = Settings.Secure.getString( 480 mWebView.getContext().getContentResolver(), 481 Settings.Secure.ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS); 482 483 SimpleStringSplitter semiColonSplitter = new SimpleStringSplitter(';'); 484 semiColonSplitter.setString(webContentKeyBindingsString); 485 486 while (semiColonSplitter.hasNext()) { 487 String bindingString = semiColonSplitter.next(); 488 if (TextUtils.isEmpty(bindingString)) { 489 Log.e(LOG_TAG, "Disregarding malformed Web content key binding: " 490 + webContentKeyBindingsString); 491 continue; 492 } 493 String[] keyValueArray = bindingString.split("="); 494 if (keyValueArray.length != 2) { 495 Log.e(LOG_TAG, "Disregarding malformed Web content key binding: " + bindingString); 496 continue; 497 } 498 try { 499 long keyCodeAndModifiers = Long.decode(keyValueArray[0].trim()); 500 String[] actionStrings = keyValueArray[1].split(":"); 501 int[] actions = new int[actionStrings.length]; 502 for (int i = 0, count = actions.length; i < count; i++) { 503 actions[i] = Integer.decode(actionStrings[i].trim()); 504 } 505 sBindings.add(new AccessibilityWebContentKeyBinding(keyCodeAndModifiers, actions)); 506 } catch (NumberFormatException nfe) { 507 Log.e(LOG_TAG, "Disregarding malformed key binding: " + bindingString); 508 } 509 } 510 } 511 512 private boolean isEnterActionKey(int keyCode) { 513 return keyCode == KeyEvent.KEYCODE_DPAD_CENTER 514 || keyCode == KeyEvent.KEYCODE_ENTER 515 || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER; 516 } 517 518 /** 519 * Represents a web content key-binding. 520 */ 521 private static final class AccessibilityWebContentKeyBinding { 522 523 private static final int MODIFIERS_OFFSET = 32; 524 private static final long MODIFIERS_MASK = 0xFFFFFFF00000000L; 525 526 private static final int KEY_CODE_OFFSET = 0; 527 private static final long KEY_CODE_MASK = 0x00000000FFFFFFFFL; 528 529 private static final int ACTION_OFFSET = 24; 530 private static final int ACTION_MASK = 0xFF000000; 531 532 private static final int FIRST_ARGUMENT_OFFSET = 16; 533 private static final int FIRST_ARGUMENT_MASK = 0x00FF0000; 534 535 private static final int SECOND_ARGUMENT_OFFSET = 8; 536 private static final int SECOND_ARGUMENT_MASK = 0x0000FF00; 537 538 private static final int THIRD_ARGUMENT_OFFSET = 0; 539 private static final int THIRD_ARGUMENT_MASK = 0x000000FF; 540 541 private final long mKeyCodeAndModifiers; 542 543 private final int [] mActionSequence; 544 545 /** 546 * @return The key code of the binding key. 547 */ 548 public int getKeyCode() { 549 return (int) ((mKeyCodeAndModifiers & KEY_CODE_MASK) >> KEY_CODE_OFFSET); 550 } 551 552 /** 553 * @return The meta state of the binding key. 554 */ 555 public int getModifiers() { 556 return (int) ((mKeyCodeAndModifiers & MODIFIERS_MASK) >> MODIFIERS_OFFSET); 557 } 558 559 /** 560 * @return The number of actions in the key binding. 561 */ 562 public int getActionCount() { 563 return mActionSequence.length; 564 } 565 566 /** 567 * @param index The action for a given action <code>index</code>. 568 */ 569 public int getAction(int index) { 570 return mActionSequence[index]; 571 } 572 573 /** 574 * @param index The action code for a given action <code>index</code>. 575 */ 576 public int getActionCode(int index) { 577 return (mActionSequence[index] & ACTION_MASK) >> ACTION_OFFSET; 578 } 579 580 /** 581 * @param index The first argument for a given action <code>index</code>. 582 */ 583 public int getFirstArgument(int index) { 584 return (mActionSequence[index] & FIRST_ARGUMENT_MASK) >> FIRST_ARGUMENT_OFFSET; 585 } 586 587 /** 588 * @param index The second argument for a given action <code>index</code>. 589 */ 590 public int getSecondArgument(int index) { 591 return (mActionSequence[index] & SECOND_ARGUMENT_MASK) >> SECOND_ARGUMENT_OFFSET; 592 } 593 594 /** 595 * @param index The third argument for a given action <code>index</code>. 596 */ 597 public int getThirdArgument(int index) { 598 return (mActionSequence[index] & THIRD_ARGUMENT_MASK) >> THIRD_ARGUMENT_OFFSET; 599 } 600 601 /** 602 * Creates a new instance. 603 * @param keyCodeAndModifiers The key for the binding (key and modifiers). 604 * @param actionSequence The sequence of action for the binding. 605 */ 606 public AccessibilityWebContentKeyBinding(long keyCodeAndModifiers, int[] actionSequence) { 607 mKeyCodeAndModifiers = keyCodeAndModifiers; 608 mActionSequence = actionSequence; 609 } 610 611 @Override 612 public String toString() { 613 StringBuilder builder = new StringBuilder(); 614 builder.append("modifiers: "); 615 builder.append(getModifiers()); 616 builder.append(", keyCode: "); 617 builder.append(getKeyCode()); 618 builder.append(", actions["); 619 for (int i = 0, count = getActionCount(); i < count; i++) { 620 builder.append("{actionCode"); 621 builder.append(i); 622 builder.append(": "); 623 builder.append(getActionCode(i)); 624 builder.append(", firstArgument: "); 625 builder.append(getFirstArgument(i)); 626 builder.append(", secondArgument: "); 627 builder.append(getSecondArgument(i)); 628 builder.append(", thirdArgument: "); 629 builder.append(getThirdArgument(i)); 630 builder.append("}"); 631 } 632 builder.append("]"); 633 return builder.toString(); 634 } 635 } 636 } 637