• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (c) 2008-2009, Motorola, Inc.
3  *
4  * All rights reserved.
5  *
6  * Redistribution and use in source and binary forms, with or without
7  * modification, are permitted provided that the following conditions are met:
8  *
9  * - Redistributions of source code must retain the above copyright notice,
10  * this list of conditions and the following disclaimer.
11  *
12  * - Redistributions in binary form must reproduce the above copyright notice,
13  * this list of conditions and the following disclaimer in the documentation
14  * and/or other materials provided with the distribution.
15  *
16  * - Neither the name of the Motorola, Inc. nor the names of its contributors
17  * may be used to endorse or promote products derived from this software
18  * without specific prior written permission.
19  *
20  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23  * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
24  * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30  * POSSIBILITY OF SUCH DAMAGE.
31  */
32 
33 package com.android.bluetooth.opp;
34 
35 import java.io.BufferedOutputStream;
36 import java.io.File;
37 import java.io.IOException;
38 import java.io.InputStream;
39 import java.util.Arrays;
40 
41 import android.content.ContentValues;
42 import android.content.Context;
43 import android.content.Intent;
44 import android.net.Uri;
45 import android.os.Handler;
46 import android.os.Message;
47 import android.os.PowerManager;
48 import android.os.PowerManager.WakeLock;
49 import android.util.Log;
50 import android.webkit.MimeTypeMap;
51 
52 import javax.obex.HeaderSet;
53 import javax.obex.ObexTransport;
54 import javax.obex.Operation;
55 import javax.obex.ResponseCodes;
56 import javax.obex.ServerRequestHandler;
57 import javax.obex.ServerSession;
58 
59 /**
60  * This class runs as an OBEX server
61  */
62 public class BluetoothOppObexServerSession extends ServerRequestHandler implements
63         BluetoothOppObexSession {
64 
65     private static final String TAG = "BtOppObexServer";
66     private static final boolean D = Constants.DEBUG;
67     private static final boolean V = Constants.VERBOSE;
68 
69     private ObexTransport mTransport;
70 
71     private Context mContext;
72 
73     private Handler mCallback = null;
74 
75     /* status when server is blocking for user/auto confirmation */
76     private boolean mServerBlocking = true;
77 
78     /* the current transfer info */
79     private BluetoothOppShareInfo mInfo;
80 
81     /* info id when we insert the record */
82     private int mLocalShareInfoId;
83 
84     private int mAccepted = BluetoothShare.USER_CONFIRMATION_PENDING;
85 
86     private boolean mInterrupted = false;
87 
88     private ServerSession mSession;
89 
90     private long mTimestamp;
91 
92     private BluetoothOppReceiveFileInfo mFileInfo;
93 
94     private WakeLock mWakeLock;
95 
96     private WakeLock mPartialWakeLock;
97 
98     boolean mTimeoutMsgSent = false;
99 
BluetoothOppObexServerSession(Context context, ObexTransport transport)100     public BluetoothOppObexServerSession(Context context, ObexTransport transport) {
101         mContext = context;
102         mTransport = transport;
103         PowerManager pm = (PowerManager)mContext.getSystemService(Context.POWER_SERVICE);
104         mWakeLock = pm.newWakeLock(PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP
105                 | PowerManager.ON_AFTER_RELEASE, TAG);
106         mPartialWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
107     }
108 
unblock()109     public void unblock() {
110         mServerBlocking = false;
111     }
112 
113     /**
114      * Called when connection is accepted from remote, to retrieve the first
115      * Header then wait for user confirmation
116      */
preStart()117     public void preStart() {
118         if (D) Log.d(TAG, "acquire full WakeLock");
119         mWakeLock.acquire();
120         try {
121             if (D) Log.d(TAG, "Create ServerSession with transport " + mTransport.toString());
122             mSession = new ServerSession(mTransport, this, null);
123         } catch (IOException e) {
124             Log.e(TAG, "Create server session error" + e);
125         }
126     }
127 
128     /**
129      * Called from BluetoothOppTransfer to start the "Transfer"
130      */
start(Handler handler, int numShares)131     public void start(Handler handler, int numShares) {
132         if (D) Log.d(TAG, "Start!");
133         mCallback = handler;
134 
135     }
136 
137     /**
138      * Called from BluetoothOppTransfer to cancel the "Transfer" Otherwise,
139      * server should end by itself.
140      */
stop()141     public void stop() {
142         /*
143          * TODO now we implement in a tough way, just close the socket.
144          * maybe need nice way
145          */
146         if (D) Log.d(TAG, "Stop!");
147         mInterrupted = true;
148         if (mSession != null) {
149             try {
150                 mSession.close();
151                 mTransport.close();
152             } catch (IOException e) {
153                 Log.e(TAG, "close mTransport error" + e);
154             }
155         }
156         mCallback = null;
157         mSession = null;
158     }
159 
addShare(BluetoothOppShareInfo info)160     public void addShare(BluetoothOppShareInfo info) {
161         if (D) Log.d(TAG, "addShare for id " + info.mId);
162         mInfo = info;
163         mFileInfo = processShareInfo();
164     }
165 
166     @Override
onPut(Operation op)167     public int onPut(Operation op) {
168         if (D) Log.d(TAG, "onPut " + op.toString());
169         HeaderSet request;
170         String name, mimeType;
171         Long length;
172 
173         int obexResponse = ResponseCodes.OBEX_HTTP_OK;
174 
175         /**
176          * For multiple objects, reject further objects after user deny the
177          * first one
178          */
179         if (mAccepted == BluetoothShare.USER_CONFIRMATION_DENIED) {
180             return ResponseCodes.OBEX_HTTP_FORBIDDEN;
181         }
182 
183         String destination;
184         if (mTransport instanceof BluetoothOppRfcommTransport) {
185             destination = ((BluetoothOppRfcommTransport)mTransport).getRemoteAddress();
186         } else {
187             destination = "FF:FF:FF:00:00:00";
188         }
189         boolean isWhitelisted = BluetoothOppManager.getInstance(mContext).
190                 isWhitelisted(destination);
191 
192         try {
193             boolean pre_reject = false;
194 
195             request = op.getReceivedHeader();
196             if (V) Constants.logHeader(request);
197             name = (String)request.getHeader(HeaderSet.NAME);
198             length = (Long)request.getHeader(HeaderSet.LENGTH);
199             mimeType = (String)request.getHeader(HeaderSet.TYPE);
200 
201             if (length == 0) {
202                 if (D) Log.w(TAG, "length is 0, reject the transfer");
203                 pre_reject = true;
204                 obexResponse = ResponseCodes.OBEX_HTTP_LENGTH_REQUIRED;
205             }
206 
207             if (name == null || name.equals("")) {
208                 if (D) Log.w(TAG, "name is null or empty, reject the transfer");
209                 pre_reject = true;
210                 obexResponse = ResponseCodes.OBEX_HTTP_BAD_REQUEST;
211             }
212 
213             if (!pre_reject) {
214                 /* first we look for Mimetype in Android map */
215                 String extension, type;
216                 int dotIndex = name.lastIndexOf(".");
217                 if (dotIndex < 0 && mimeType == null) {
218                     if (D) Log.w(TAG, "There is no file extension or mime type," +
219                             "reject the transfer");
220                     pre_reject = true;
221                     obexResponse = ResponseCodes.OBEX_HTTP_BAD_REQUEST;
222                 } else {
223                     extension = name.substring(dotIndex + 1).toLowerCase();
224                     MimeTypeMap map = MimeTypeMap.getSingleton();
225                     type = map.getMimeTypeFromExtension(extension);
226                     if (V) Log.v(TAG, "Mimetype guessed from extension " + extension + " is " + type);
227                     if (type != null) {
228                         mimeType = type;
229 
230                     } else {
231                         if (mimeType == null) {
232                             if (D) Log.w(TAG, "Can't get mimetype, reject the transfer");
233                             pre_reject = true;
234                             obexResponse = ResponseCodes.OBEX_HTTP_UNSUPPORTED_TYPE;
235                         }
236                     }
237                     if (mimeType != null) {
238                         mimeType = mimeType.toLowerCase();
239                     }
240                 }
241             }
242 
243             // Reject policy: anything outside the "white list" plus unspecified
244             // MIME Types. Also reject everything in the "black list".
245             if (!pre_reject
246                     && (mimeType == null
247                             || (!isWhitelisted && !Constants.mimeTypeMatches(mimeType,
248                                     Constants.ACCEPTABLE_SHARE_INBOUND_TYPES))
249                             || Constants.mimeTypeMatches(mimeType,
250                                     Constants.UNACCEPTABLE_SHARE_INBOUND_TYPES))) {
251                 if (D) Log.w(TAG, "mimeType is null or in unacceptable list, reject the transfer");
252                 pre_reject = true;
253                 obexResponse = ResponseCodes.OBEX_HTTP_UNSUPPORTED_TYPE;
254             }
255 
256             if (pre_reject && obexResponse != ResponseCodes.OBEX_HTTP_OK) {
257                 // some bad implemented client won't send disconnect
258                 return obexResponse;
259             }
260 
261         } catch (IOException e) {
262             Log.e(TAG, "get getReceivedHeaders error " + e);
263             return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
264         }
265 
266         ContentValues values = new ContentValues();
267 
268         values.put(BluetoothShare.FILENAME_HINT, name);
269         values.put(BluetoothShare.TOTAL_BYTES, length.intValue());
270         values.put(BluetoothShare.MIMETYPE, mimeType);
271 
272         values.put(BluetoothShare.DESTINATION, destination);
273 
274         values.put(BluetoothShare.DIRECTION, BluetoothShare.DIRECTION_INBOUND);
275         values.put(BluetoothShare.TIMESTAMP, mTimestamp);
276 
277         boolean needConfirm = true;
278         /** It's not first put if !serverBlocking, so we auto accept it */
279         if (!mServerBlocking) {
280             values.put(BluetoothShare.USER_CONFIRMATION,
281                     BluetoothShare.USER_CONFIRMATION_AUTO_CONFIRMED);
282             needConfirm = false;
283         }
284 
285         if (isWhitelisted) {
286             values.put(BluetoothShare.USER_CONFIRMATION,
287                     BluetoothShare.USER_CONFIRMATION_HANDOVER_CONFIRMED);
288             needConfirm = false;
289 
290         }
291 
292         Uri contentUri = mContext.getContentResolver().insert(BluetoothShare.CONTENT_URI, values);
293         mLocalShareInfoId = Integer.parseInt(contentUri.getPathSegments().get(1));
294 
295         if (needConfirm) {
296             Intent in = new Intent(BluetoothShare.INCOMING_FILE_CONFIRMATION_REQUEST_ACTION);
297             in.setClassName(Constants.THIS_PACKAGE_NAME, BluetoothOppReceiver.class.getName());
298             mContext.sendBroadcast(in);
299         }
300 
301         if (V) Log.v(TAG, "insert contentUri: " + contentUri);
302         if (V) Log.v(TAG, "mLocalShareInfoId = " + mLocalShareInfoId);
303 
304         if (V) Log.v(TAG, "acquire partial WakeLock");
305 
306 
307         synchronized (this) {
308             if (mWakeLock.isHeld()) {
309                 mPartialWakeLock.acquire();
310                 mWakeLock.release();
311             }
312             mServerBlocking = true;
313             try {
314 
315                 while (mServerBlocking) {
316                     wait(1000);
317                     if (mCallback != null && !mTimeoutMsgSent) {
318                         mCallback.sendMessageDelayed(mCallback
319                                 .obtainMessage(BluetoothOppObexSession.MSG_CONNECT_TIMEOUT),
320                                 BluetoothOppObexSession.SESSION_TIMEOUT);
321                         mTimeoutMsgSent = true;
322                         if (V) Log.v(TAG, "MSG_CONNECT_TIMEOUT sent");
323                     }
324                 }
325             } catch (InterruptedException e) {
326                 if (V) Log.v(TAG, "Interrupted in onPut blocking");
327             }
328         }
329         if (D) Log.d(TAG, "Server unblocked ");
330         synchronized (this) {
331             if (mCallback != null && mTimeoutMsgSent) {
332                 mCallback.removeMessages(BluetoothOppObexSession.MSG_CONNECT_TIMEOUT);
333             }
334         }
335 
336         /* we should have mInfo now */
337 
338         /*
339          * TODO check if this mInfo match the one that we insert before server
340          * blocking? just to make sure no error happens
341          */
342         if (mInfo.mId != mLocalShareInfoId) {
343             Log.e(TAG, "Unexpected error!");
344         }
345         mAccepted = mInfo.mConfirm;
346 
347         if (V) Log.v(TAG, "after confirm: userAccepted=" + mAccepted);
348         int status = BluetoothShare.STATUS_SUCCESS;
349 
350         if (mAccepted == BluetoothShare.USER_CONFIRMATION_CONFIRMED
351                 || mAccepted == BluetoothShare.USER_CONFIRMATION_AUTO_CONFIRMED
352                 || mAccepted == BluetoothShare.USER_CONFIRMATION_HANDOVER_CONFIRMED) {
353             /* Confirm or auto-confirm */
354 
355             if (mFileInfo.mFileName == null) {
356                 status = mFileInfo.mStatus;
357                 /* TODO need to check if this line is correct */
358                 mInfo.mStatus = mFileInfo.mStatus;
359                 Constants.updateShareStatus(mContext, mInfo.mId, status);
360                 obexResponse = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
361 
362             }
363 
364             if (mFileInfo.mFileName != null) {
365 
366                 ContentValues updateValues = new ContentValues();
367                 contentUri = Uri.parse(BluetoothShare.CONTENT_URI + "/" + mInfo.mId);
368                 updateValues.put(BluetoothShare._DATA, mFileInfo.mFileName);
369                 updateValues.put(BluetoothShare.STATUS, BluetoothShare.STATUS_RUNNING);
370                 mContext.getContentResolver().update(contentUri, updateValues, null, null);
371 
372                 status = receiveFile(mFileInfo, op);
373                 /*
374                  * TODO map status to obex response code
375                  */
376                 if (status != BluetoothShare.STATUS_SUCCESS) {
377                     obexResponse = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
378                 }
379                 Constants.updateShareStatus(mContext, mInfo.mId, status);
380             }
381 
382             if (status == BluetoothShare.STATUS_SUCCESS) {
383                 Message msg = Message.obtain(mCallback, BluetoothOppObexSession.MSG_SHARE_COMPLETE);
384                 msg.obj = mInfo;
385                 msg.sendToTarget();
386             } else {
387                 if (mCallback != null) {
388                     Message msg = Message.obtain(mCallback,
389                             BluetoothOppObexSession.MSG_SESSION_ERROR);
390                     mInfo.mStatus = status;
391                     msg.obj = mInfo;
392                     msg.sendToTarget();
393                 }
394             }
395         } else if (mAccepted == BluetoothShare.USER_CONFIRMATION_DENIED
396                 || mAccepted == BluetoothShare.USER_CONFIRMATION_TIMEOUT) {
397             /* user actively deny the inbound transfer */
398             /*
399              * Note There is a question: what's next if user deny the first obj?
400              * Option 1 :continue prompt for next objects
401              * Option 2 :reject next objects and finish the session
402              * Now we take option 2:
403              */
404 
405             Log.i(TAG, "Rejected incoming request");
406             if (mFileInfo.mFileName != null) {
407                 try {
408                     mFileInfo.mOutputStream.close();
409                 } catch (IOException e) {
410                     Log.e(TAG, "error close file stream");
411                 }
412                 new File(mFileInfo.mFileName).delete();
413             }
414             // set status as local cancel
415             status = BluetoothShare.STATUS_CANCELED;
416             Constants.updateShareStatus(mContext, mInfo.mId, status);
417             obexResponse = ResponseCodes.OBEX_HTTP_FORBIDDEN;
418 
419             Message msg = Message.obtain(mCallback);
420             msg.what = BluetoothOppObexSession.MSG_SHARE_INTERRUPTED;
421             mInfo.mStatus = status;
422             msg.obj = mInfo;
423             msg.sendToTarget();
424         }
425         return obexResponse;
426     }
427 
receiveFile(BluetoothOppReceiveFileInfo fileInfo, Operation op)428     private int receiveFile(BluetoothOppReceiveFileInfo fileInfo, Operation op) {
429         /*
430          * implement receive file
431          */
432         int status = -1;
433         BufferedOutputStream bos = null;
434 
435         InputStream is = null;
436         boolean error = false;
437         try {
438             is = op.openInputStream();
439         } catch (IOException e1) {
440             Log.e(TAG, "Error when openInputStream");
441             status = BluetoothShare.STATUS_OBEX_DATA_ERROR;
442             error = true;
443         }
444 
445         Uri contentUri = Uri.parse(BluetoothShare.CONTENT_URI + "/" + mInfo.mId);
446 
447         if (!error) {
448             ContentValues updateValues = new ContentValues();
449             updateValues.put(BluetoothShare._DATA, fileInfo.mFileName);
450             mContext.getContentResolver().update(contentUri, updateValues, null, null);
451         }
452 
453         int position = 0;
454         if (!error) {
455             bos = new BufferedOutputStream(fileInfo.mOutputStream, 0x10000);
456         }
457 
458         if (!error) {
459             int outputBufferSize = op.getMaxPacketSize();
460             byte[] b = new byte[outputBufferSize];
461             int readLength = 0;
462             long timestamp = 0;
463             try {
464                 while ((!mInterrupted) && (position != fileInfo.mLength)) {
465 
466                     if (V) timestamp = System.currentTimeMillis();
467 
468                     readLength = is.read(b);
469 
470                     if (readLength == -1) {
471                         if (D) Log.d(TAG, "Receive file reached stream end at position" + position);
472                         break;
473                     }
474 
475                     bos.write(b, 0, readLength);
476                     position += readLength;
477 
478                     if (V) {
479                         Log.v(TAG, "Receive file position = " + position + " readLength "
480                                 + readLength + " bytes took "
481                                 + (System.currentTimeMillis() - timestamp) + " ms");
482                     }
483 
484                     ContentValues updateValues = new ContentValues();
485                     updateValues.put(BluetoothShare.CURRENT_BYTES, position);
486                     mContext.getContentResolver().update(contentUri, updateValues, null, null);
487                 }
488             } catch (IOException e1) {
489                 Log.e(TAG, "Error when receiving file");
490                 /* OBEX Abort packet received from remote device */
491                 if ("Abort Received".equals(e1.getMessage())) {
492                     status = BluetoothShare.STATUS_CANCELED;
493                 } else {
494                     status = BluetoothShare.STATUS_OBEX_DATA_ERROR;
495                 }
496                 error = true;
497             }
498         }
499 
500         if (mInterrupted) {
501             if (D) Log.d(TAG, "receiving file interrupted by user.");
502             status = BluetoothShare.STATUS_CANCELED;
503         } else {
504             if (position == fileInfo.mLength) {
505                 if (D) Log.d(TAG, "Receiving file completed for " + fileInfo.mFileName);
506                 status = BluetoothShare.STATUS_SUCCESS;
507             } else {
508                 if (D) Log.d(TAG, "Reading file failed at " + position + " of " + fileInfo.mLength);
509                 if (status == -1) {
510                     status = BluetoothShare.STATUS_UNKNOWN_ERROR;
511                 }
512             }
513         }
514 
515         if (bos != null) {
516             try {
517                 bos.close();
518             } catch (IOException e) {
519                 Log.e(TAG, "Error when closing stream after send");
520             }
521         }
522         return status;
523     }
524 
processShareInfo()525     private BluetoothOppReceiveFileInfo processShareInfo() {
526         if (D) Log.d(TAG, "processShareInfo() " + mInfo.mId);
527         BluetoothOppReceiveFileInfo fileInfo = BluetoothOppReceiveFileInfo.generateFileInfo(
528                 mContext, mInfo.mId);
529         if (V) {
530             Log.v(TAG, "Generate BluetoothOppReceiveFileInfo:");
531             Log.v(TAG, "filename  :" + fileInfo.mFileName);
532             Log.v(TAG, "length    :" + fileInfo.mLength);
533             Log.v(TAG, "status    :" + fileInfo.mStatus);
534         }
535         return fileInfo;
536     }
537 
538     @Override
onConnect(HeaderSet request, HeaderSet reply)539     public int onConnect(HeaderSet request, HeaderSet reply) {
540 
541         if (D) Log.d(TAG, "onConnect");
542         if (V) Constants.logHeader(request);
543         Long objectCount = null;
544         try {
545             byte[] uuid = (byte[])request.getHeader(HeaderSet.TARGET);
546             if (V) Log.v(TAG, "onConnect(): uuid =" + Arrays.toString(uuid));
547             if(uuid != null) {
548                  return ResponseCodes.OBEX_HTTP_NOT_ACCEPTABLE;
549             }
550 
551             objectCount = (Long) request.getHeader(HeaderSet.COUNT);
552         } catch (IOException e) {
553             Log.e(TAG, e.toString());
554             return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
555         }
556         String destination;
557         if (mTransport instanceof BluetoothOppRfcommTransport) {
558             destination = ((BluetoothOppRfcommTransport)mTransport).getRemoteAddress();
559         } else {
560             destination = "FF:FF:FF:00:00:00";
561         }
562         boolean isHandover = BluetoothOppManager.getInstance(mContext).
563                 isWhitelisted(destination);
564         if (isHandover) {
565             // Notify the handover requester file transfer has started
566             Intent intent = new Intent(Constants.ACTION_HANDOVER_STARTED);
567             if (objectCount != null) {
568                 intent.putExtra(Constants.EXTRA_BT_OPP_OBJECT_COUNT, objectCount.intValue());
569             } else {
570                 intent.putExtra(Constants.EXTRA_BT_OPP_OBJECT_COUNT,
571                         Constants.COUNT_HEADER_UNAVAILABLE);
572             }
573             intent.putExtra(Constants.EXTRA_BT_OPP_ADDRESS, destination);
574             mContext.sendBroadcast(intent, Constants.HANDOVER_STATUS_PERMISSION);
575         }
576         mTimestamp = System.currentTimeMillis();
577         return ResponseCodes.OBEX_HTTP_OK;
578     }
579 
580     @Override
onDisconnect(HeaderSet req, HeaderSet resp)581     public void onDisconnect(HeaderSet req, HeaderSet resp) {
582         if (D) Log.d(TAG, "onDisconnect");
583         resp.responseCode = ResponseCodes.OBEX_HTTP_OK;
584     }
585 
releaseWakeLocks()586     private synchronized void releaseWakeLocks() {
587         if (mWakeLock.isHeld()) {
588             mWakeLock.release();
589         }
590         if (mPartialWakeLock.isHeld()) {
591             mPartialWakeLock.release();
592         }
593     }
594 
595     @Override
onClose()596     public void onClose() {
597         if (V) Log.v(TAG, "release WakeLock");
598         releaseWakeLocks();
599 
600         /* onClose could happen even before start() where mCallback is set */
601         if (mCallback != null) {
602             Message msg = Message.obtain(mCallback);
603             msg.what = BluetoothOppObexSession.MSG_SESSION_COMPLETE;
604             msg.obj = mInfo;
605             msg.sendToTarget();
606         }
607     }
608 }
609