blob: 7d02342ca59cc4a62e0107423c4edf66de6303fc [file] [log] [blame]
Svetoslav Ganov80943d82013-01-02 10:25:37 -08001/*
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 android.app;
18
19import android.accessibilityservice.AccessibilityService.Callbacks;
20import android.accessibilityservice.AccessibilityService.IAccessibilityServiceClientWrapper;
Svetoslavbbfa5852013-02-11 19:38:12 -080021import android.accessibilityservice.AccessibilityServiceInfo;
Svetoslav Ganov80943d82013-01-02 10:25:37 -080022import android.accessibilityservice.IAccessibilityServiceClient;
Svetoslavbbfa5852013-02-11 19:38:12 -080023import android.accessibilityservice.IAccessibilityServiceConnection;
Svetoslav Ganov80943d82013-01-02 10:25:37 -080024import android.graphics.Bitmap;
25import android.graphics.Canvas;
26import android.graphics.Point;
27import android.hardware.display.DisplayManagerGlobal;
28import android.os.Looper;
29import android.os.RemoteException;
30import android.os.SystemClock;
31import android.util.Log;
32import android.view.Display;
33import android.view.InputEvent;
34import android.view.Surface;
35import android.view.accessibility.AccessibilityEvent;
36import android.view.accessibility.AccessibilityInteractionClient;
37import android.view.accessibility.AccessibilityNodeInfo;
38import android.view.accessibility.IAccessibilityInteractionConnection;
39
Svetoslav Ganov80943d82013-01-02 10:25:37 -080040import java.util.ArrayList;
41import java.util.concurrent.TimeoutException;
42
43/**
44 * Class for interacting with the device's UI by simulation user actions and
45 * introspection of the screen content. It relies on the platform accessibility
46 * APIs to introspect the screen and to perform some actions on the remote view
47 * tree. It also allows injecting of arbitrary raw input events simulating user
Svetoslavbbfa5852013-02-11 19:38:12 -080048 * interaction with keyboards and touch devices. One can think of a UiAutomation
49 * as a special type of {@link android.accessibilityservice.AccessibilityService}
50 * which does not provide hooks for the service life cycle and exposes other
51 * APIs that are useful for UI test automation.
Svetoslav Ganov80943d82013-01-02 10:25:37 -080052 * <p>
53 * The APIs exposed by this class are low-level to maximize flexibility when
54 * developing UI test automation tools and libraries. Generally, a UiAutomation
55 * client should be using a higher-level library or implement high-level functions.
56 * For example, performing a tap on the screen requires construction and injecting
57 * of a touch down and up events which have to be delivered to the system by a
58 * call to {@link #injectInputEvent(InputEvent, boolean)}.
59 * </p>
60 * <p>
61 * The APIs exposed by this class operate across applications enabling a client
62 * to write tests that cover use cases spanning over multiple applications. For
63 * example, going to the settings application to change a setting and then
64 * interacting with another application whose behavior depends on that setting.
65 * </p>
66 */
67public final class UiAutomation {
68
69 private static final String LOG_TAG = UiAutomation.class.getSimpleName();
70
71 private static final boolean DEBUG = false;
72
73 private static final int CONNECTION_ID_UNDEFINED = -1;
74
75 private static final long CONNECT_TIMEOUT_MILLIS = 5000;
76
77 /** Rotation constant: Unfreeze rotation (rotating the device changes its rotation state). */
78 public static final int ROTATION_UNFREEZE = -2;
79
80 /** Rotation constant: Freeze rotation to its current state. */
81 public static final int ROTATION_FREEZE_CURRENT = -1;
82
83 /** Rotation constant: Freeze rotation to 0 degrees (natural orientation) */
84 public static final int ROTATION_FREEZE_0 = Surface.ROTATION_0;
85
86 /** Rotation constant: Freeze rotation to 90 degrees . */
87 public static final int ROTATION_FREEZE_90 = Surface.ROTATION_90;
88
89 /** Rotation constant: Freeze rotation to 180 degrees . */
90 public static final int ROTATION_FREEZE_180 = Surface.ROTATION_180;
91
92 /** Rotation constant: Freeze rotation to 270 degrees . */
93 public static final int ROTATION_FREEZE_270 = Surface.ROTATION_270;
94
95 private final Object mLock = new Object();
96
97 private final ArrayList<AccessibilityEvent> mEventQueue = new ArrayList<AccessibilityEvent>();
98
99 private final IAccessibilityServiceClient mClient;
100
101 private final IUiAutomationConnection mUiAutomationConnection;
102
103 private int mConnectionId = CONNECTION_ID_UNDEFINED;
104
105 private OnAccessibilityEventListener mOnAccessibilityEventListener;
106
107 private boolean mWaitingForEventDelivery;
108
109 private long mLastEventTimeMillis;
110
111 private boolean mIsConnecting;
112
113 /**
114 * Listener for observing the {@link AccessibilityEvent} stream.
115 */
116 public static interface OnAccessibilityEventListener {
117
118 /**
119 * Callback for receiving an {@link AccessibilityEvent}.
120 * <p>
121 * <strong>Note:</strong> This method is <strong>NOT</strong> executed
122 * on the main test thread. The client is responsible for proper
123 * synchronization.
124 * </p>
125 * <p>
126 * <strong>Note:</strong> It is responsibility of the client
127 * to recycle the received events to minimize object creation.
128 * </p>
129 *
130 * @param event The received event.
131 */
132 public void onAccessibilityEvent(AccessibilityEvent event);
133 }
134
135 /**
Svetoslav550b48f2013-02-12 14:56:29 -0800136 * Listener for filtering accessibility events.
137 */
138 public static interface AccessibilityEventFilter {
139
140 /**
141 * Callback for determining whether an event is accepted or
142 * it is filtered out.
143 *
144 * @param event The event to process.
145 * @return True if the event is accepted, false to filter it out.
146 */
147 public boolean accept(AccessibilityEvent event);
148 }
149
150 /**
Svetoslav Ganov80943d82013-01-02 10:25:37 -0800151 * Creates a new instance that will handle callbacks from the accessibility
152 * layer on the thread of the provided looper and perform requests for privileged
153 * operations on the provided connection.
154 *
155 * @param looper The looper on which to execute accessibility callbacks.
156 * @param connection The connection for performing privileged operations.
157 *
158 * @hide
159 */
160 public UiAutomation(Looper looper, IUiAutomationConnection connection) {
161 if (looper == null) {
162 throw new IllegalArgumentException("Looper cannot be null!");
163 }
164 if (connection == null) {
165 throw new IllegalArgumentException("Connection cannot be null!");
166 }
167 mUiAutomationConnection = connection;
168 mClient = new IAccessibilityServiceClientImpl(looper);
169 }
170
171 /**
172 * Connects this UiAutomation to the accessibility introspection APIs.
173 *
174 * @hide
175 */
176 public void connect() {
177 synchronized (mLock) {
178 throwIfConnectedLocked();
179 if (mIsConnecting) {
180 return;
181 }
182 mIsConnecting = true;
183 }
184
185 try {
186 // Calling out without a lock held.
187 mUiAutomationConnection.connect(mClient);
188 } catch (RemoteException re) {
189 throw new RuntimeException("Error while connecting UiAutomation", re);
190 }
191
192 synchronized (mLock) {
193 final long startTimeMillis = SystemClock.uptimeMillis();
194 try {
195 while (true) {
196 if (isConnectedLocked()) {
197 break;
198 }
199 final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis;
200 final long remainingTimeMillis = CONNECT_TIMEOUT_MILLIS - elapsedTimeMillis;
201 if (remainingTimeMillis <= 0) {
202 throw new RuntimeException("Error while connecting UiAutomation");
203 }
204 try {
205 mLock.wait(remainingTimeMillis);
206 } catch (InterruptedException ie) {
207 /* ignore */
208 }
209 }
210 } finally {
211 mIsConnecting = false;
212 }
213 }
214 }
215
216 /**
217 * Disconnects this UiAutomation from the accessibility introspection APIs.
218 *
219 * @hide
220 */
221 public void disconnect() {
222 synchronized (mLock) {
223 if (mIsConnecting) {
224 throw new IllegalStateException(
225 "Cannot call disconnect() while connecting!");
226 }
227 throwIfNotConnectedLocked();
228 mConnectionId = CONNECTION_ID_UNDEFINED;
229 }
230 try {
231 // Calling out without a lock held.
232 mUiAutomationConnection.disconnect();
233 } catch (RemoteException re) {
234 throw new RuntimeException("Error while disconnecting UiAutomation", re);
235 }
236 }
237
238 /**
239 * The id of the {@link IAccessibilityInteractionConnection} for querying
240 * the screen content. This is here for legacy purposes since some tools use
241 * hidden APIs to introspect the screen.
242 *
243 * @hide
244 */
245 public int getConnectionId() {
246 synchronized (mLock) {
247 throwIfNotConnectedLocked();
248 return mConnectionId;
249 }
250 }
251
252 /**
253 * Sets a callback for observing the stream of {@link AccessibilityEvent}s.
254 *
255 * @param listener The callback.
256 */
257 public void setOnAccessibilityEventListener(OnAccessibilityEventListener listener) {
258 synchronized (mLock) {
259 mOnAccessibilityEventListener = listener;
260 }
261 }
262
263 /**
Svetoslavbbfa5852013-02-11 19:38:12 -0800264 * Performs a global action. Such an action can be performed at any moment
265 * regardless of the current application or user location in that application.
266 * For example going back, going home, opening recents, etc.
267 *
268 * @param action The action to perform.
269 * @return Whether the action was successfully performed.
270 *
271 * @see AccessibilityService#GLOBAL_ACTION_BACK
272 * @see AccessibilityService#GLOBAL_ACTION_HOME
273 * @see AccessibilityService#GLOBAL_ACTION_NOTIFICATIONS
274 * @see AccessibilityService#GLOBAL_ACTION_RECENTS
275 */
276 public final boolean performGlobalAction(int action) {
277 final IAccessibilityServiceConnection connection;
278 synchronized (mLock) {
279 throwIfNotConnectedLocked();
280 connection = AccessibilityInteractionClient.getInstance()
281 .getConnection(mConnectionId);
282 }
283 // Calling out without a lock held.
284 if (connection != null) {
285 try {
286 return connection.performGlobalAction(action);
287 } catch (RemoteException re) {
288 Log.w(LOG_TAG, "Error while calling performGlobalAction", re);
289 }
290 }
291 return false;
292 }
293
294 /**
295 * Gets the an {@link AccessibilityServiceInfo} describing this UiAutomation.
296 * This method is useful if one wants to change some of the dynamically
297 * configurable properties at runtime.
298 *
299 * @return The accessibility service info.
300 *
301 * @see AccessibilityServiceInfo
302 */
303 public final AccessibilityServiceInfo getServiceInfo() {
304 final IAccessibilityServiceConnection connection;
305 synchronized (mLock) {
306 throwIfNotConnectedLocked();
307 connection = AccessibilityInteractionClient.getInstance()
308 .getConnection(mConnectionId);
309 }
310 // Calling out without a lock held.
311 if (connection != null) {
312 try {
313 return connection.getServiceInfo();
314 } catch (RemoteException re) {
315 Log.w(LOG_TAG, "Error while getting AccessibilityServiceInfo", re);
316 }
317 }
318 return null;
319 }
320
321 /**
322 * Sets the {@link AccessibilityServiceInfo} that describes how this
323 * UiAutomation will be handled by the platform accessibility layer.
324 *
325 * @param info The info.
326 *
327 * @see AccessibilityServiceInfo
328 */
329 public final void setServiceInfo(AccessibilityServiceInfo info) {
330 final IAccessibilityServiceConnection connection;
331 synchronized (mLock) {
332 throwIfNotConnectedLocked();
333 AccessibilityInteractionClient.getInstance().clearCache();
334 connection = AccessibilityInteractionClient.getInstance()
335 .getConnection(mConnectionId);
336 }
337 // Calling out without a lock held.
338 if (connection != null) {
339 try {
340 connection.setServiceInfo(info);
341 } catch (RemoteException re) {
342 Log.w(LOG_TAG, "Error while setting AccessibilityServiceInfo", re);
343 }
344 }
345 }
346
347 /**
Svetoslav Ganov80943d82013-01-02 10:25:37 -0800348 * Gets the root {@link AccessibilityNodeInfo} in the active window.
349 *
350 * @return The root info.
351 */
352 public AccessibilityNodeInfo getRootInActiveWindow() {
353 final int connectionId;
354 synchronized (mLock) {
355 throwIfNotConnectedLocked();
356 connectionId = mConnectionId;
357 }
358 // Calling out without a lock held.
359 return AccessibilityInteractionClient.getInstance()
360 .getRootInActiveWindow(connectionId);
361 }
362
363 /**
364 * A method for injecting an arbitrary input event.
365 * <p>
366 * <strong>Note:</strong> It is caller's responsibility to recycle the event.
367 * </p>
368 * @param event The event to inject.
369 * @param sync Whether to inject the event synchronously.
370 * @return Whether event injection succeeded.
371 */
372 public boolean injectInputEvent(InputEvent event, boolean sync) {
373 synchronized (mLock) {
374 throwIfNotConnectedLocked();
375 }
376 try {
377 if (DEBUG) {
378 Log.i(LOG_TAG, "Injecting: " + event + " sync: " + sync);
379 }
380 // Calling out without a lock held.
381 return mUiAutomationConnection.injectInputEvent(event, sync);
382 } catch (RemoteException re) {
383 Log.e(LOG_TAG, "Error while injecting input event!", re);
384 }
385 return false;
386 }
387
388 /**
389 * Sets the device rotation. A client can freeze the rotation in
390 * desired state or freeze the rotation to its current state or
391 * unfreeze the rotation (rotating the device changes its rotation
392 * state).
393 *
394 * @param rotation The desired rotation.
395 * @return Whether the rotation was set successfully.
396 *
397 * @see #ROTATION_FREEZE_0
398 * @see #ROTATION_FREEZE_90
399 * @see #ROTATION_FREEZE_180
400 * @see #ROTATION_FREEZE_270
401 * @see #ROTATION_FREEZE_CURRENT
402 * @see #ROTATION_UNFREEZE
403 */
404 public boolean setRotation(int rotation) {
405 synchronized (mLock) {
406 throwIfNotConnectedLocked();
407 }
408 switch (rotation) {
409 case ROTATION_FREEZE_0:
410 case ROTATION_FREEZE_90:
411 case ROTATION_FREEZE_180:
412 case ROTATION_FREEZE_270:
413 case ROTATION_UNFREEZE:
414 case ROTATION_FREEZE_CURRENT: {
415 try {
416 // Calling out without a lock held.
417 mUiAutomationConnection.setRotation(rotation);
418 return true;
419 } catch (RemoteException re) {
420 Log.e(LOG_TAG, "Error while setting rotation!", re);
421 }
422 } return false;
423 default: {
424 throw new IllegalArgumentException("Invalid rotation.");
425 }
426 }
427 }
428
429 /**
430 * Executes a command and waits for a specific accessibility event up to a
431 * given wait timeout. To detect a sequence of events one can implement a
432 * filter that keeps track of seen events of the expected sequence and
433 * returns true after the last event of that sequence is received.
434 * <p>
435 * <strong>Note:</strong> It is caller's responsibility to recycle the returned event.
436 * </p>
437 * @param command The command to execute.
438 * @param filter Filter that recognizes the expected event.
439 * @param timeoutMillis The wait timeout in milliseconds.
440 *
441 * @throws TimeoutException If the expected event is not received within the timeout.
442 */
443 public AccessibilityEvent executeAndWaitForEvent(Runnable command,
Svetoslav550b48f2013-02-12 14:56:29 -0800444 AccessibilityEventFilter filter, long timeoutMillis) throws TimeoutException {
Svetoslav Ganov80943d82013-01-02 10:25:37 -0800445 synchronized (mLock) {
446 throwIfNotConnectedLocked();
447
448 mEventQueue.clear();
449 // Prepare to wait for an event.
450 mWaitingForEventDelivery = true;
451
452 // We will ignore events from previous interactions.
453 final long executionStartTimeMillis = SystemClock.uptimeMillis();
454
455 // Execute the command.
456 command.run();
457 try {
458 // Wait for the event.
459 final long startTimeMillis = SystemClock.uptimeMillis();
460 while (true) {
461 // Drain the event queue
462 while (!mEventQueue.isEmpty()) {
463 AccessibilityEvent event = mEventQueue.remove(0);
464 // Ignore events from previous interactions.
465 if (event.getEventTime() <= executionStartTimeMillis) {
466 continue;
467 }
Svetoslav550b48f2013-02-12 14:56:29 -0800468 if (filter.accept(event)) {
Svetoslav Ganov80943d82013-01-02 10:25:37 -0800469 return event;
470 }
471 event.recycle();
472 }
473 // Check if timed out and if not wait.
474 final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis;
475 final long remainingTimeMillis = timeoutMillis - elapsedTimeMillis;
476 if (remainingTimeMillis <= 0) {
477 throw new TimeoutException("Expected event not received within: "
478 + timeoutMillis + " ms.");
479 }
480 try {
481 mLock.wait(remainingTimeMillis);
482 } catch (InterruptedException ie) {
483 /* ignore */
484 }
485 }
486 } finally {
487 mWaitingForEventDelivery = false;
488 mEventQueue.clear();
489 mLock.notifyAll();
490 }
491 }
492 }
493
494 /**
495 * Waits for the accessibility event stream to become idle, which is not to
496 * have received an accessibility event within <code>idleTimeoutMillis</code>.
497 * The total time spent to wait for an idle accessibility event stream is bounded
498 * by the <code>globalTimeoutMillis</code>.
499 *
500 * @param idleTimeoutMillis The timeout in milliseconds between two events
501 * to consider the device idle.
502 * @param globalTimeoutMillis The maximal global timeout in milliseconds in
503 * which to wait for an idle state.
504 *
505 * @throws TimeoutException If no idle state was detected within
506 * <code>globalTimeoutMillis.</code>
507 */
508 public void waitForIdle(long idleTimeoutMillis, long globalTimeoutMillis)
509 throws TimeoutException {
510 synchronized (mLock) {
511 throwIfNotConnectedLocked();
512
513 final long startTimeMillis = SystemClock.uptimeMillis();
514 if (mLastEventTimeMillis <= 0) {
515 mLastEventTimeMillis = startTimeMillis;
516 }
517
518 while (true) {
519 final long currentTimeMillis = SystemClock.uptimeMillis();
520 // Did we get idle state within the global timeout?
521 final long elapsedGlobalTimeMillis = currentTimeMillis - startTimeMillis;
522 final long remainingGlobalTimeMillis =
523 globalTimeoutMillis - elapsedGlobalTimeMillis;
524 if (remainingGlobalTimeMillis <= 0) {
525 throw new TimeoutException("No idle state with idle timeout: "
526 + idleTimeoutMillis + " within global timeout: "
527 + globalTimeoutMillis);
528 }
529 // Did we get an idle state within the idle timeout?
530 final long elapsedIdleTimeMillis = currentTimeMillis - mLastEventTimeMillis;
531 final long remainingIdleTimeMillis = idleTimeoutMillis - elapsedIdleTimeMillis;
532 if (remainingIdleTimeMillis <= 0) {
533 return;
534 }
535 try {
536 mLock.wait(remainingIdleTimeMillis);
537 } catch (InterruptedException ie) {
538 /* ignore */
539 }
540 }
541 }
542 }
543
544 /**
545 * Takes a screenshot.
546 *
547 * @return The screenshot bitmap on success, null otherwise.
548 */
549 public Bitmap takeScreenshot() {
550 synchronized (mLock) {
551 throwIfNotConnectedLocked();
552 }
553 Display display = DisplayManagerGlobal.getInstance()
554 .getRealDisplay(Display.DEFAULT_DISPLAY);
555 Point displaySize = new Point();
556 display.getRealSize(displaySize);
557 final int displayWidth = displaySize.x;
558 final int displayHeight = displaySize.y;
559
560 final float screenshotWidth;
561 final float screenshotHeight;
562
563 final int rotation = display.getRotation();
564 switch (rotation) {
565 case ROTATION_FREEZE_0: {
566 screenshotWidth = displayWidth;
567 screenshotHeight = displayHeight;
568 } break;
569 case ROTATION_FREEZE_90: {
570 screenshotWidth = displayHeight;
571 screenshotHeight = displayWidth;
572 } break;
573 case ROTATION_FREEZE_180: {
574 screenshotWidth = displayWidth;
575 screenshotHeight = displayHeight;
576 } break;
577 case ROTATION_FREEZE_270: {
578 screenshotWidth = displayHeight;
579 screenshotHeight = displayWidth;
580 } break;
581 default: {
582 throw new IllegalArgumentException("Invalid rotation: "
583 + rotation);
584 }
585 }
586
587 // Take the screenshot
588 Bitmap screenShot = null;
589 try {
590 // Calling out without a lock held.
591 screenShot = mUiAutomationConnection.takeScreenshot((int) screenshotWidth,
592 (int) screenshotHeight);
593 if (screenShot == null) {
594 return null;
595 }
596 } catch (RemoteException re) {
597 Log.e(LOG_TAG, "Error while taking screnshot!", re);
598 return null;
599 }
600
601 // Rotate the screenshot to the current orientation
602 if (rotation != ROTATION_FREEZE_0) {
603 Bitmap unrotatedScreenShot = Bitmap.createBitmap(displayWidth, displayHeight,
604 Bitmap.Config.ARGB_8888);
605 Canvas canvas = new Canvas(unrotatedScreenShot);
606 canvas.translate(unrotatedScreenShot.getWidth() / 2,
607 unrotatedScreenShot.getHeight() / 2);
608 canvas.rotate(getDegreesForRotation(rotation));
609 canvas.translate(- screenshotWidth / 2, - screenshotHeight / 2);
610 canvas.drawBitmap(screenShot, 0, 0, null);
611 canvas.setBitmap(null);
612 screenShot = unrotatedScreenShot;
613 }
614
615 // Optimization
616 screenShot.setHasAlpha(false);
617
618 return screenShot;
619 }
620
621 private static float getDegreesForRotation(int value) {
622 switch (value) {
623 case Surface.ROTATION_90: {
624 return 360f - 90f;
625 }
626 case Surface.ROTATION_180: {
627 return 360f - 180f;
628 }
629 case Surface.ROTATION_270: {
630 return 360f - 270f;
631 } default: {
632 return 0;
633 }
634 }
635 }
636
637 private boolean isConnectedLocked() {
638 return mConnectionId != CONNECTION_ID_UNDEFINED;
639 }
640
641 private void throwIfConnectedLocked() {
642 if (mConnectionId != CONNECTION_ID_UNDEFINED) {
643 throw new IllegalStateException("UiAutomation not connected!");
644 }
645 }
646
647 private void throwIfNotConnectedLocked() {
648 if (!isConnectedLocked()) {
649 throw new IllegalStateException("UiAutomation not connected!");
650 }
651 }
652
653 private class IAccessibilityServiceClientImpl extends IAccessibilityServiceClientWrapper {
654
655 public IAccessibilityServiceClientImpl(Looper looper) {
656 super(null, looper, new Callbacks() {
657 @Override
658 public void onSetConnectionId(int connectionId) {
659 synchronized (mLock) {
660 mConnectionId = connectionId;
661 mLock.notifyAll();
662 }
663 }
664
665 @Override
666 public void onServiceConnected() {
667 /* do nothing */
668 }
669
670 @Override
671 public void onInterrupt() {
672 /* do nothing */
673 }
674
675 @Override
676 public boolean onGesture(int gestureId) {
677 /* do nothing */
678 return false;
679 }
680
681 @Override
682 public void onAccessibilityEvent(AccessibilityEvent event) {
683 synchronized (mLock) {
684 mLastEventTimeMillis = event.getEventTime();
685 if (mWaitingForEventDelivery) {
686 mEventQueue.add(AccessibilityEvent.obtain(event));
687 }
688 mLock.notifyAll();
689 }
690 // Calling out only without a lock held.
691 final OnAccessibilityEventListener listener = mOnAccessibilityEventListener;
692 if (listener != null) {
693 listener.onAccessibilityEvent(AccessibilityEvent.obtain(event));
694 }
695 }
696 });
697 }
698 }
699}