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 android.webkit;
18
19import android.os.Bundle;
20import android.provider.Settings;
21import android.text.TextUtils;
22import android.text.TextUtils.SimpleStringSplitter;
23import android.util.Log;
24import android.view.KeyEvent;
25import android.view.accessibility.AccessibilityEvent;
26import android.view.accessibility.AccessibilityManager;
27import android.view.accessibility.AccessibilityNodeInfo;
28import android.webkit.WebViewCore.EventHub;
29
30import com.android.internal.os.SomeArgs;
31
32import 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 */
66class 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