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 tests.support;
18
19import java.io.*;
20import java.lang.Thread;
21import java.net.*;
22import java.text.SimpleDateFormat;
23import java.util.*;
24import java.util.concurrent.ConcurrentHashMap;
25import java.util.logging.Logger;
26
27/**
28 * TestWebServer is a simulated controllable test server that
29 * can respond to requests from HTTP clients.
30 *
31 * The server can be controlled to change how it reacts to any
32 * requests, and can be told to simulate various events (such as
33 * network failure) that would happen in a real environment.
34 */
35public class Support_TestWebServer implements Support_HttpConstants {
36
37    /* static class data/methods */
38
39    /* The ANDROID_LOG_TAG */
40    private final static String LOGTAG = "httpsv";
41
42    /** maps the recently requested URLs to the full request snapshot */
43    private final Map<String, Request> pathToRequest
44            = new ConcurrentHashMap<String, Request>();
45
46    /* timeout on client connections */
47    int timeout = 0;
48
49    /* Default socket timeout value */
50    final static int DEFAULT_TIMEOUT = 5000;
51
52    /* Version string (configurable) */
53    protected String HTTP_VERSION_STRING = "HTTP/1.1";
54
55    /* Indicator for whether this server is configured as a HTTP/1.1
56     * or HTTP/1.0 server
57     */
58    private boolean http11 = true;
59
60    /* The thread handling new requests from clients */
61    private AcceptThread acceptT;
62
63    /* timeout on client connections */
64    int mTimeout;
65
66    /* Server port */
67    int mPort;
68
69    /* Switch on/off logging */
70    boolean mLog = false;
71
72    /* Minimum delay before sending each response */
73    int mDelay = 0;
74
75    /* If set, this will keep connections alive after a request has been
76     * processed.
77     */
78    boolean keepAlive = true;
79
80    /* If set, this will cause response data to be sent in 'chunked' format */
81    boolean chunked = false;
82    int maxChunkSize = 1024;
83
84    /* If set, this will indicate a new redirection host */
85    String redirectHost = null;
86
87    /* If set, this indicates the reason for redirection */
88    int redirectCode = -1;
89
90    public Support_TestWebServer() {
91    }
92
93    /**
94     * @param servePath the path to the dynamic web test data
95     * @param contentType the type of the dynamic web test data
96     */
97    public int initServer(String servePath, String contentType) throws Exception {
98        Support_TestWebData.initDynamicTestWebData(servePath, contentType);
99        return initServer();
100    }
101
102    public int initServer() throws Exception {
103        mTimeout = DEFAULT_TIMEOUT;
104        mLog = false;
105        keepAlive = true;
106        if (acceptT == null) {
107            acceptT = new AcceptThread();
108            mPort = acceptT.init();
109            acceptT.start();
110        }
111        return mPort;
112    }
113
114    /**
115     * Print to the log file (if logging enabled)
116     * @param s String to send to the log
117     */
118    protected void log(String s) {
119        if (mLog) {
120            Logger.global.fine(s);
121        }
122    }
123
124    /**
125     * Set the server to be an HTTP/1.0 or HTTP/1.1 server.
126     * This should be called prior to any requests being sent
127     * to the server.
128     * @param set True for the server to be HTTP/1.1, false for HTTP/1.0
129     */
130    public void setHttpVersion11(boolean set) {
131        http11 = set;
132        if (set) {
133            HTTP_VERSION_STRING = "HTTP/1.1";
134        } else {
135            HTTP_VERSION_STRING = "HTTP/1.0";
136        }
137    }
138
139    /**
140     * Call this to determine whether server connection should remain open
141     * @param value Set true to keep connections open after a request
142     *              completes
143     */
144    public void setKeepAlive(boolean value) {
145        keepAlive = value;
146    }
147
148    /**
149     * Call this to indicate whether chunked data should be used
150     * @param value Set true to make server respond with chunk encoded
151     *              content data.
152     */
153    public void setChunked(boolean value) {
154        chunked = value;
155    }
156
157    /**
158     * Sets the maximum byte count of any chunk if the server is using
159     * the "chunked" transfer encoding.
160     */
161    public void setMaxChunkSize(int maxChunkSize) {
162        this.maxChunkSize = maxChunkSize;
163    }
164
165    /**
166     * Call this to indicate redirection port requirement.
167     * When this value is set, the server will respond to a request with
168     * a redirect code with the Location response header set to the value
169     * specified.
170     * @param redirect The location to be redirected to
171     * @param code The code to send when redirecting
172     */
173    public void setRedirect(String redirect, int code) {
174        redirectHost = redirect;
175        redirectCode = code;
176        log("Server will redirect output to "+redirect+" code "+code);
177    }
178
179    /**
180     * Call this to introduce a minimum delay before server responds to requests. When this value is
181     * not set, no delay will be introduced.
182     * @param delay The delay in milliseconds
183     */
184    public void setDelay(int delay) {
185        mDelay = delay;
186    }
187
188    /**
189     * Returns a map from recently-requested paths (like "/index.html") to a
190     * snapshot of the request data.
191     */
192    public Map<String, Request> pathToRequest() {
193        return pathToRequest;
194    }
195
196    /**
197     * Cause the thread accepting connections on the server socket to close
198     */
199    public void close() {
200        /* Stop the Accept thread */
201        if (acceptT != null) {
202            log("Closing AcceptThread"+acceptT);
203            acceptT.close();
204            acceptT = null;
205        }
206    }
207    /**
208     * The AcceptThread is responsible for initiating worker threads
209     * to handle incoming requests from clients.
210     */
211    class AcceptThread extends Thread {
212
213        ServerSocket ss = null;
214        volatile boolean closed = false;
215
216        public int init() throws IOException {
217            ss = new ServerSocket(0);
218            ss.setSoTimeout(5000);
219            ss.setReuseAddress(true);
220            return ss.getLocalPort();
221        }
222
223        /**
224         * Main thread responding to new connections
225         */
226        public void run() {
227            while (!closed) {
228                try {
229                    Socket s = ss.accept();
230                    new Thread(new Worker(s).setDelay(mDelay), "additional worker").start();
231                } catch (SocketException e) {
232                    log(e.getMessage());
233                } catch (IOException e) {
234                    log(e.getMessage());
235                }
236            }
237            log("AcceptThread terminated" + this);
238        }
239
240        // Close this socket
241        public void close() {
242            try {
243                closed = true;
244                /* Stop server socket from processing further. Currently
245                   this does not cause the SocketException from ss.accept
246                   therefore the acceptLimit functionality has been added
247                   to circumvent this limitation */
248                ss.close();
249            } catch (IOException e) {
250                /* We are shutting down the server, so we expect
251                 * things to die. Don't propagate.
252                 */
253                log("IOException caught by server socket close");
254            }
255        }
256    }
257
258    // Size of buffer for reading from the connection
259    final static int BUF_SIZE = 2048;
260
261    /* End of line byte sequence */
262    static final byte[] EOL = {(byte)'\r', (byte)'\n' };
263
264    /**
265     * An immutable snapshot of an HTTP request.
266     */
267    public static class Request {
268        private final String path;
269        private final Map<String, String> headers;
270        // TODO: include posted content?
271
272        public Request(String path, Map<String, String> headers) {
273            this.path = path;
274            this.headers = new LinkedHashMap<String, String>(headers);
275        }
276
277        public String getPath() {
278            return path;
279        }
280
281        public Map<String, String> getHeaders() {
282            return headers;
283        }
284    }
285
286    /**
287     * The worker thread handles all interactions with a current open
288     * connection. If pipelining is turned on, this will allow this
289     * thread to continuously operate on numerous requests before the
290     * connection is closed.
291     */
292    class Worker implements Support_HttpConstants, Runnable {
293
294        /* buffer to use to hold request data */
295        byte[] buf;
296
297        /* Socket to client we're handling */
298        private Socket s;
299
300        /* Reference to current request method ID */
301        private int requestMethod;
302
303        /* Reference to current requests test file/data */
304        private String testID;
305
306        /* The requested path, such as "/test1" */
307        private String path;
308
309        /* Reference to test number from testID */
310        private int testNum;
311
312        /* Reference to whether new request has been initiated yet */
313        private boolean readStarted;
314
315        /* Indicates whether current request has any data content */
316        private boolean hasContent = false;
317
318        /* Optional delay before sending response */
319        private int delay = 0;
320
321        /* Request headers are stored here */
322        private Map<String, String> headers = new LinkedHashMap<String, String>();
323
324        /* Create a new worker thread */
325        Worker(Socket s) {
326            this.buf = new byte[BUF_SIZE];
327            this.s = s;
328        }
329
330        Worker setDelay(int delay) {
331            this.delay = delay;
332            return this;
333        }
334
335        public synchronized void run() {
336            try {
337                handleClient();
338            } catch (Exception e) {
339                log("Exception during handleClient in the TestWebServer: " + e.getMessage());
340            }
341            log(this+" terminated");
342        }
343
344        /**
345         * Zero out the buffer from last time
346         */
347        private void clearBuffer() {
348            for (int i = 0; i < BUF_SIZE; i++) {
349                buf[i] = 0;
350            }
351        }
352
353        /**
354         * Utility method to read a line of data from the input stream
355         * @param is Inputstream to read
356         * @return number of bytes read
357         */
358        private int readOneLine(InputStream is) {
359
360            int read = 0;
361
362            clearBuffer();
363            try {
364                log("Reading one line: started ="+readStarted+" avail="+is.available());
365                StringBuilder log = new StringBuilder();
366                while ((!readStarted) || (is.available() > 0)) {
367                    int data = is.read();
368                    // We shouldn't get EOF but we need tdo check
369                    if (data == -1) {
370                        log("EOF returned");
371                        return -1;
372                    }
373
374                    buf[read] = (byte)data;
375
376                    log.append((char)data);
377
378                    readStarted = true;
379                    if (buf[read++]==(byte)'\n') {
380                        log(log.toString());
381                        return read;
382                    }
383                }
384            } catch (IOException e) {
385                log("IOException from readOneLine");
386            }
387            return read;
388        }
389
390        /**
391         * Read a chunk of data
392         * @param is Stream from which to read data
393         * @param length Amount of data to read
394         * @return number of bytes read
395         */
396        private int readData(InputStream is, int length) {
397            int read = 0;
398            int count;
399            // At the moment we're only expecting small data amounts
400            byte[] buf = new byte[length];
401
402            try {
403                while (is.available() > 0) {
404                    count = is.read(buf, read, length-read);
405                    read += count;
406                }
407            } catch (IOException e) {
408                log("IOException from readData");
409            }
410            return read;
411        }
412
413        /**
414         * Read the status line from the input stream extracting method
415         * information.
416         * @param is Inputstream to read
417         * @return number of bytes read
418         */
419        private int parseStatusLine(InputStream is) {
420            int index;
421            int nread = 0;
422
423            log("Parse status line");
424            // Check for status line first
425            nread = readOneLine(is);
426            // Bomb out if stream closes prematurely
427            if (nread == -1) {
428                requestMethod = UNKNOWN_METHOD;
429                return -1;
430            }
431
432            if (buf[0] == (byte)'G' &&
433                buf[1] == (byte)'E' &&
434                buf[2] == (byte)'T' &&
435                buf[3] == (byte)' ') {
436                requestMethod = GET_METHOD;
437                log("GET request");
438                index = 4;
439            } else if (buf[0] == (byte)'H' &&
440                       buf[1] == (byte)'E' &&
441                       buf[2] == (byte)'A' &&
442                       buf[3] == (byte)'D' &&
443                       buf[4] == (byte)' ') {
444                requestMethod = HEAD_METHOD;
445                log("HEAD request");
446                index = 5;
447            } else if (buf[0] == (byte)'P' &&
448                       buf[1] == (byte)'O' &&
449                       buf[2] == (byte)'S' &&
450                       buf[3] == (byte)'T' &&
451                       buf[4] == (byte)' ') {
452                requestMethod = POST_METHOD;
453                log("POST request");
454                index = 5;
455            } else {
456                // Unhandled request
457                requestMethod = UNKNOWN_METHOD;
458                return -1;
459            }
460
461            // A valid method we understand
462            if (requestMethod > UNKNOWN_METHOD) {
463                // Read file name
464                int i = index;
465                while (buf[i] != (byte)' ') {
466                    // There should be HTTP/1.x at the end
467                    if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) {
468                        requestMethod = UNKNOWN_METHOD;
469                        return -1;
470                    }
471                    i++;
472                }
473
474                path = new String(buf, 0, index, i-index);
475                testID = path.substring(1);
476
477                return nread;
478            }
479            return -1;
480        }
481
482        /**
483         * Read a header from the input stream
484         * @param is Inputstream to read
485         * @return number of bytes read
486         */
487        private int parseHeader(InputStream is) {
488            int index = 0;
489            int nread = 0;
490            log("Parse a header");
491            // Check for status line first
492            nread = readOneLine(is);
493            // Bomb out if stream closes prematurely
494            if (nread == -1) {
495                requestMethod = UNKNOWN_METHOD;
496                return -1;
497            }
498            // Read header entry 'Header: data'
499            int i = index;
500            while (buf[i] != (byte)':') {
501                // There should be an entry after the header
502
503                if ((buf[i] == (byte)'\n') || (buf[i] == (byte)'\r')) {
504                    return UNKNOWN_METHOD;
505                }
506                i++;
507            }
508
509            String headerName = new String(buf, 0, i);
510            i++; // Over ':'
511            while (buf[i] == ' ') {
512                i++;
513            }
514            String headerValue = new String(buf, i, nread - i - 2); // drop \r\n
515
516            headers.put(headerName, headerValue);
517            return nread;
518        }
519
520        /**
521         * Read all headers from the input stream
522         * @param is Inputstream to read
523         * @return number of bytes read
524         */
525        private int readHeaders(InputStream is) {
526            int nread = 0;
527            log("Read headers");
528            // Headers should be terminated by empty CRLF line
529            while (true) {
530                int headerLen = 0;
531                headerLen = parseHeader(is);
532                if (headerLen == -1)
533                    return -1;
534                nread += headerLen;
535                if (headerLen <= 2) {
536                    return nread;
537                }
538            }
539        }
540
541        /**
542         * Read content data from the input stream
543         * @param is Inputstream to read
544         * @return number of bytes read
545         */
546        private int readContent(InputStream is) {
547            int nread = 0;
548            log("Read content");
549            String lengthString = headers.get(requestHeaders[REQ_CONTENT_LENGTH]);
550            int length = new Integer(lengthString).intValue();
551
552            // Read content
553            length = readData(is, length);
554            return length;
555        }
556
557        /**
558         * The main loop, reading requests.
559         */
560        void handleClient() throws IOException {
561            InputStream is = new BufferedInputStream(s.getInputStream());
562            PrintStream ps = new PrintStream(s.getOutputStream());
563            int nread = 0;
564
565            /* we will only block in read for this many milliseconds
566             * before we fail with java.io.InterruptedIOException,
567             * at which point we will abandon the connection.
568             */
569            s.setSoTimeout(mTimeout);
570            s.setTcpNoDelay(true);
571
572            do {
573                nread = parseStatusLine(is);
574                if (requestMethod != UNKNOWN_METHOD) {
575
576                    // If status line found, read any headers
577                    nread = readHeaders(is);
578
579                    pathToRequest().put(path, new Request(path, headers));
580
581                    // Then read content (if any)
582                    // TODO handle chunked encoding from the client
583                    if (headers.get(requestHeaders[REQ_CONTENT_LENGTH]) != null) {
584                        nread = readContent(is);
585                    }
586                } else {
587                    if (nread > 0) {
588                        /* we don't support this method */
589                        ps.print(HTTP_VERSION_STRING + " " + HTTP_BAD_METHOD +
590                                 " unsupported method type: ");
591                        ps.write(buf, 0, 5);
592                        ps.write(EOL);
593                        ps.flush();
594                    } else {
595                    }
596                    if (!keepAlive || nread <= 0) {
597                        headers.clear();
598                        readStarted = false;
599
600                        log("SOCKET CLOSED");
601                        s.close();
602                        return;
603                    }
604                }
605
606                // Reset test number prior to outputing data
607                testNum = -1;
608
609                // Delay before sending response
610                if (mDelay > 0) {
611                    try {
612                        Thread.sleep(mDelay);
613                    } catch (InterruptedException e) {
614                        // Ignored
615                    }
616                }
617
618                // Write out the data
619                printStatus(ps);
620                printHeaders(ps);
621
622                // Write line between headers and body
623                psWriteEOL(ps);
624
625                // Write the body
626                if (redirectCode == -1) {
627                    switch (requestMethod) {
628                        case GET_METHOD:
629                            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
630                                send404(ps);
631                            } else {
632                                sendFile(ps);
633                            }
634                            break;
635                        case HEAD_METHOD:
636                            // Nothing to do
637                            break;
638                        case POST_METHOD:
639                            // Post method write body data
640                            if ((testNum > 0) || (testNum < Support_TestWebData.tests.length - 1)) {
641                                sendFile(ps);
642                            }
643
644                            break;
645                        default:
646                            break;
647                    }
648                } else { // Redirecting
649                    switch (redirectCode) {
650                        case 301:
651                            // Seems 301 needs a body by neon (although spec
652                            // says SHOULD).
653                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_301]);
654                            break;
655                        case 302:
656                            //
657                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_302]);
658                            break;
659                        case 303:
660                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_303]);
661                            break;
662                        case 307:
663                            psPrint(ps, Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_307]);
664                            break;
665                        default:
666                            break;
667                    }
668                }
669
670                ps.flush();
671
672                // Reset for next request
673                readStarted = false;
674                headers.clear();
675
676            } while (keepAlive);
677
678            log("SOCKET CLOSED");
679            s.close();
680        }
681
682        // Print string to log and output stream
683        void psPrint(PrintStream ps, String s) throws IOException {
684            log(s);
685            ps.print(s);
686        }
687
688        // Print bytes to log and output stream
689        void psWrite(PrintStream ps, byte[] bytes, int offset, int count) throws IOException {
690            log(new String(bytes));
691            ps.write(bytes, offset, count);
692        }
693
694        // Print CRLF to log and output stream
695        void psWriteEOL(PrintStream ps) throws IOException {
696            log("CRLF");
697            ps.write(EOL);
698        }
699
700
701        // Print status to log and output stream
702        void printStatus(PrintStream ps) throws IOException {
703            // Handle redirects first.
704            if (redirectCode != -1) {
705                log("REDIRECTING TO "+redirectHost+" status "+redirectCode);
706                psPrint(ps, HTTP_VERSION_STRING + " " + redirectCode +" Moved permanently");
707                psWriteEOL(ps);
708                psPrint(ps, "Location: " + redirectHost);
709                psWriteEOL(ps);
710                return;
711            }
712
713
714            if (testID.startsWith("test")) {
715                testNum = Integer.valueOf(testID.substring(4))-1;
716            }
717
718            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
719                psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_NOT_FOUND + " not found");
720                psWriteEOL(ps);
721            }  else {
722                psPrint(ps, HTTP_VERSION_STRING + " " + HTTP_OK+" OK");
723                psWriteEOL(ps);
724            }
725
726            log("Status sent");
727        }
728        /**
729         * Create the server response and output to the stream
730         * @param ps The PrintStream to output response headers and data to
731         */
732        void printHeaders(PrintStream ps) throws IOException {
733            if ((testNum < -1) || (testNum > Support_TestWebData.tests.length - 1)) {
734                // 404 status already sent
735                return;
736            }
737            SimpleDateFormat df = new SimpleDateFormat("EE, dd MMM yyyy HH:mm:ss z");
738            df.setTimeZone(TimeZone.getTimeZone("GMT"));
739
740            psPrint(ps,"Server: TestWebServer"+mPort);
741            psWriteEOL(ps);
742            psPrint(ps, "Date: " + df.format(new Date()));
743            psWriteEOL(ps);
744            psPrint(ps, "Connection: " + ((keepAlive) ? "Keep-Alive" : "Close"));
745            psWriteEOL(ps);
746
747            // Yuk, if we're not redirecting, we add the file details
748            if (redirectCode == -1) {
749
750                if (testNum == -1) {
751                    if (!Support_TestWebData.test0DataAvailable) {
752                        log("testdata was not initilaized");
753                        return;
754                    }
755                    if (chunked) {
756                        psPrint(ps, "Transfer-Encoding: chunked");
757                    } else {
758                        psPrint(ps, "Content-length: "
759                                + Support_TestWebData.test0Data.length);
760                    }
761                    psWriteEOL(ps);
762
763                    psPrint(ps, "Last Modified: " + (new Date(
764                            Support_TestWebData.test0Params.testLastModified)));
765                    psWriteEOL(ps);
766
767                    psPrint(ps, "Content-type: "
768                            + Support_TestWebData.test0Params.testType);
769                    psWriteEOL(ps);
770
771                    if (Support_TestWebData.testParams[testNum].testExp > 0) {
772                        long exp;
773                        exp = Support_TestWebData.testParams[testNum].testExp;
774                        psPrint(ps, "expires: "
775                                + df.format(exp) + " GMT");
776                        psWriteEOL(ps);
777                    }
778                } else if (!Support_TestWebData.testParams[testNum].testDir) {
779                    if (chunked) {
780                        psPrint(ps, "Transfer-Encoding: chunked");
781                    } else {
782                        psPrint(ps, "Content-length: "+Support_TestWebData.testParams[testNum].testLength);
783                    }
784                    psWriteEOL(ps);
785
786                    psPrint(ps,"Last Modified: " + (new
787                                                    Date(Support_TestWebData.testParams[testNum].testLastModified)));
788                    psWriteEOL(ps);
789
790                    psPrint(ps, "Content-type: " + Support_TestWebData.testParams[testNum].testType);
791                    psWriteEOL(ps);
792
793                    if (Support_TestWebData.testParams[testNum].testExp > 0) {
794                        long exp;
795                        exp = Support_TestWebData.testParams[testNum].testExp;
796                        psPrint(ps, "expires: "
797                                + df.format(exp) + " GMT");
798                        psWriteEOL(ps);
799                    }
800                } else {
801                    psPrint(ps, "Content-type: text/html");
802                    psWriteEOL(ps);
803                }
804            } else {
805                // Content-length of 301, 302, 303, 307 are the same.
806                psPrint(ps, "Content-length: "+(Support_TestWebData.testServerResponse[Support_TestWebData.REDIRECT_301]).length());
807                psWriteEOL(ps);
808                psWriteEOL(ps);
809            }
810            log("Headers sent");
811
812        }
813
814        /**
815         * Sends the 404 not found message
816         * @param ps The PrintStream to write to
817         */
818        void send404(PrintStream ps) throws IOException {
819            ps.println("Not Found\n\n"+
820                       "The requested resource was not found.\n");
821        }
822
823        /**
824         * Sends the data associated with the headers
825         * @param ps The PrintStream to write to
826         */
827        void sendFile(PrintStream ps) throws IOException {
828            if (testNum == -1) {
829                if (!Support_TestWebData.test0DataAvailable) {
830                    log("test data was not initialized");
831                    return;
832                }
833                sendFile(ps, Support_TestWebData.test0Data);
834            } else {
835                sendFile(ps, Support_TestWebData.tests[testNum]);
836            }
837        }
838
839        void sendFile(PrintStream ps, byte[] bytes) throws IOException {
840            if (chunked) {
841                int offset = 0;
842                while (offset < bytes.length) {
843                    int chunkSize = Math.min(bytes.length - offset, maxChunkSize);
844                    psPrint(ps, Integer.toHexString(chunkSize));
845                    psWriteEOL(ps);
846                    psWrite(ps, bytes, offset, chunkSize);
847                    psWriteEOL(ps);
848                    offset += chunkSize;
849                }
850                psPrint(ps, "0");
851                psWriteEOL(ps);
852                psWriteEOL(ps);
853            } else {
854                psWrite(ps, bytes, 0, bytes.length);
855            }
856        }
857    }
858}
859