1/*
2 * Copyright (C) 2007 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.server;
18
19import android.net.LocalSocket;
20import android.net.LocalSocketAddress;
21import android.os.Build;
22import android.os.Handler;
23import android.os.Looper;
24import android.os.Message;
25import android.os.PowerManager;
26import android.os.SystemClock;
27import android.util.LocalLog;
28import android.util.Slog;
29
30import com.android.internal.annotations.VisibleForTesting;
31import com.android.internal.util.Preconditions;
32import com.google.android.collect.Lists;
33
34import java.io.FileDescriptor;
35import java.io.IOException;
36import java.io.InputStream;
37import java.io.OutputStream;
38import java.io.PrintWriter;
39import java.nio.charset.StandardCharsets;
40import java.util.ArrayList;
41import java.util.concurrent.atomic.AtomicInteger;
42import java.util.concurrent.ArrayBlockingQueue;
43import java.util.concurrent.BlockingQueue;
44import java.util.concurrent.CountDownLatch;
45import java.util.concurrent.TimeUnit;
46import java.util.LinkedList;
47
48/**
49 * Generic connector class for interfacing with a native daemon which uses the
50 * {@code libsysutils} FrameworkListener protocol.
51 */
52final class NativeDaemonConnector implements Runnable, Handler.Callback, Watchdog.Monitor {
53    private final static boolean VDBG = false;
54
55    private final String TAG;
56
57    private String mSocket;
58    private OutputStream mOutputStream;
59    private LocalLog mLocalLog;
60
61    private volatile boolean mDebug = false;
62    private volatile Object mWarnIfHeld;
63
64    private final ResponseQueue mResponseQueue;
65
66    private final PowerManager.WakeLock mWakeLock;
67
68    private final Looper mLooper;
69
70    private INativeDaemonConnectorCallbacks mCallbacks;
71    private Handler mCallbackHandler;
72
73    private AtomicInteger mSequenceNumber;
74
75    private static final long DEFAULT_TIMEOUT = 1 * 60 * 1000; /* 1 minute */
76    private static final long WARN_EXECUTE_DELAY_MS = 500; /* .5 sec */
77
78    /** Lock held whenever communicating with native daemon. */
79    private final Object mDaemonLock = new Object();
80
81    private final int BUFFER_SIZE = 4096;
82
83    NativeDaemonConnector(INativeDaemonConnectorCallbacks callbacks, String socket,
84            int responseQueueSize, String logTag, int maxLogSize, PowerManager.WakeLock wl) {
85        this(callbacks, socket, responseQueueSize, logTag, maxLogSize, wl,
86                FgThread.get().getLooper());
87    }
88
89    NativeDaemonConnector(INativeDaemonConnectorCallbacks callbacks, String socket,
90            int responseQueueSize, String logTag, int maxLogSize, PowerManager.WakeLock wl,
91            Looper looper) {
92        mCallbacks = callbacks;
93        mSocket = socket;
94        mResponseQueue = new ResponseQueue(responseQueueSize);
95        mWakeLock = wl;
96        if (mWakeLock != null) {
97            mWakeLock.setReferenceCounted(true);
98        }
99        mLooper = looper;
100        mSequenceNumber = new AtomicInteger(0);
101        TAG = logTag != null ? logTag : "NativeDaemonConnector";
102        mLocalLog = new LocalLog(maxLogSize);
103    }
104
105    /**
106     * Enable Set debugging mode, which causes messages to also be written to both
107     * {@link Slog} in addition to internal log.
108     */
109    public void setDebug(boolean debug) {
110        mDebug = debug;
111    }
112
113    /**
114     * Like SystemClock.uptimeMillis, except truncated to an int so it will fit in a message arg.
115     * Inaccurate across 49.7 days of uptime, but only used for debugging.
116     */
117    private int uptimeMillisInt() {
118        return (int) SystemClock.uptimeMillis() & Integer.MAX_VALUE;
119    }
120
121    /**
122     * Yell loudly if someone tries making future {@link #execute(Command)}
123     * calls while holding a lock on the given object.
124     */
125    public void setWarnIfHeld(Object warnIfHeld) {
126        Preconditions.checkState(mWarnIfHeld == null);
127        mWarnIfHeld = Preconditions.checkNotNull(warnIfHeld);
128    }
129
130    @Override
131    public void run() {
132        mCallbackHandler = new Handler(mLooper, this);
133
134        while (true) {
135            try {
136                listenToSocket();
137            } catch (Exception e) {
138                loge("Error in NativeDaemonConnector: " + e);
139                SystemClock.sleep(5000);
140            }
141        }
142    }
143
144    @Override
145    public boolean handleMessage(Message msg) {
146        final String event = (String) msg.obj;
147        final int start = uptimeMillisInt();
148        final int sent = msg.arg1;
149        try {
150            if (!mCallbacks.onEvent(msg.what, event, NativeDaemonEvent.unescapeArgs(event))) {
151                log(String.format("Unhandled event '%s'", event));
152            }
153        } catch (Exception e) {
154            loge("Error handling '" + event + "': " + e);
155        } finally {
156            if (mCallbacks.onCheckHoldWakeLock(msg.what) && mWakeLock != null) {
157                mWakeLock.release();
158            }
159            final int end = uptimeMillisInt();
160            if (start > sent && start - sent > WARN_EXECUTE_DELAY_MS) {
161                loge(String.format("NDC event {%s} processed too late: %dms", event, start - sent));
162            }
163            if (end > start && end - start > WARN_EXECUTE_DELAY_MS) {
164                loge(String.format("NDC event {%s} took too long: %dms", event, end - start));
165            }
166        }
167        return true;
168    }
169
170    private LocalSocketAddress determineSocketAddress() {
171        // If we're testing, set up a socket in a namespace that's accessible to test code.
172        // In order to ensure that unprivileged apps aren't able to impersonate native daemons on
173        // production devices, even if said native daemons ill-advisedly pick a socket name that
174        // starts with __test__, only allow this on debug builds.
175        if (mSocket.startsWith("__test__") && Build.IS_DEBUGGABLE) {
176            return new LocalSocketAddress(mSocket);
177        } else {
178            return new LocalSocketAddress(mSocket, LocalSocketAddress.Namespace.RESERVED);
179        }
180    }
181
182    private void listenToSocket() throws IOException {
183        LocalSocket socket = null;
184
185        try {
186            socket = new LocalSocket();
187            LocalSocketAddress address = determineSocketAddress();
188
189            socket.connect(address);
190
191            InputStream inputStream = socket.getInputStream();
192            synchronized (mDaemonLock) {
193                mOutputStream = socket.getOutputStream();
194            }
195
196            mCallbacks.onDaemonConnected();
197
198            FileDescriptor[] fdList = null;
199            byte[] buffer = new byte[BUFFER_SIZE];
200            int start = 0;
201
202            while (true) {
203                int count = inputStream.read(buffer, start, BUFFER_SIZE - start);
204                if (count < 0) {
205                    loge("got " + count + " reading with start = " + start);
206                    break;
207                }
208                fdList = socket.getAncillaryFileDescriptors();
209
210                // Add our starting point to the count and reset the start.
211                count += start;
212                start = 0;
213
214                for (int i = 0; i < count; i++) {
215                    if (buffer[i] == 0) {
216                        // Note - do not log this raw message since it may contain
217                        // sensitive data
218                        final String rawEvent = new String(
219                                buffer, start, i - start, StandardCharsets.UTF_8);
220
221                        boolean releaseWl = false;
222                        try {
223                            final NativeDaemonEvent event =
224                                    NativeDaemonEvent.parseRawEvent(rawEvent, fdList);
225
226                            log("RCV <- {" + event + "}");
227
228                            if (event.isClassUnsolicited()) {
229                                // TODO: migrate to sending NativeDaemonEvent instances
230                                if (mCallbacks.onCheckHoldWakeLock(event.getCode())
231                                        && mWakeLock != null) {
232                                    mWakeLock.acquire();
233                                    releaseWl = true;
234                                }
235                                Message msg = mCallbackHandler.obtainMessage(
236                                        event.getCode(), uptimeMillisInt(), 0, event.getRawEvent());
237                                if (mCallbackHandler.sendMessage(msg)) {
238                                    releaseWl = false;
239                                }
240                            } else {
241                                mResponseQueue.add(event.getCmdNumber(), event);
242                            }
243                        } catch (IllegalArgumentException e) {
244                            log("Problem parsing message " + e);
245                        } finally {
246                            if (releaseWl) {
247                                mWakeLock.release();
248                            }
249                        }
250
251                        start = i + 1;
252                    }
253                }
254
255                if (start == 0) {
256                    log("RCV incomplete");
257                }
258
259                // We should end at the amount we read. If not, compact then
260                // buffer and read again.
261                if (start != count) {
262                    final int remaining = BUFFER_SIZE - start;
263                    System.arraycopy(buffer, start, buffer, 0, remaining);
264                    start = remaining;
265                } else {
266                    start = 0;
267                }
268            }
269        } catch (IOException ex) {
270            loge("Communications error: " + ex);
271            throw ex;
272        } finally {
273            synchronized (mDaemonLock) {
274                if (mOutputStream != null) {
275                    try {
276                        loge("closing stream for " + mSocket);
277                        mOutputStream.close();
278                    } catch (IOException e) {
279                        loge("Failed closing output stream: " + e);
280                    }
281                    mOutputStream = null;
282                }
283            }
284
285            try {
286                if (socket != null) {
287                    socket.close();
288                }
289            } catch (IOException ex) {
290                loge("Failed closing socket: " + ex);
291            }
292        }
293    }
294
295    /**
296     * Wrapper around argument that indicates it's sensitive and shouldn't be
297     * logged.
298     */
299    public static class SensitiveArg {
300        private final Object mArg;
301
302        public SensitiveArg(Object arg) {
303            mArg = arg;
304        }
305
306        @Override
307        public String toString() {
308            return String.valueOf(mArg);
309        }
310    }
311
312    /**
313     * Make command for daemon, escaping arguments as needed.
314     */
315    @VisibleForTesting
316    static void makeCommand(StringBuilder rawBuilder, StringBuilder logBuilder, int sequenceNumber,
317            String cmd, Object... args) {
318        if (cmd.indexOf('\0') >= 0) {
319            throw new IllegalArgumentException("Unexpected command: " + cmd);
320        }
321        if (cmd.indexOf(' ') >= 0) {
322            throw new IllegalArgumentException("Arguments must be separate from command");
323        }
324
325        rawBuilder.append(sequenceNumber).append(' ').append(cmd);
326        logBuilder.append(sequenceNumber).append(' ').append(cmd);
327        for (Object arg : args) {
328            final String argString = String.valueOf(arg);
329            if (argString.indexOf('\0') >= 0) {
330                throw new IllegalArgumentException("Unexpected argument: " + arg);
331            }
332
333            rawBuilder.append(' ');
334            logBuilder.append(' ');
335
336            appendEscaped(rawBuilder, argString);
337            if (arg instanceof SensitiveArg) {
338                logBuilder.append("[scrubbed]");
339            } else {
340                appendEscaped(logBuilder, argString);
341            }
342        }
343
344        rawBuilder.append('\0');
345    }
346
347    /**
348     * Method that waits until all asychronous notifications sent by the native daemon have
349     * been processed. This method must not be called on the notification thread or an
350     * exception will be thrown.
351     */
352    public void waitForCallbacks() {
353        if (Thread.currentThread() == mLooper.getThread()) {
354            throw new IllegalStateException("Must not call this method on callback thread");
355        }
356
357        final CountDownLatch latch = new CountDownLatch(1);
358        mCallbackHandler.post(new Runnable() {
359            @Override
360            public void run() {
361                latch.countDown();
362            }
363        });
364        try {
365            latch.await();
366        } catch (InterruptedException e) {
367            Slog.wtf(TAG, "Interrupted while waiting for unsolicited response handling", e);
368        }
369    }
370
371    /**
372     * Issue the given command to the native daemon and return a single expected
373     * response.
374     *
375     * @throws NativeDaemonConnectorException when problem communicating with
376     *             native daemon, or if the response matches
377     *             {@link NativeDaemonEvent#isClassClientError()} or
378     *             {@link NativeDaemonEvent#isClassServerError()}.
379     */
380    public NativeDaemonEvent execute(Command cmd) throws NativeDaemonConnectorException {
381        return execute(cmd.mCmd, cmd.mArguments.toArray());
382    }
383
384    /**
385     * Issue the given command to the native daemon and return a single expected
386     * response. Any arguments must be separated from base command so they can
387     * be properly escaped.
388     *
389     * @throws NativeDaemonConnectorException when problem communicating with
390     *             native daemon, or if the response matches
391     *             {@link NativeDaemonEvent#isClassClientError()} or
392     *             {@link NativeDaemonEvent#isClassServerError()}.
393     */
394    public NativeDaemonEvent execute(String cmd, Object... args)
395            throws NativeDaemonConnectorException {
396        return execute(DEFAULT_TIMEOUT, cmd, args);
397    }
398
399    public NativeDaemonEvent execute(long timeoutMs, String cmd, Object... args)
400            throws NativeDaemonConnectorException {
401        final NativeDaemonEvent[] events = executeForList(timeoutMs, cmd, args);
402        if (events.length != 1) {
403            throw new NativeDaemonConnectorException(
404                    "Expected exactly one response, but received " + events.length);
405        }
406        return events[0];
407    }
408
409    /**
410     * Issue the given command to the native daemon and return any
411     * {@link NativeDaemonEvent#isClassContinue()} responses, including the
412     * final terminal response.
413     *
414     * @throws NativeDaemonConnectorException when problem communicating with
415     *             native daemon, or if the response matches
416     *             {@link NativeDaemonEvent#isClassClientError()} or
417     *             {@link NativeDaemonEvent#isClassServerError()}.
418     */
419    public NativeDaemonEvent[] executeForList(Command cmd) throws NativeDaemonConnectorException {
420        return executeForList(cmd.mCmd, cmd.mArguments.toArray());
421    }
422
423    /**
424     * Issue the given command to the native daemon and return any
425     * {@link NativeDaemonEvent#isClassContinue()} responses, including the
426     * final terminal response. Any arguments must be separated from base
427     * command so they can be properly escaped.
428     *
429     * @throws NativeDaemonConnectorException when problem communicating with
430     *             native daemon, or if the response matches
431     *             {@link NativeDaemonEvent#isClassClientError()} or
432     *             {@link NativeDaemonEvent#isClassServerError()}.
433     */
434    public NativeDaemonEvent[] executeForList(String cmd, Object... args)
435            throws NativeDaemonConnectorException {
436        return executeForList(DEFAULT_TIMEOUT, cmd, args);
437    }
438
439    /**
440     * Issue the given command to the native daemon and return any {@linke
441     * NativeDaemonEvent@isClassContinue()} responses, including the final
442     * terminal response. Note that the timeout does not count time in deep
443     * sleep. Any arguments must be separated from base command so they can be
444     * properly escaped.
445     *
446     * @throws NativeDaemonConnectorException when problem communicating with
447     *             native daemon, or if the response matches
448     *             {@link NativeDaemonEvent#isClassClientError()} or
449     *             {@link NativeDaemonEvent#isClassServerError()}.
450     */
451    public NativeDaemonEvent[] executeForList(long timeoutMs, String cmd, Object... args)
452            throws NativeDaemonConnectorException {
453        if (mWarnIfHeld != null && Thread.holdsLock(mWarnIfHeld)) {
454            Slog.wtf(TAG, "Calling thread " + Thread.currentThread().getName() + " is holding 0x"
455                    + Integer.toHexString(System.identityHashCode(mWarnIfHeld)), new Throwable());
456        }
457
458        final long startTime = SystemClock.elapsedRealtime();
459
460        final ArrayList<NativeDaemonEvent> events = Lists.newArrayList();
461
462        final StringBuilder rawBuilder = new StringBuilder();
463        final StringBuilder logBuilder = new StringBuilder();
464        final int sequenceNumber = mSequenceNumber.incrementAndGet();
465
466        makeCommand(rawBuilder, logBuilder, sequenceNumber, cmd, args);
467
468        final String rawCmd = rawBuilder.toString();
469        final String logCmd = logBuilder.toString();
470
471        log("SND -> {" + logCmd + "}");
472
473        synchronized (mDaemonLock) {
474            if (mOutputStream == null) {
475                throw new NativeDaemonConnectorException("missing output stream");
476            } else {
477                try {
478                    mOutputStream.write(rawCmd.getBytes(StandardCharsets.UTF_8));
479                } catch (IOException e) {
480                    throw new NativeDaemonConnectorException("problem sending command", e);
481                }
482            }
483        }
484
485        NativeDaemonEvent event = null;
486        do {
487            event = mResponseQueue.remove(sequenceNumber, timeoutMs, logCmd);
488            if (event == null) {
489                loge("timed-out waiting for response to " + logCmd);
490                throw new NativeDaemonTimeoutException(logCmd, event);
491            }
492            if (VDBG) log("RMV <- {" + event + "}");
493            events.add(event);
494        } while (event.isClassContinue());
495
496        final long endTime = SystemClock.elapsedRealtime();
497        if (endTime - startTime > WARN_EXECUTE_DELAY_MS) {
498            loge("NDC Command {" + logCmd + "} took too long (" + (endTime - startTime) + "ms)");
499        }
500
501        if (event.isClassClientError()) {
502            throw new NativeDaemonArgumentException(logCmd, event);
503        }
504        if (event.isClassServerError()) {
505            throw new NativeDaemonFailureException(logCmd, event);
506        }
507
508        return events.toArray(new NativeDaemonEvent[events.size()]);
509    }
510
511    /**
512     * Append the given argument to {@link StringBuilder}, escaping as needed,
513     * and surrounding with quotes when it contains spaces.
514     */
515    @VisibleForTesting
516    static void appendEscaped(StringBuilder builder, String arg) {
517        final boolean hasSpaces = arg.indexOf(' ') >= 0;
518        if (hasSpaces) {
519            builder.append('"');
520        }
521
522        final int length = arg.length();
523        for (int i = 0; i < length; i++) {
524            final char c = arg.charAt(i);
525
526            if (c == '"') {
527                builder.append("\\\"");
528            } else if (c == '\\') {
529                builder.append("\\\\");
530            } else {
531                builder.append(c);
532            }
533        }
534
535        if (hasSpaces) {
536            builder.append('"');
537        }
538    }
539
540    private static class NativeDaemonArgumentException extends NativeDaemonConnectorException {
541        public NativeDaemonArgumentException(String command, NativeDaemonEvent event) {
542            super(command, event);
543        }
544
545        @Override
546        public IllegalArgumentException rethrowAsParcelableException() {
547            throw new IllegalArgumentException(getMessage(), this);
548        }
549    }
550
551    private static class NativeDaemonFailureException extends NativeDaemonConnectorException {
552        public NativeDaemonFailureException(String command, NativeDaemonEvent event) {
553            super(command, event);
554        }
555    }
556
557    /**
558     * Command builder that handles argument list building. Any arguments must
559     * be separated from base command so they can be properly escaped.
560     */
561    public static class Command {
562        private String mCmd;
563        private ArrayList<Object> mArguments = Lists.newArrayList();
564
565        public Command(String cmd, Object... args) {
566            mCmd = cmd;
567            for (Object arg : args) {
568                appendArg(arg);
569            }
570        }
571
572        public Command appendArg(Object arg) {
573            mArguments.add(arg);
574            return this;
575        }
576    }
577
578    /** {@inheritDoc} */
579    public void monitor() {
580        synchronized (mDaemonLock) { }
581    }
582
583    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
584        mLocalLog.dump(fd, pw, args);
585        pw.println();
586        mResponseQueue.dump(fd, pw, args);
587    }
588
589    private void log(String logstring) {
590        if (mDebug) Slog.d(TAG, logstring);
591        mLocalLog.log(logstring);
592    }
593
594    private void loge(String logstring) {
595        Slog.e(TAG, logstring);
596        mLocalLog.log(logstring);
597    }
598
599    private static class ResponseQueue {
600
601        private static class PendingCmd {
602            public final int cmdNum;
603            public final String logCmd;
604
605            public BlockingQueue<NativeDaemonEvent> responses =
606                    new ArrayBlockingQueue<NativeDaemonEvent>(10);
607
608            // The availableResponseCount member is used to track when we can remove this
609            // instance from the ResponseQueue.
610            // This is used under the protection of a sync of the mPendingCmds object.
611            // A positive value means we've had more writers retreive this object while
612            // a negative value means we've had more readers.  When we've had an equal number
613            // (it goes to zero) we can remove this object from the mPendingCmds list.
614            // Note that we may have more responses for this command (and more readers
615            // coming), but that would result in a new PendingCmd instance being created
616            // and added with the same cmdNum.
617            // Also note that when this goes to zero it just means a parity of readers and
618            // writers have retrieved this object - not that they are done using it.  The
619            // responses queue may well have more responses yet to be read or may get more
620            // responses added to it.  But all those readers/writers have retreived and
621            // hold references to this instance already so it can be removed from
622            // mPendingCmds queue.
623            public int availableResponseCount;
624
625            public PendingCmd(int cmdNum, String logCmd) {
626                this.cmdNum = cmdNum;
627                this.logCmd = logCmd;
628            }
629        }
630
631        private final LinkedList<PendingCmd> mPendingCmds;
632        private int mMaxCount;
633
634        ResponseQueue(int maxCount) {
635            mPendingCmds = new LinkedList<PendingCmd>();
636            mMaxCount = maxCount;
637        }
638
639        public void add(int cmdNum, NativeDaemonEvent response) {
640            PendingCmd found = null;
641            synchronized (mPendingCmds) {
642                for (PendingCmd pendingCmd : mPendingCmds) {
643                    if (pendingCmd.cmdNum == cmdNum) {
644                        found = pendingCmd;
645                        break;
646                    }
647                }
648                if (found == null) {
649                    // didn't find it - make sure our queue isn't too big before adding
650                    while (mPendingCmds.size() >= mMaxCount) {
651                        Slog.e("NativeDaemonConnector.ResponseQueue",
652                                "more buffered than allowed: " + mPendingCmds.size() +
653                                " >= " + mMaxCount);
654                        // let any waiter timeout waiting for this
655                        PendingCmd pendingCmd = mPendingCmds.remove();
656                        Slog.e("NativeDaemonConnector.ResponseQueue",
657                                "Removing request: " + pendingCmd.logCmd + " (" +
658                                pendingCmd.cmdNum + ")");
659                    }
660                    found = new PendingCmd(cmdNum, null);
661                    mPendingCmds.add(found);
662                }
663                found.availableResponseCount++;
664                // if a matching remove call has already retrieved this we can remove this
665                // instance from our list
666                if (found.availableResponseCount == 0) mPendingCmds.remove(found);
667            }
668            try {
669                found.responses.put(response);
670            } catch (InterruptedException e) { }
671        }
672
673        // note that the timeout does not count time in deep sleep.  If you don't want
674        // the device to sleep, hold a wakelock
675        public NativeDaemonEvent remove(int cmdNum, long timeoutMs, String logCmd) {
676            PendingCmd found = null;
677            synchronized (mPendingCmds) {
678                for (PendingCmd pendingCmd : mPendingCmds) {
679                    if (pendingCmd.cmdNum == cmdNum) {
680                        found = pendingCmd;
681                        break;
682                    }
683                }
684                if (found == null) {
685                    found = new PendingCmd(cmdNum, logCmd);
686                    mPendingCmds.add(found);
687                }
688                found.availableResponseCount--;
689                // if a matching add call has already retrieved this we can remove this
690                // instance from our list
691                if (found.availableResponseCount == 0) mPendingCmds.remove(found);
692            }
693            NativeDaemonEvent result = null;
694            try {
695                result = found.responses.poll(timeoutMs, TimeUnit.MILLISECONDS);
696            } catch (InterruptedException e) {}
697            if (result == null) {
698                Slog.e("NativeDaemonConnector.ResponseQueue", "Timeout waiting for response");
699            }
700            return result;
701        }
702
703        public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
704            pw.println("Pending requests:");
705            synchronized (mPendingCmds) {
706                for (PendingCmd pendingCmd : mPendingCmds) {
707                    pw.println("  Cmd " + pendingCmd.cmdNum + " - " + pendingCmd.logCmd);
708                }
709            }
710        }
711    }
712}
713