| /* |
| * Copyright (c) 2008-2009, Motorola, Inc. |
| * |
| * All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions are met: |
| * |
| * - Redistributions of source code must retain the above copyright notice, |
| * this list of conditions and the following disclaimer. |
| * |
| * - Redistributions in binary form must reproduce the above copyright notice, |
| * this list of conditions and the following disclaimer in the documentation |
| * and/or other materials provided with the distribution. |
| * |
| * - Neither the name of the Motorola, Inc. nor the names of its contributors |
| * may be used to endorse or promote products derived from this software |
| * without specific prior written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE |
| * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
| * POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| package com.android.bluetooth.opp; |
| |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.net.Uri; |
| import android.os.Handler; |
| import android.os.Message; |
| import android.os.PowerManager; |
| import android.os.PowerManager.WakeLock; |
| import android.os.SystemClock; |
| import android.util.Log; |
| import android.webkit.MimeTypeMap; |
| |
| import com.android.bluetooth.BluetoothMetricsProto; |
| import com.android.bluetooth.BluetoothObexTransport; |
| import com.android.bluetooth.btservice.MetricsLogger; |
| |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.util.Arrays; |
| |
| import javax.obex.HeaderSet; |
| import javax.obex.ObexTransport; |
| import javax.obex.Operation; |
| import javax.obex.ResponseCodes; |
| import javax.obex.ServerRequestHandler; |
| import javax.obex.ServerSession; |
| |
| /** |
| * This class runs as an OBEX server |
| */ |
| public class BluetoothOppObexServerSession extends ServerRequestHandler |
| implements BluetoothOppObexSession { |
| |
| private static final String TAG = "BtOppObexServer"; |
| private static final boolean D = Constants.DEBUG; |
| private static final boolean V = Constants.VERBOSE; |
| |
| private ObexTransport mTransport; |
| |
| private Context mContext; |
| |
| private Handler mCallback = null; |
| |
| /* status when server is blocking for user/auto confirmation */ |
| private boolean mServerBlocking = true; |
| |
| /* the current transfer info */ |
| private BluetoothOppShareInfo mInfo; |
| |
| /* info id when we insert the record */ |
| private int mLocalShareInfoId; |
| |
| private int mAccepted = BluetoothShare.USER_CONFIRMATION_PENDING; |
| |
| private boolean mInterrupted = false; |
| |
| private ServerSession mSession; |
| |
| private long mTimestamp; |
| |
| private BluetoothOppReceiveFileInfo mFileInfo; |
| |
| private WakeLock mPartialWakeLock; |
| |
| boolean mTimeoutMsgSent = false; |
| |
| private BluetoothOppService mBluetoothOppService; |
| |
| private int mNumFilesAttemptedToReceive; |
| |
| public BluetoothOppObexServerSession(Context context, ObexTransport transport, |
| BluetoothOppService service) { |
| mContext = context; |
| mTransport = transport; |
| mBluetoothOppService = service; |
| PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); |
| mPartialWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); |
| mPartialWakeLock.setReferenceCounted(false); |
| } |
| |
| @Override |
| public void unblock() { |
| mServerBlocking = false; |
| } |
| |
| /** |
| * Called when connection is accepted from remote, to retrieve the first |
| * Header then wait for user confirmation |
| */ |
| public void preStart() { |
| try { |
| if (D) { |
| Log.d(TAG, "Create ServerSession with transport " + mTransport.toString()); |
| } |
| mSession = new ServerSession(mTransport, this, null); |
| } catch (IOException e) { |
| Log.e(TAG, "Create server session error" + e); |
| } |
| } |
| |
| /** |
| * Called from BluetoothOppTransfer to start the "Transfer" |
| */ |
| @Override |
| public void start(Handler handler, int numShares) { |
| if (D) { |
| Log.d(TAG, "Start!"); |
| } |
| mCallback = handler; |
| |
| } |
| |
| /** |
| * Called from BluetoothOppTransfer to cancel the "Transfer" Otherwise, |
| * server should end by itself. |
| */ |
| @Override |
| public void stop() { |
| /* |
| * TODO now we implement in a tough way, just close the socket. |
| * maybe need nice way |
| */ |
| if (D) { |
| Log.d(TAG, "Stop!"); |
| } |
| mInterrupted = true; |
| if (mSession != null) { |
| try { |
| mSession.close(); |
| mTransport.close(); |
| } catch (IOException e) { |
| Log.e(TAG, "close mTransport error" + e); |
| } |
| } |
| mCallback = null; |
| mSession = null; |
| } |
| |
| @Override |
| public void addShare(BluetoothOppShareInfo info) { |
| if (D) { |
| Log.d(TAG, "addShare for id " + info.mId); |
| } |
| mInfo = info; |
| mFileInfo = processShareInfo(); |
| } |
| |
| @Override |
| public int onPut(Operation op) { |
| if (D) { |
| Log.d(TAG, "onPut " + op.toString()); |
| } |
| |
| /* For multiple objects, reject further objects after the user denies the first one */ |
| if (mAccepted == BluetoothShare.USER_CONFIRMATION_DENIED) { |
| return ResponseCodes.OBEX_HTTP_FORBIDDEN; |
| } |
| |
| String destination; |
| if (mTransport instanceof BluetoothObexTransport) { |
| destination = ((BluetoothObexTransport) mTransport).getRemoteAddress(); |
| } else { |
| destination = "FF:FF:FF:00:00:00"; |
| } |
| boolean isAcceptlisted = |
| BluetoothOppManager.getInstance(mContext).isAcceptlisted(destination); |
| |
| HeaderSet request; |
| String name, mimeType; |
| Long length; |
| try { |
| request = op.getReceivedHeader(); |
| if (V) { |
| Constants.logHeader(request); |
| } |
| name = (String) request.getHeader(HeaderSet.NAME); |
| length = (Long) request.getHeader(HeaderSet.LENGTH); |
| mimeType = (String) request.getHeader(HeaderSet.TYPE); |
| } catch (IOException e) { |
| Log.e(TAG, "onPut: getReceivedHeaders error " + e); |
| return ResponseCodes.OBEX_HTTP_BAD_REQUEST; |
| } |
| |
| if (length == 0) { |
| if (D) { |
| Log.w(TAG, "length is 0, reject the transfer"); |
| } |
| return ResponseCodes.OBEX_HTTP_LENGTH_REQUIRED; |
| } |
| |
| if (name == null || name.isEmpty()) { |
| if (D) { |
| Log.w(TAG, "name is null or empty, reject the transfer"); |
| } |
| return ResponseCodes.OBEX_HTTP_BAD_REQUEST; |
| } |
| |
| // First we look for the mime type in the Android map |
| String extension, type; |
| int dotIndex = name.lastIndexOf("."); |
| if (dotIndex < 0 && mimeType == null) { |
| if (D) { |
| Log.w(TAG, "There is no file extension or mime type, reject the transfer"); |
| } |
| return ResponseCodes.OBEX_HTTP_BAD_REQUEST; |
| } else { |
| extension = name.substring(dotIndex + 1).toLowerCase(); |
| MimeTypeMap map = MimeTypeMap.getSingleton(); |
| type = map.getMimeTypeFromExtension(extension); |
| if (V) { |
| Log.v(TAG, "Mimetype guessed from extension " + extension + " is " + type); |
| } |
| if (type != null) { |
| mimeType = type; |
| } else { |
| if (mimeType == null) { |
| if (D) { |
| Log.w(TAG, "Can't get mimetype, reject the transfer"); |
| } |
| return ResponseCodes.OBEX_HTTP_UNSUPPORTED_TYPE; |
| } |
| } |
| mimeType = mimeType.toLowerCase(); |
| } |
| |
| // Reject anything outside the "acceptlist" plus unspecified MIME Types. |
| if (mimeType == null || (!isAcceptlisted && !Constants.mimeTypeMatches(mimeType, |
| Constants.ACCEPTABLE_SHARE_INBOUND_TYPES))) { |
| if (D) { |
| Log.w(TAG, "mimeType is null or in unacceptable list, reject the transfer"); |
| } |
| return ResponseCodes.OBEX_HTTP_UNSUPPORTED_TYPE; |
| } |
| |
| ContentValues values = new ContentValues(); |
| values.put(BluetoothShare.FILENAME_HINT, name); |
| values.put(BluetoothShare.TOTAL_BYTES, length); |
| values.put(BluetoothShare.MIMETYPE, mimeType); |
| values.put(BluetoothShare.DESTINATION, destination); |
| values.put(BluetoothShare.DIRECTION, BluetoothShare.DIRECTION_INBOUND); |
| values.put(BluetoothShare.TIMESTAMP, mTimestamp); |
| |
| // It's not first put if !serverBlocking, so we auto accept it |
| if (!mServerBlocking && (mAccepted == BluetoothShare.USER_CONFIRMATION_CONFIRMED |
| || mAccepted == BluetoothShare.USER_CONFIRMATION_AUTO_CONFIRMED)) { |
| values.put(BluetoothShare.USER_CONFIRMATION, |
| BluetoothShare.USER_CONFIRMATION_AUTO_CONFIRMED); |
| } |
| |
| if (isAcceptlisted) { |
| values.put(BluetoothShare.USER_CONFIRMATION, |
| BluetoothShare.USER_CONFIRMATION_HANDOVER_CONFIRMED); |
| } |
| |
| Uri contentUri = mContext.getContentResolver().insert(BluetoothShare.CONTENT_URI, values); |
| mLocalShareInfoId = Integer.parseInt(contentUri.getPathSegments().get(1)); |
| |
| if (V) { |
| Log.v(TAG, "insert contentUri: " + contentUri); |
| Log.v(TAG, "mLocalShareInfoId = " + mLocalShareInfoId); |
| } |
| |
| synchronized (this) { |
| mPartialWakeLock.acquire(); |
| mServerBlocking = true; |
| try { |
| |
| while (mServerBlocking) { |
| wait(1000); |
| if (mCallback != null && !mTimeoutMsgSent) { |
| mCallback.sendMessageDelayed(mCallback.obtainMessage( |
| BluetoothOppObexSession.MSG_CONNECT_TIMEOUT), |
| BluetoothOppObexSession.SESSION_TIMEOUT); |
| mTimeoutMsgSent = true; |
| if (V) { |
| Log.v(TAG, "MSG_CONNECT_TIMEOUT sent"); |
| } |
| } |
| } |
| } catch (InterruptedException e) { |
| if (V) { |
| Log.v(TAG, "Interrupted in onPut blocking"); |
| } |
| } |
| } |
| if (D) { |
| Log.d(TAG, "Server unblocked "); |
| } |
| synchronized (this) { |
| if (mCallback != null && mTimeoutMsgSent) { |
| mCallback.removeMessages(BluetoothOppObexSession.MSG_CONNECT_TIMEOUT); |
| } |
| } |
| |
| /* we should have mInfo now */ |
| |
| /* |
| * TODO check if this mInfo match the one that we insert before server |
| * blocking? just to make sure no error happens |
| */ |
| if (mInfo.mId != mLocalShareInfoId) { |
| Log.e(TAG, "Unexpected error!"); |
| } |
| mAccepted = mInfo.mConfirm; |
| |
| if (V) { |
| Log.v(TAG, "after confirm: userAccepted=" + mAccepted); |
| } |
| int status = BluetoothShare.STATUS_SUCCESS; |
| |
| int obexResponse = ResponseCodes.OBEX_HTTP_OK; |
| |
| if (mAccepted == BluetoothShare.USER_CONFIRMATION_CONFIRMED |
| || mAccepted == BluetoothShare.USER_CONFIRMATION_AUTO_CONFIRMED |
| || mAccepted == BluetoothShare.USER_CONFIRMATION_HANDOVER_CONFIRMED) { |
| /* Confirm or auto-confirm */ |
| mNumFilesAttemptedToReceive++; |
| |
| if (mFileInfo.mFileName == null) { |
| status = mFileInfo.mStatus; |
| /* TODO need to check if this line is correct */ |
| mInfo.mStatus = mFileInfo.mStatus; |
| Constants.updateShareStatus(mContext, mInfo.mId, status); |
| obexResponse = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; |
| |
| } |
| |
| if (mFileInfo.mFileName != null && mFileInfo.mInsertUri != null) { |
| |
| ContentValues updateValues = new ContentValues(); |
| contentUri = Uri.parse(BluetoothShare.CONTENT_URI + "/" + mInfo.mId); |
| updateValues.put(BluetoothShare._DATA, mFileInfo.mFileName); |
| updateValues.put(BluetoothShare.STATUS, BluetoothShare.STATUS_RUNNING); |
| updateValues.put(BluetoothShare.URI, mFileInfo.mInsertUri.toString()); |
| mContext.getContentResolver().update(contentUri, updateValues, null, null); |
| |
| mInfo.mUri = mFileInfo.mInsertUri; |
| status = receiveFile(mFileInfo, op); |
| /* |
| * TODO map status to obex response code |
| */ |
| if (status != BluetoothShare.STATUS_SUCCESS) { |
| obexResponse = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; |
| } |
| Constants.updateShareStatus(mContext, mInfo.mId, status); |
| } |
| |
| if (status == BluetoothShare.STATUS_SUCCESS) { |
| Message msg = Message.obtain(mCallback, BluetoothOppObexSession.MSG_SHARE_COMPLETE); |
| msg.obj = mInfo; |
| msg.sendToTarget(); |
| } else { |
| if (mCallback != null) { |
| Message msg = |
| Message.obtain(mCallback, BluetoothOppObexSession.MSG_SESSION_ERROR); |
| mInfo.mStatus = status; |
| msg.obj = mInfo; |
| msg.sendToTarget(); |
| } |
| } |
| } else if (mAccepted == BluetoothShare.USER_CONFIRMATION_DENIED |
| || mAccepted == BluetoothShare.USER_CONFIRMATION_TIMEOUT) { |
| /* user actively deny the inbound transfer */ |
| /* |
| * Note There is a question: what's next if user deny the first obj? |
| * Option 1 :continue prompt for next objects |
| * Option 2 :reject next objects and finish the session |
| * Now we take option 2: |
| */ |
| |
| Log.i(TAG, "Rejected incoming request"); |
| if (mFileInfo.mInsertUri != null) { |
| mContext.getContentResolver().delete(mFileInfo.mInsertUri, null, null); |
| } |
| // set status as local cancel |
| status = BluetoothShare.STATUS_CANCELED; |
| Constants.updateShareStatus(mContext, mInfo.mId, status); |
| obexResponse = ResponseCodes.OBEX_HTTP_FORBIDDEN; |
| |
| Message msg = Message.obtain(mCallback); |
| msg.what = BluetoothOppObexSession.MSG_SHARE_INTERRUPTED; |
| mInfo.mStatus = status; |
| msg.obj = mInfo; |
| msg.sendToTarget(); |
| } |
| return obexResponse; |
| } |
| |
| private int receiveFile(BluetoothOppReceiveFileInfo fileInfo, Operation op) { |
| /* |
| * implement receive file |
| */ |
| int status = -1; |
| OutputStream os = null; |
| InputStream is = null; |
| boolean error = false; |
| try { |
| is = op.openInputStream(); |
| } catch (IOException e1) { |
| Log.e(TAG, "Error when openInputStream"); |
| status = BluetoothShare.STATUS_OBEX_DATA_ERROR; |
| error = true; |
| } |
| |
| Uri contentUri = Uri.parse(BluetoothShare.CONTENT_URI + "/" + mInfo.mId); |
| |
| if (!error) { |
| ContentValues updateValues = new ContentValues(); |
| updateValues.put(BluetoothShare._DATA, fileInfo.mFileName); |
| mContext.getContentResolver().update(contentUri, updateValues, null, null); |
| } |
| |
| long position = 0; |
| long percent; |
| long prevPercent = 0; |
| |
| if (!error) { |
| try { |
| os = mContext.getContentResolver().openOutputStream(fileInfo.mInsertUri); |
| } catch (FileNotFoundException e) { |
| Log.e(TAG, "Error when openOutputStream"); |
| error = true; |
| } |
| } |
| |
| if (!error) { |
| int outputBufferSize = op.getMaxPacketSize(); |
| byte[] b = new byte[outputBufferSize]; |
| int readLength; |
| long timestamp = 0; |
| long currentTime; |
| long prevTimestamp = SystemClock.elapsedRealtime(); |
| try { |
| while ((!mInterrupted) && (position != fileInfo.mLength)) { |
| |
| if (V) { |
| timestamp = SystemClock.elapsedRealtime(); |
| } |
| |
| readLength = is.read(b); |
| |
| if (readLength == -1) { |
| if (D) { |
| Log.d(TAG, "Receive file reached stream end at position" + position); |
| } |
| break; |
| } |
| |
| os.write(b, 0, readLength); |
| position += readLength; |
| percent = position * 100 / fileInfo.mLength; |
| currentTime = SystemClock.elapsedRealtime(); |
| |
| if (V) { |
| Log.v(TAG, |
| "Receive file position = " + position + " readLength " + readLength |
| + " bytes took " + (currentTime - timestamp) + " ms"); |
| } |
| |
| // Update the Progress Bar only if there is change in percentage |
| // or once per a period to notify NFC of this transfer is still alive |
| if (percent > prevPercent |
| || currentTime - prevTimestamp > Constants.NFC_ALIVE_CHECK_MS) { |
| ContentValues updateValues = new ContentValues(); |
| updateValues.put(BluetoothShare.CURRENT_BYTES, position); |
| mContext.getContentResolver().update(contentUri, updateValues, null, null); |
| prevPercent = percent; |
| prevTimestamp = currentTime; |
| } |
| } |
| } catch (IOException e1) { |
| Log.e(TAG, "Error when receiving file: " + e1); |
| /* OBEX Abort packet received from remote device */ |
| if ("Abort Received".equals(e1.getMessage())) { |
| status = BluetoothShare.STATUS_CANCELED; |
| } else { |
| status = BluetoothShare.STATUS_OBEX_DATA_ERROR; |
| } |
| error = true; |
| } |
| } |
| |
| if (mInterrupted) { |
| if (D) { |
| Log.d(TAG, "receiving file interrupted by user."); |
| } |
| status = BluetoothShare.STATUS_CANCELED; |
| } else { |
| if (position == fileInfo.mLength) { |
| if (D) { |
| Log.d(TAG, "Receiving file completed for " + fileInfo.mFileName); |
| } |
| status = BluetoothShare.STATUS_SUCCESS; |
| } else { |
| if (D) { |
| Log.d(TAG, "Reading file failed at " + position + " of " + fileInfo.mLength); |
| } |
| if (status == -1) { |
| status = BluetoothShare.STATUS_UNKNOWN_ERROR; |
| } |
| } |
| } |
| |
| if (os != null) { |
| try { |
| os.flush(); |
| os.close(); |
| } catch (IOException e) { |
| Log.e(TAG, "Error when closing stream after send"); |
| } |
| } |
| BluetoothOppUtility.cancelNotification(mContext); |
| return status; |
| } |
| |
| private BluetoothOppReceiveFileInfo processShareInfo() { |
| if (D) { |
| Log.d(TAG, "processShareInfo() " + mInfo.mId); |
| } |
| BluetoothOppReceiveFileInfo fileInfo = |
| BluetoothOppReceiveFileInfo.generateFileInfo(mContext, mInfo.mId); |
| if (V) { |
| Log.v(TAG, "Generate BluetoothOppReceiveFileInfo:"); |
| Log.v(TAG, "filename :" + fileInfo.mFileName); |
| Log.v(TAG, "length :" + fileInfo.mLength); |
| Log.v(TAG, "status :" + fileInfo.mStatus); |
| } |
| return fileInfo; |
| } |
| |
| @Override |
| public int onConnect(HeaderSet request, HeaderSet reply) { |
| |
| if (D) { |
| Log.d(TAG, "onConnect"); |
| } |
| if (V) { |
| Constants.logHeader(request); |
| } |
| Long objectCount = null; |
| try { |
| byte[] uuid = (byte[]) request.getHeader(HeaderSet.TARGET); |
| if (V) { |
| Log.v(TAG, "onConnect(): uuid =" + Arrays.toString(uuid)); |
| } |
| if (uuid != null) { |
| return ResponseCodes.OBEX_HTTP_NOT_ACCEPTABLE; |
| } |
| |
| objectCount = (Long) request.getHeader(HeaderSet.COUNT); |
| } catch (IOException e) { |
| Log.e(TAG, e.toString()); |
| return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR; |
| } |
| String destination; |
| if (mTransport instanceof BluetoothObexTransport) { |
| destination = ((BluetoothObexTransport) mTransport).getRemoteAddress(); |
| } else { |
| destination = "FF:FF:FF:00:00:00"; |
| } |
| boolean isHandover = BluetoothOppManager.getInstance(mContext).isAcceptlisted(destination); |
| if (isHandover) { |
| // Notify the handover requester file transfer has started |
| Intent intent = new Intent(Constants.ACTION_HANDOVER_STARTED); |
| if (objectCount != null) { |
| intent.putExtra(Constants.EXTRA_BT_OPP_OBJECT_COUNT, objectCount.intValue()); |
| } else { |
| intent.putExtra(Constants.EXTRA_BT_OPP_OBJECT_COUNT, |
| Constants.COUNT_HEADER_UNAVAILABLE); |
| } |
| intent.putExtra(Constants.EXTRA_BT_OPP_ADDRESS, destination); |
| mContext.sendBroadcast(intent, Constants.HANDOVER_STATUS_PERMISSION); |
| } |
| mTimestamp = System.currentTimeMillis(); |
| mNumFilesAttemptedToReceive = 0; |
| return ResponseCodes.OBEX_HTTP_OK; |
| } |
| |
| @Override |
| public void onDisconnect(HeaderSet req, HeaderSet resp) { |
| if (D) { |
| Log.d(TAG, "onDisconnect"); |
| } |
| if (mNumFilesAttemptedToReceive > 0) { |
| // Log incoming OPP transfer if more than one file is accepted by user |
| MetricsLogger.logProfileConnectionEvent(BluetoothMetricsProto.ProfileId.OPP); |
| } |
| resp.responseCode = ResponseCodes.OBEX_HTTP_OK; |
| } |
| |
| private synchronized void releaseWakeLocks() { |
| if (mPartialWakeLock.isHeld()) { |
| mPartialWakeLock.release(); |
| } |
| } |
| |
| @Override |
| public void onClose() { |
| if (D) { |
| Log.d(TAG, "onClose"); |
| } |
| releaseWakeLocks(); |
| mBluetoothOppService.acceptNewConnections(); |
| BluetoothOppUtility.cancelNotification(mContext); |
| /* onClose could happen even before start() where mCallback is set */ |
| if (mCallback != null) { |
| Message msg = Message.obtain(mCallback); |
| msg.what = BluetoothOppObexSession.MSG_SESSION_COMPLETE; |
| msg.obj = mInfo; |
| msg.sendToTarget(); |
| } |
| } |
| } |