| /* |
| * Copyright (C) 2007 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.core; |
| |
| import android.util.Log; |
| |
| import java.io.*; |
| import java.lang.Thread; |
| import java.net.*; |
| import java.util.*; |
| |
| /** |
| * TestWebServer is a simulated controllable test server that |
| * can respond to requests from HTTP clients. |
| * |
| * The server can be controlled to change how it reacts to any |
| * requests, and can be told to simulate various events (such as |
| * network failure) that would happen in a real environment. |
| */ |
| class TestWebServer implements HttpConstants { |
| |
| /* static class data/methods */ |
| |
| /* The ANDROID_LOG_TAG */ |
| private final static String LOGTAG = "httpsv"; |
| |
| /* Where worker threads stand idle */ |
| Vector threads = new Vector(); |
| |
| /* List of all active worker threads */ |
| Vector activeThreads = new Vector(); |
| |
| /* timeout on client connections */ |
| int timeout = 0; |
| |
| /* max # worker threads */ |
| int workers = 5; |
| |
| /* Default port for this server to listen on */ |
| final static int DEFAULT_PORT = 8080; |
| |
| /* Default socket timeout value */ |
| final static int DEFAULT_TIMEOUT = 5000; |
| |
| /* Version string (configurable) */ |
| protected String HTTP_VERSION_STRING = "HTTP/1.1"; |
| |
| /* Indicator for whether this server is configured as a HTTP/1.1 |
| * or HTTP/1.0 server |
| */ |
| private boolean http11 = true; |
| |
| /* The thread handling new requests from clients */ |
| private AcceptThread acceptT; |
| |
| /* timeout on client connections */ |
| int mTimeout; |
| |
| /* Server port */ |
| int mPort; |
| |
| /* Switch on/off logging */ |
| boolean mLog = false; |
| |
| /* If set, this will keep connections alive after a request has been |
| * processed. |
| */ |
| boolean keepAlive = true; |
| |
| /* If set, this will cause response data to be sent in 'chunked' format */ |
| boolean chunked = false; |
| |
| /* If set, this will indicate a new redirection host */ |
| String redirectHost = null; |
| |
| /* If set, this indicates the reason for redirection */ |
| int redirectCode = -1; |
| |
| /* Set the number of connections the server will accept before shutdown */ |
| int acceptLimit = 100; |
| |
| /* Count of number of accepted connections */ |
| int acceptedConnections = 0; |
| |
| public TestWebServer() { |
| } |
| |
| /** |
| * Initialize a new server with default port and timeout. |
| * @param log Set true if you want trace output |
| */ |
| public void initServer(boolean log) throws Exception { |
| initServer(DEFAULT_PORT, DEFAULT_TIMEOUT, log); |
| } |
| |
| /** |
| * Initialize a new server with default timeout. |
| * @param port Sets the server to listen on this port |
| * @param log Set true if you want trace output |
| */ |
| public void initServer(int port, boolean log) throws Exception { |
| initServer(port, DEFAULT_TIMEOUT, log); |
| } |
| |
| /** |
| * Initialize a new server with default port and timeout. |
| * @param port Sets the server to listen on this port |
| * @param timeout Indicates the period of time to wait until a socket is |
| * closed |
| * @param log Set true if you want trace output |
| */ |
| public void initServer(int port, int timeout, boolean log) throws Exception { |
| mPort = port; |
| mTimeout = timeout; |
| mLog = log; |
| keepAlive = true; |
| |
| if (acceptT == null) { |
| acceptT = new AcceptThread(); |
| acceptT.init(); |
| acceptT.start(); |
| } |
| } |
| |
| /** |
| * Print to the log file (if logging enabled) |
| * @param s String to send to the log |
| */ |
| protected void log(String s) { |
| if (mLog) { |
| Log.d(LOGTAG, s); |
| } |
| } |
| |
| /** |
| * Set the server to be an HTTP/1.0 or HTTP/1.1 server. |
| * This should be called prior to any requests being sent |
| * to the server. |
| * @param set True for the server to be HTTP/1.1, false for HTTP/1.0 |
| */ |
| public void setHttpVersion11(boolean set) { |
| http11 = set; |
| if (set) { |
| HTTP_VERSION_STRING = "HTTP/1.1"; |
| } else { |
| HTTP_VERSION_STRING = "HTTP/1.0"; |
| } |
| } |
| |
| /** |
| * Call this to determine whether server connection should remain open |
| * @param value Set true to keep connections open after a request |
| * completes |
| */ |
| public void setKeepAlive(boolean value) { |
| keepAlive = value; |
| } |
| |
| /** |
| * Call this to indicate whether chunked data should be used |
| * @param value Set true to make server respond with chunk encoded |
| * content data. |
| */ |
| public void setChunked(boolean value) { |
| chunked = value; |
| } |
| |
| /** |
| * Call this to specify the maximum number of sockets to accept |
| * @param limit The number of sockets to accept |
| */ |
| public void setAcceptLimit(int limit) { |
| acceptLimit = limit; |
| } |
| |
| /** |
| * Call this to indicate redirection port requirement. |
| * When this value is set, the server will respond to a request with |
| * a redirect code with the Location response header set to the value |
| * specified. |
| * @param redirect The location to be redirected to |
| * @param redirectCode The code to send when redirecting |
| */ |
| public void setRedirect(String redirect, int code) { |
| redirectHost = redirect; |
| redirectCode = code; |
| log("Server will redirect output to "+redirect+" code "+code); |
| } |
| |
| /** |
| * Cause the thread accepting connections on the server socket to close |
| */ |
| public void close() { |
| /* Stop the Accept thread */ |
| if (acceptT != null) { |
| log("Closing AcceptThread"+acceptT); |
| acceptT.close(); |
| acceptT = null; |
| } |
| } |
| /** |
| * The AcceptThread is responsible for initiating worker threads |
| * to handle incoming requests from clients. |
| */ |
| class AcceptThread extends Thread { |
| |
| ServerSocket ss = null; |
| boolean running = false; |
| |
| public void init() { |
| // Networking code doesn't support ServerSocket(port) yet |
| InetSocketAddress ia = new InetSocketAddress(mPort); |
| while (true) { |
| try { |
| ss = new ServerSocket(); |
| // Socket timeout functionality is not available yet |
| //ss.setSoTimeout(5000); |
| ss.setReuseAddress(true); |
| ss.bind(ia); |
| break; |
| } catch (IOException e) { |
| log("IOException in AcceptThread.init()"); |
| e.printStackTrace(); |
| // wait and retry |
| try { |
| Thread.sleep(1000); |
| } catch (InterruptedException e1) { |
| // TODO Auto-generated catch block |
| e1.printStackTrace(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Main thread responding to new connections |
| */ |
| public synchronized void run() { |
| running = true; |
| try { |
| while (running) { |
| // Log.d(LOGTAG, "TestWebServer run() calling accept()"); |
| Socket s = ss.accept(); |
| acceptedConnections++; |
| if (acceptedConnections >= acceptLimit) { |
| running = false; |
| } |
| |
| Worker w = null; |
| synchronized (threads) { |
| if (threads.isEmpty()) { |
| Worker ws = new Worker(); |
| ws.setSocket(s); |
| activeThreads.addElement(ws); |
| (new Thread(ws, "additional worker")).start(); |
| } else { |
| w = (Worker) threads.elementAt(0); |
| threads.removeElementAt(0); |
| w.setSocket(s); |
| } |
| } |
| } |
| } catch (SocketException e) { |
| log("SocketException in AcceptThread: probably closed during accept"); |
| running = false; |
| } catch (IOException e) { |
| log("IOException in AcceptThread"); |
| e.printStackTrace(); |
| running = false; |
| } |
| log("AcceptThread terminated" + this); |
| } |
| |
| // Close this socket |
| public void close() { |
| try { |
| running = false; |
| /* Stop server socket from processing further. Currently |
| this does not cause the SocketException from ss.accept |
| therefore the acceptLimit functionality has been added |
| to circumvent this limitation */ |
| ss.close(); |
| |
| // Stop worker threads from continuing |
| for (Enumeration e = activeThreads.elements(); e.hasMoreElements();) { |
| Worker w = (Worker)e.nextElement(); |
| w.close(); |
| } |
| activeThreads.clear(); |
| |
| } catch (IOException e) { |
| /* We are shutting down the server, so we expect |
| * things to die. Don't propagate. |
| */ |
| log("IOException caught by server socket close"); |
| } |
| } |
| } |
| |
| // Size of buffer for reading from the connection |
| final static int BUF_SIZE = 2048; |
| |
| /* End of line byte sequence */ |
| static final byte[] EOL = {(byte)'\r', (byte)'\n' }; |
| |
| /** |
| * The worker thread handles all interactions with a current open |
| * connection. If pipelining is turned on, this will allow this |
| * thread to continuously operate on numerous requests before the |
| * connection is closed. |
| */ |
| class Worker implements HttpConstants, Runnable { |
| |
| /* buffer to use to hold request data */ |
| byte[] buf; |
| |
| /* Socket to client we're handling */ |
| private Socket s; |
| |
| /* Reference to current request method ID */ |
| private int requestMethod; |
| |
| /* Reference to current requests test file/data */ |
| private String testID; |
| |
| /* Reference to test number from testID */ |
| private int testNum; |
| |
| /* Reference to whether new request has been initiated yet */ |
| private boolean readStarted; |
| |
| /* Indicates whether current request has any data content */ |
| private boolean hasContent = false; |
| |
| boolean running = false; |
| |
| /* Request headers are stored here */ |
| private Hashtable<String, String> headers = new Hashtable<String, String>(); |
| |
| /* Create a new worker thread */ |
| Worker() { |
| buf = new byte[BUF_SIZE]; |
| s = null; |
| } |
| |
| /** |
| * Called by the AcceptThread to unblock this Worker to process |
| * a request. |
| * @param s The socket on which the connection has been made |
| */ |
| synchronized void setSocket(Socket s) { |
| this.s = s; |
| notify(); |
| } |
| |
| /** |
| * Called by the accept thread when it's closing. Potentially unblocks |
| * the worker thread to terminate properly |
| */ |
| synchronized void close() { |
| running = false; |
| notify(); |
| } |
| |
| /** |
| * Main worker thread. This will wait until a request has |
| * been identified by the accept thread upon which it will |
| * service the thread. |
| */ |
| public synchronized void run() { |
| running = true; |
| while(running) { |
| if (s == null) { |
| /* nothing to do */ |
| try { |
| log(this+" Moving to wait state"); |
| wait(); |
| } catch (InterruptedException e) { |
| /* should not happen */ |
| continue; |
| } |
| if (!running) break; |
| } |
| try { |
| handleClient(); |
| } catch (Exception e) { |
| e.printStackTrace(); |
| } |
| /* go back in wait queue if there's fewer |
| * than numHandler connections. |
| */ |
| s = null; |
| Vector pool = threads; |
| synchronized (pool) { |
| if (pool.size() >= workers) { |
| /* too many threads, exit this one */ |
| activeThreads.remove(this); |
| return; |
| } else { |
| pool.addElement(this); |
| } |
| } |
| } |
| log(this+" terminated"); |
| } |
| |
| /** |
| * Zero out the buffer from last time |
| */ |
| private void clearBuffer() { |
| for (int i = 0; i < BUF_SIZE; i++) { |
| buf[i] = 0; |
| } |
| } |
| |
| /** |
| * Utility method to read a line of data from the input stream |
| * @param is Inputstream to read |
| * @return number of bytes read |
| */ |
| private int readOneLine(InputStream is) { |
| |
| int read = 0; |
| |
| clearBuffer(); |
| try { |
| log("Reading one line: started ="+readStarted+" avail="+is.available()); |
| while ((!readStarted) || (is.available() > 0)) { |
| int data = is.read(); |
| // We shouldn't get EOF but we need tdo check |
| if (data == -1) { |
| log("EOF returned"); |
| return -1; |
| } |
| |
| buf[read] = (byte)data; |
| |
| System.out.print((char)data); |
| |
| readStarted = true; |
| if (buf[read++]==(byte)'\n') { |
| System.out.println(); |
| return read; |
| } |
| } |
| } catch (IOException e) { |
| log("IOException from readOneLine"); |
| e.printStackTrace(); |
| } |
| return read; |
| } |
| |
| /** |
| * Read a chunk of data |
| * @param is Stream from which to read data |
| * @param length Amount of data to read |
| * @return number of bytes read |
| */ |
| private int readData(InputStream is, int length) { |
| int read = 0; |
| int count; |
| // At the moment we're only expecting small data amounts |
| byte[] buf = new byte[length]; |
| |
| try { |
| while (is.available() > 0) { |
| count = is.read(buf, read, length-read); |
| read += count; |
| } |
| } catch (IOException e) { |
| log("IOException from readData"); |
| e.printStackTrace(); |
| } |
| return read; |
| } |
| |
| /** |
| * Read the status line from the input stream extracting method |
| * information. |
| * @param is Inputstream to read |
| * @return number of bytes read |
| */ |
| private int parseStatusLine(InputStream is) { |
| int index; |
| int nread = 0; |
| |
| log("Parse status line"); |
| // Check for status line first |
| nread = readOneLine(is); |
| // Bomb out if stream closes prematurely |
| if (nread == -1) { |
| requestMethod = UNKNOWN_METHOD; |
| return -1; |
| } |
| |
| if (buf[0] == (byte)'G' && |
| buf[1] == (byte)'E' && |
| buf[2] == (byte)'T' && |
| buf[3] == (byte)' ') { |
| requestMethod = GET_METHOD; |
| log("GET request"); |
| index = 4; |
| } else if (buf[0] == (byte)'H' && |
| buf[1] == (byte)'E' && |
| buf[2] == (byte)'A' && |
| buf[3] == (byte)'D' && |
| buf[4] == (byte)' ') { |
| requestMethod = HEAD_METHOD; |
| log("HEAD request"); |
| index = 5; |
| } else if (buf[0] == (byte)'P' && |
| buf[1] == (byte)'O' && |
| buf[2] == (byte)'S' && |
| buf[3] == (byte)'T' && |
| buf[4] == (byte)' ') { |
| requestMethod = POST_METHOD; |
| log("POST request"); |
| index = 5; |
| } else { |
| // Unhandled request |
| requestMethod = UNKNOWN_METHOD; |
| return -1; |
| } |
| |
| // A valid method we understand |
| if (requestMethod > UNKNOWN_METHOD) { |
| // Read file name |
| int i = index; |
| while (buf[i] != (byte)' ') { |
| // There should be HTTP/1.x at the end |
| if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) { |
| requestMethod = UNKNOWN_METHOD; |
| return -1; |
| } |
| i++; |
| } |
| |
| testID = new String(buf, 0, index, i-index); |
| if (testID.startsWith("/")) { |
| testID = testID.substring(1); |
| } |
| |
| return nread; |
| } |
| return -1; |
| } |
| |
| /** |
| * Read a header from the input stream |
| * @param is Inputstream to read |
| * @return number of bytes read |
| */ |
| private int parseHeader(InputStream is) { |
| int index = 0; |
| int nread = 0; |
| log("Parse a header"); |
| // Check for status line first |
| nread = readOneLine(is); |
| // Bomb out if stream closes prematurely |
| if (nread == -1) { |
| requestMethod = UNKNOWN_METHOD; |
| return -1; |
| } |
| // Read header entry 'Header: data' |
| int i = index; |
| while (buf[i] != (byte)':') { |
| // There should be an entry after the header |
| |
| if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) { |
| return UNKNOWN_METHOD; |
| } |
| i++; |
| } |
| |
| String headerName = new String(buf, 0, i); |
| i++; // Over ':' |
| while (buf[i] == ' ') { |
| i++; |
| } |
| String headerValue = new String(buf, i, nread-1); |
| |
| headers.put(headerName, headerValue); |
| return nread; |
| } |
| |
| /** |
| * Read all headers from the input stream |
| * @param is Inputstream to read |
| * @return number of bytes read |
| */ |
| private int readHeaders(InputStream is) { |
| int nread = 0; |
| log("Read headers"); |
| // Headers should be terminated by empty CRLF line |
| while (true) { |
| int headerLen = 0; |
| headerLen = parseHeader(is); |
| if (headerLen == -1) |
| return -1; |
| nread += headerLen; |
| if (headerLen <= 2) { |
| return nread; |
| } |
| } |
| } |
| |
| /** |
| * Read content data from the input stream |
| * @param is Inputstream to read |
| * @return number of bytes read |
| */ |
| private int readContent(InputStream is) { |
| int nread = 0; |
| log("Read content"); |
| String lengthString = headers.get(requestHeaders[REQ_CONTENT_LENGTH]); |
| int length = new Integer(lengthString).intValue(); |
| |
| // Read content |
| length = readData(is, length); |
| return length; |
| } |
| |
| /** |
| * The main loop, reading requests. |
| */ |
| void handleClient() throws IOException { |
| InputStream is = new BufferedInputStream(s.getInputStream()); |
| PrintStream ps = new PrintStream(s.getOutputStream()); |
| int nread = 0; |
| |
| /* we will only block in read for this many milliseconds |
| * before we fail with java.io.InterruptedIOException, |
| * at which point we will abandon the connection. |
| */ |
| s.setSoTimeout(mTimeout); |
| s.setTcpNoDelay(true); |
| |
| do { |
| nread = parseStatusLine(is); |
| if (requestMethod != UNKNOWN_METHOD) { |
| |
| // If status line found, read any headers |
| nread = readHeaders(is); |
| |
| // Then read content (if any) |
| // TODO handle chunked encoding from the client |
| if (headers.get(requestHeaders[REQ_CONTENT_LENGTH]) != null) { |
| nread = readContent(is); |
| } |
| } else { |
| if (nread > 0) { |
| /* we don't support this method */ |
| ps.print(HTTP_VERSION_STRING + " " + HTTP_BAD_METHOD + |
| " unsupported method type: "); |
| ps.write(buf, 0, 5); |
| ps.write(EOL); |
| ps.flush(); |
| } else { |
| } |
| if (!keepAlive || nread <= 0) { |
| headers.clear(); |
| readStarted = false; |
| |
| log("SOCKET CLOSED"); |
| s.close(); |
| return; |
| } |
| } |
| |
| // Reset test number prior to outputing data |
| testNum = -1; |
| |
| // Write out the data |
| printStatus(ps); |
| printHeaders(ps); |
| |
| // Write line between headers and body |
| psWriteEOL(ps); |
| |
| // Write the body |
| if (redirectCode == -1) { |
| switch (requestMethod) { |
| case GET_METHOD: |
| if ((testNum < 0) || (testNum > TestWebData.tests.length - 1)) { |
| send404(ps); |
| } else { |
| sendFile(ps); |
| } |
| break; |
| case HEAD_METHOD: |
| // Nothing to do |
| break; |
| case POST_METHOD: |
| // Post method write body data |
| if ((testNum > 0) || (testNum < TestWebData.tests.length - 1)) { |
| sendFile(ps); |
| } |
| |
| break; |
| default: |
| break; |
| } |
| } else { // Redirecting |
| switch (redirectCode) { |
| case 301: |
| // Seems 301 needs a body by neon (although spec |
| // says SHOULD). |
| psPrint(ps, TestWebData.testServerResponse[TestWebData.REDIRECT_301]); |
| break; |
| case 302: |
| // |
| psPrint(ps, TestWebData.testServerResponse[TestWebData.REDIRECT_302]); |
| break; |
| case 303: |
| psPrint(ps, TestWebData.testServerResponse[TestWebData.REDIRECT_303]); |
| break; |
| case 307: |
| psPrint(ps, TestWebData.testServerResponse[TestWebData.REDIRECT_307]); |
| break; |
| default: |
| break; |
| } |
| } |
| |
| ps.flush(); |
| |
| // Reset for next request |
| readStarted = false; |
| headers.clear(); |
| |
| } while (keepAlive); |
| |
| log("SOCKET CLOSED"); |
| s.close(); |
| } |
| |
| // Print string to log and output stream |
| void psPrint(PrintStream ps, String s) throws IOException { |
| log(s); |
| ps.print(s); |
| } |
| |
| // Print bytes to log and output stream |
| void psWrite(PrintStream ps, byte[] bytes, int len) throws IOException { |
| log(new String(bytes)); |
| ps.write(bytes, 0, len); |
| } |
| |
| // Print CRLF to log and output stream |
| void psWriteEOL(PrintStream ps) throws IOException { |
| log("CRLF"); |
| ps.write(EOL); |
| } |
| |
| |
| // Print status to log and output stream |
| void printStatus(PrintStream ps) throws IOException { |
| // Handle redirects first. |
| if (redirectCode != -1) { |
| log("REDIRECTING TO "+redirectHost+" status "+redirectCode); |
| psPrint(ps, HTTP_VERSION_STRING + " " + redirectCode +" Moved permanently"); |
| psWriteEOL(ps); |
| psPrint(ps, "Location: " + redirectHost); |
| psWriteEOL(ps); |
| return; |
| } |
| |
| |
| if (testID.startsWith("test")) { |
| testNum = Integer.valueOf(testID.substring(4))-1; |
| } |
| |
| if ((testNum < 0) || (testNum > TestWebData.tests.length - 1)) { |
| psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_NOT_FOUND + " not found"); |
| psWriteEOL(ps); |
| } else { |
| psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_OK+" OK"); |
| psWriteEOL(ps); |
| } |
| |
| log("Status sent"); |
| } |
| /** |
| * Create the server response and output to the stream |
| * @param ps The PrintStream to output response headers and data to |
| */ |
| void printHeaders(PrintStream ps) throws IOException { |
| psPrint(ps,"Server: TestWebServer"+mPort); |
| psWriteEOL(ps); |
| psPrint(ps, "Date: " + (new Date())); |
| psWriteEOL(ps); |
| psPrint(ps, "Connection: " + ((keepAlive) ? "Keep-Alive" : "Close")); |
| psWriteEOL(ps); |
| |
| // Yuk, if we're not redirecting, we add the file details |
| if (redirectCode == -1) { |
| |
| if (!TestWebData.testParams[testNum].testDir) { |
| if (chunked) { |
| psPrint(ps, "Transfer-Encoding: chunked"); |
| } else { |
| psPrint(ps, "Content-length: "+TestWebData.testParams[testNum].testLength); |
| } |
| psWriteEOL(ps); |
| |
| psPrint(ps,"Last Modified: " + (new |
| Date(TestWebData.testParams[testNum].testLastModified))); |
| psWriteEOL(ps); |
| |
| psPrint(ps, "Content-type: " + TestWebData.testParams[testNum].testType); |
| psWriteEOL(ps); |
| } else { |
| psPrint(ps, "Content-type: text/html"); |
| psWriteEOL(ps); |
| } |
| } else { |
| // Content-length of 301, 302, 303, 307 are the same. |
| psPrint(ps, "Content-length: "+(TestWebData.testServerResponse[TestWebData.REDIRECT_301]).length()); |
| psWriteEOL(ps); |
| psWriteEOL(ps); |
| } |
| log("Headers sent"); |
| |
| } |
| |
| /** |
| * Sends the 404 not found message |
| * @param ps The PrintStream to write to |
| */ |
| void send404(PrintStream ps) throws IOException { |
| ps.println("Not Found\n\n"+ |
| "The requested resource was not found.\n"); |
| } |
| |
| /** |
| * Sends the data associated with the headers |
| * @param ps The PrintStream to write to |
| */ |
| void sendFile(PrintStream ps) throws IOException { |
| // For now just make a chunk with the whole of the test data |
| // It might be worth making this multiple chunks for large |
| // test data to test multiple chunks. |
| int dataSize = TestWebData.tests[testNum].length; |
| if (chunked) { |
| psPrint(ps, Integer.toHexString(dataSize)); |
| psWriteEOL(ps); |
| psWrite(ps, TestWebData.tests[testNum], dataSize); |
| psWriteEOL(ps); |
| psPrint(ps, "0"); |
| psWriteEOL(ps); |
| psWriteEOL(ps); |
| } else { |
| psWrite(ps, TestWebData.tests[testNum], dataSize); |
| } |
| } |
| } |
| } |