/*
* Copyright (C) 2014 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.bluetooth.le;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresNoPermission;
import android.annotation.RequiresPermission;
import android.annotation.SuppressLint;
import android.annotation.SystemApi;
import android.app.PendingIntent;
import android.bluetooth.Attributable;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.IBluetoothGatt;
import android.bluetooth.IBluetoothScan;
import android.bluetooth.annotations.RequiresBluetoothLocationPermission;
import android.bluetooth.annotations.RequiresBluetoothScanPermission;
import android.bluetooth.annotations.RequiresLegacyBluetoothAdminPermission;
import android.content.AttributionSource;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.os.WorkSource;
import android.util.Log;
import com.android.bluetooth.flags.Flags;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* This class provides methods to perform scan related operations for Bluetooth LE devices. An
* application can scan for a particular type of Bluetooth LE devices using {@link ScanFilter}. It
* can also request different types of callbacks for delivering the result.
*
*
Use {@link BluetoothAdapter#getBluetoothLeScanner()} to get an instance of {@link
* BluetoothLeScanner}.
*
* @see ScanFilter
*/
public final class BluetoothLeScanner {
private static final String TAG = "BluetoothLeScanner";
private static final boolean DBG = true;
private static final boolean VDBG = false;
/**
* Extra containing a list of ScanResults. It can have one or more results if there was no
* error. In case of error, {@link #EXTRA_ERROR_CODE} will contain the error code and this extra
* will not be available.
*/
public static final String EXTRA_LIST_SCAN_RESULT =
"android.bluetooth.le.extra.LIST_SCAN_RESULT";
/**
* Optional extra indicating the error code, if any. The error code will be one of the
* SCAN_FAILED_* codes in {@link ScanCallback}.
*/
public static final String EXTRA_ERROR_CODE = "android.bluetooth.le.extra.ERROR_CODE";
/**
* Optional extra indicating the callback type, which will be one of CALLBACK_TYPE_* constants
* in {@link ScanSettings}.
*
* @see ScanCallback#onScanResult(int, ScanResult)
*/
public static final String EXTRA_CALLBACK_TYPE = "android.bluetooth.le.extra.CALLBACK_TYPE";
private final BluetoothAdapter mBluetoothAdapter;
private final AttributionSource mAttributionSource;
private final Handler mHandler;
private final Map mLeScanClients;
/**
* Use {@link BluetoothAdapter#getBluetoothLeScanner()} instead.
*
* @hide
*/
public BluetoothLeScanner(BluetoothAdapter bluetoothAdapter) {
mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
mAttributionSource = mBluetoothAdapter.getAttributionSource();
mHandler = new Handler(Looper.getMainLooper());
mLeScanClients = new HashMap();
}
/**
* Start Bluetooth LE scan with default parameters and no filters. The scan results will be
* delivered through {@code callback}. For unfiltered scans, scanning is stopped on screen off
* to save power. Scanning is resumed when screen is turned on again. To avoid this, use {@link
* #startScan(List, ScanSettings, ScanCallback)} with desired {@link ScanFilter}.
*
* An app must have {@link android.Manifest.permission#ACCESS_COARSE_LOCATION
* ACCESS_COARSE_LOCATION} permission in order to get results. An App targeting Android Q or
* later must have {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION}
* permission in order to get results.
*
* @param callback Callback used to deliver scan results.
* @throws IllegalArgumentException If {@code callback} is null.
*/
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresBluetoothLocationPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void startScan(final ScanCallback callback) {
startScan(null, new ScanSettings.Builder().build(), callback);
}
/**
* Start Bluetooth LE scan. The scan results will be delivered through {@code callback}. For
* unfiltered scans, scanning is stopped on screen off to save power. Scanning is resumed when
* screen is turned on again. To avoid this, do filtered scanning by using proper {@link
* ScanFilter}.
*
*
An app must have {@link android.Manifest.permission#ACCESS_COARSE_LOCATION
* ACCESS_COARSE_LOCATION} permission in order to get results. An App targeting Android Q or
* later must have {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION}
* permission in order to get results.
*
* @param filters {@link ScanFilter}s for finding exact BLE devices.
* @param settings Settings for the scan.
* @param callback Callback used to deliver scan results.
* @throws IllegalArgumentException If {@code settings} or {@code callback} is null.
*/
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresBluetoothLocationPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void startScan(
List filters, ScanSettings settings, final ScanCallback callback) {
startScan(filters, settings, null, callback, /* callbackIntent= */ null);
}
/**
* Start Bluetooth LE scan using a {@link PendingIntent}. The scan results will be delivered via
* the PendingIntent. Use this method of scanning if your process is not always running and it
* should be started when scan results are available.
*
* An app must have {@link android.Manifest.permission#ACCESS_COARSE_LOCATION
* ACCESS_COARSE_LOCATION} permission in order to get results. An App targeting Android Q or
* later must have {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION}
* permission in order to get results.
*
*
When the PendingIntent is delivered, the Intent passed to the receiver or activity will
* contain one or more of the extras {@link #EXTRA_CALLBACK_TYPE}, {@link #EXTRA_ERROR_CODE} and
* {@link #EXTRA_LIST_SCAN_RESULT} to indicate the result of the scan.
*
* @param filters Optional list of ScanFilters for finding exact BLE devices.
* @param settings Optional settings for the scan.
* @param callbackIntent The PendingIntent to deliver the result to.
* @return Returns 0 for success or an error code from {@link ScanCallback} if the scan request
* could not be sent.
* @see #stopScan(PendingIntent)
*/
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresBluetoothLocationPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public int startScan(
@Nullable List filters,
@Nullable ScanSettings settings,
@NonNull PendingIntent callbackIntent) {
return startScan(
filters,
settings != null ? settings : new ScanSettings.Builder().build(),
null,
null,
callbackIntent);
}
/**
* Start Bluetooth LE scan. Same as {@link #startScan(ScanCallback)} but allows the caller to
* specify on behalf of which application(s) the work is being done.
*
* @param workSource {@link WorkSource} identifying the application(s) for which to blame for
* the scan.
* @param callback Callback used to deliver scan results.
* @hide
*/
@SystemApi
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresBluetoothLocationPermission
@RequiresPermission(
allOf = {
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.UPDATE_DEVICE_STATS
})
public void startScanFromSource(final WorkSource workSource, final ScanCallback callback) {
startScanFromSource(null, new ScanSettings.Builder().build(), workSource, callback);
}
/**
* Start Bluetooth LE scan. Same as {@link #startScan(List, ScanSettings, ScanCallback)} but
* allows the caller to specify on behalf of which application(s) the work is being done.
*
* @param filters {@link ScanFilter}s for finding exact BLE devices.
* @param settings Settings for the scan.
* @param workSource {@link WorkSource} identifying the application(s) for which to blame for
* the scan.
* @param callback Callback used to deliver scan results.
* @hide
*/
@SystemApi
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresBluetoothLocationPermission
@RequiresPermission(
allOf = {
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.UPDATE_DEVICE_STATS
})
@SuppressLint("AndroidFrameworkRequiresPermission")
public void startScanFromSource(
List filters,
ScanSettings settings,
final WorkSource workSource,
final ScanCallback callback) {
startScan(filters, settings, workSource, callback, null);
}
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
private int startScan(
List filters,
ScanSettings settings,
final WorkSource workSource,
final ScanCallback callback,
final PendingIntent callbackIntent) {
BluetoothLeUtils.checkAdapterStateOn(mBluetoothAdapter);
if (callback == null && callbackIntent == null) {
throw new IllegalArgumentException("callback is null");
}
if (settings == null) {
throw new IllegalArgumentException("settings is null");
}
synchronized (mLeScanClients) {
if (callback != null && mLeScanClients.containsKey(callback)) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_ALREADY_STARTED);
}
IBluetoothScan scan = null;
IBluetoothGatt gatt = null;
if (Flags.scanManagerRefactor()) {
scan = mBluetoothAdapter.getBluetoothScan();
if (scan == null) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
}
} else {
gatt = mBluetoothAdapter.getBluetoothGatt();
if (gatt == null) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
}
}
if (!isSettingsConfigAllowedForScan(settings)) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED);
}
if (!isHardwareResourcesAvailableForScan(settings)) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES);
}
if (!isSettingsAndFilterComboAllowed(settings, filters)) {
return postCallbackErrorOrReturn(
callback, ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED);
}
if (callback != null) {
BleScanCallbackWrapper wrapper =
new BleScanCallbackWrapper(
gatt, scan, filters, settings, workSource, callback);
wrapper.startRegistration();
} else {
try {
if (Flags.scanManagerRefactor()) {
scan.startScanForIntent(
callbackIntent, settings, filters, mAttributionSource);
} else {
gatt.startScanForIntent(
callbackIntent, settings, filters, mAttributionSource);
}
} catch (RemoteException e) {
return ScanCallback.SCAN_FAILED_INTERNAL_ERROR;
}
}
}
return ScanCallback.NO_ERROR;
}
/** Stops an ongoing Bluetooth LE scan. */
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void stopScan(ScanCallback callback) {
BluetoothLeUtils.checkAdapterStateOn(mBluetoothAdapter);
synchronized (mLeScanClients) {
BleScanCallbackWrapper wrapper = mLeScanClients.remove(callback);
if (wrapper == null) {
if (DBG) Log.d(TAG, "could not find callback wrapper");
return;
}
wrapper.stopLeScan();
}
}
/**
* Stops an ongoing Bluetooth LE scan started using a PendingIntent. When creating the
* PendingIntent parameter, please do not use the FLAG_CANCEL_CURRENT flag. Otherwise, the stop
* scan may have no effect.
*
* @param callbackIntent The PendingIntent that was used to start the scan.
* @see #startScan(List, ScanSettings, PendingIntent)
*/
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void stopScan(PendingIntent callbackIntent) {
BluetoothLeUtils.checkAdapterStateOn(mBluetoothAdapter);
try {
if (Flags.scanManagerRefactor()) {
IBluetoothScan scan = mBluetoothAdapter.getBluetoothScan();
if (scan == null) {
Log.w(TAG, "stopScan called after bluetooth has been turned off");
return;
}
scan.stopScanForIntent(callbackIntent, mAttributionSource);
} else {
IBluetoothGatt gatt = mBluetoothAdapter.getBluetoothGatt();
if (gatt == null) {
Log.w(TAG, "stopScan called after bluetooth has been turned off");
return;
}
gatt.stopScanForIntent(callbackIntent, mAttributionSource);
}
} catch (RemoteException e) {
Log.e(TAG, "Failed to stop scan", e);
}
}
/**
* Flush pending batch scan results stored in Bluetooth controller. This will return Bluetooth
* LE scan results batched on bluetooth controller. Returns immediately, batch scan results data
* will be delivered through the {@code callback}.
*
* @param callback Callback of the Bluetooth LE Scan, it has to be the same instance as the one
* used to start scan.
*/
@RequiresLegacyBluetoothAdminPermission
@RequiresBluetoothScanPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void flushPendingScanResults(ScanCallback callback) {
BluetoothLeUtils.checkAdapterStateOn(mBluetoothAdapter);
if (callback == null) {
throw new IllegalArgumentException("callback cannot be null!");
}
synchronized (mLeScanClients) {
BleScanCallbackWrapper wrapper = mLeScanClients.get(callback);
if (wrapper == null) {
return;
}
wrapper.flushPendingBatchResults();
}
}
/**
* Start truncated scan.
*
* @deprecated this is not used anywhere
* @hide
*/
@Deprecated
@SystemApi
@RequiresBluetoothScanPermission
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void startTruncatedScan(
List truncatedFilters,
ScanSettings settings,
final ScanCallback callback) {
int filterSize = truncatedFilters.size();
List scanFilters = new ArrayList(filterSize);
for (TruncatedFilter filter : truncatedFilters) {
scanFilters.add(filter.getFilter());
}
startScan(scanFilters, settings, null, callback, null);
}
/**
* Cleans up scan clients. Should be called when bluetooth is down.
*
* @hide
*/
@RequiresNoPermission
public void cleanup() {
mLeScanClients.clear();
}
/** Bluetooth GATT interface callbacks */
@SuppressLint("AndroidFrameworkRequiresPermission")
private class BleScanCallbackWrapper extends IScannerCallback.Stub {
private static final int REGISTRATION_CALLBACK_TIMEOUT_MILLIS = 2000;
private final ScanCallback mScanCallback;
private final List mFilters;
private final WorkSource mWorkSource;
private ScanSettings mSettings;
private IBluetoothGatt mBluetoothGatt;
private IBluetoothScan mBluetoothScan;
// mLeHandle 0: not registered
// -2: registration failed because app is scanning to frequently
// -1: scan stopped or registration failed
// > 0: registered and scan started
private int mScannerId;
public BleScanCallbackWrapper(
IBluetoothGatt bluetoothGatt,
IBluetoothScan bluetoothScan,
List filters,
ScanSettings settings,
WorkSource workSource,
ScanCallback scanCallback) {
mBluetoothGatt = bluetoothGatt;
mBluetoothScan = bluetoothScan;
mFilters = filters;
mSettings = settings;
mWorkSource = workSource;
mScanCallback = scanCallback;
mScannerId = 0;
}
@SuppressWarnings("WaitNotInLoop") // TODO(b/314811467)
public void startRegistration() {
synchronized (this) {
// Scan stopped.
if (mScannerId == -1 || mScannerId == -2) return;
try {
if (Flags.scanManagerRefactor()) {
mBluetoothScan.registerScanner(this, mWorkSource, mAttributionSource);
} else {
mBluetoothGatt.registerScanner(this, mWorkSource, mAttributionSource);
}
wait(REGISTRATION_CALLBACK_TIMEOUT_MILLIS);
} catch (InterruptedException | RemoteException e) {
Log.e(TAG, "application registration exception", e);
postCallbackError(mScanCallback, ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
}
if (mScannerId > 0) {
mLeScanClients.put(mScanCallback, this);
} else {
// Registration timed out or got exception, reset scannerId to -1 so no
// subsequent operations can proceed.
if (mScannerId == 0) mScannerId = -1;
// If scanning too frequently, don't report anything to the app.
if (mScannerId == -2) {
Log.e(TAG, "registration failed because app is scanning too frequently");
return;
}
postCallbackError(
mScanCallback,
ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED);
}
}
}
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
public void stopLeScan() {
synchronized (this) {
if (mScannerId <= 0) {
Log.e(TAG, "Error state, mLeHandle: " + mScannerId);
return;
}
try {
if (Flags.scanManagerRefactor()) {
mBluetoothScan.stopScan(mScannerId, mAttributionSource);
mBluetoothScan.unregisterScanner(mScannerId, mAttributionSource);
} else {
mBluetoothGatt.stopScan(mScannerId, mAttributionSource);
mBluetoothGatt.unregisterScanner(mScannerId, mAttributionSource);
}
} catch (RemoteException e) {
Log.e(TAG, "Failed to stop scan and unregister", e);
}
mScannerId = -1;
}
}
@RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
void flushPendingBatchResults() {
synchronized (this) {
if (mScannerId <= 0) {
Log.e(TAG, "Error state, mLeHandle: " + mScannerId);
return;
}
try {
if (Flags.scanManagerRefactor()) {
mBluetoothScan.flushPendingBatchResults(mScannerId, mAttributionSource);
} else {
mBluetoothGatt.flushPendingBatchResults(mScannerId, mAttributionSource);
}
} catch (RemoteException e) {
Log.e(TAG, "Failed to get pending scan results", e);
}
}
}
/** Application interface registered - app is ready to go */
@Override
public void onScannerRegistered(int status, int scannerId) {
Log.d(
TAG,
"onScannerRegistered() - status="
+ status
+ " scannerId="
+ scannerId
+ " mScannerId="
+ mScannerId);
synchronized (this) {
if (status == BluetoothGatt.GATT_SUCCESS) {
try {
if (mScannerId == -1) {
// Registration succeeds after timeout, unregister scanner.
if (Flags.scanManagerRefactor()) {
mBluetoothScan.unregisterScanner(scannerId, mAttributionSource);
} else {
mBluetoothGatt.unregisterScanner(scannerId, mAttributionSource);
}
} else {
mScannerId = scannerId;
if (Flags.scanManagerRefactor()) {
mBluetoothScan.startScan(
mScannerId, mSettings, mFilters, mAttributionSource);
} else {
mBluetoothGatt.startScan(
mScannerId, mSettings, mFilters, mAttributionSource);
}
}
} catch (RemoteException e) {
Log.e(TAG, "fail to start le scan: " + e);
mScannerId = -1;
}
} else if (status == ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY) {
// application was scanning too frequently
mScannerId = -2;
} else {
// registration failed
mScannerId = -1;
}
notifyAll();
}
}
/**
* Callback reporting an LE scan result.
*
* @hide
*/
@Override
public void onScanResult(final ScanResult scanResult) {
Attributable.setAttributionSource(scanResult, mAttributionSource);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onScanResult() - mScannerId=" + mScannerId);
}
if (VDBG) Log.d(TAG, "onScanResult() - " + scanResult.toString());
// Check null in case the scan has been stopped
synchronized (this) {
if (mScannerId <= 0) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Ignoring result as scan stopped.");
}
return;
}
;
}
Handler handler = new Handler(Looper.getMainLooper());
handler.post(
new Runnable() {
@Override
public void run() {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onScanResult() - handler run");
}
mScanCallback.onScanResult(
ScanSettings.CALLBACK_TYPE_ALL_MATCHES, scanResult);
}
});
}
@Override
public void onBatchScanResults(final List results) {
Attributable.setAttributionSource(results, mAttributionSource);
Handler handler = new Handler(Looper.getMainLooper());
handler.post(
new Runnable() {
@Override
public void run() {
mScanCallback.onBatchScanResults(results);
}
});
}
@Override
public void onFoundOrLost(final boolean onFound, final ScanResult scanResult) {
Attributable.setAttributionSource(scanResult, mAttributionSource);
if (VDBG) {
Log.d(TAG, "onFoundOrLost() - onFound = " + onFound + " " + scanResult.toString());
}
// Check null in case the scan has been stopped
synchronized (this) {
if (mScannerId <= 0) {
return;
}
}
Handler handler = new Handler(Looper.getMainLooper());
handler.post(
new Runnable() {
@Override
public void run() {
if (onFound) {
mScanCallback.onScanResult(
ScanSettings.CALLBACK_TYPE_FIRST_MATCH, scanResult);
} else {
mScanCallback.onScanResult(
ScanSettings.CALLBACK_TYPE_MATCH_LOST, scanResult);
}
}
});
}
@Override
public void onScanManagerErrorCallback(final int errorCode) {
if (VDBG) {
Log.d(TAG, "onScanManagerErrorCallback() - errorCode = " + errorCode);
}
synchronized (this) {
if (mScannerId <= 0) {
return;
}
}
postCallbackError(mScanCallback, errorCode);
}
}
private int postCallbackErrorOrReturn(final ScanCallback callback, final int errorCode) {
if (callback == null) {
return errorCode;
} else {
postCallbackError(callback, errorCode);
return ScanCallback.NO_ERROR;
}
}
@SuppressLint("AndroidFrameworkBluetoothPermission")
private void postCallbackError(final ScanCallback callback, final int errorCode) {
mHandler.post(
new Runnable() {
@Override
public void run() {
callback.onScanFailed(errorCode);
}
});
}
private boolean isSettingsConfigAllowedForScan(ScanSettings settings) {
if (mBluetoothAdapter.isOffloadedFilteringSupported()) {
return true;
}
final int callbackType = settings.getCallbackType();
// Only support regular scan if no offloaded filter support.
if (callbackType == ScanSettings.CALLBACK_TYPE_ALL_MATCHES
&& settings.getReportDelayMillis() == 0) {
return true;
}
return false;
}
private boolean isSettingsAndFilterComboAllowed(
ScanSettings settings, List filterList) {
final int callbackType = settings.getCallbackType();
// If onlost/onfound is requested, a non-empty filter is expected
if ((callbackType
& (ScanSettings.CALLBACK_TYPE_FIRST_MATCH
| ScanSettings.CALLBACK_TYPE_MATCH_LOST))
!= 0) {
if (filterList == null) {
return false;
}
for (ScanFilter filter : filterList) {
if (filter.isAllFieldsEmpty()) {
return false;
}
}
}
return true;
}
@RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
private boolean isHardwareResourcesAvailableForScan(ScanSettings settings) {
final int callbackType = settings.getCallbackType();
if ((callbackType & ScanSettings.CALLBACK_TYPE_FIRST_MATCH) != 0
|| (callbackType & ScanSettings.CALLBACK_TYPE_MATCH_LOST) != 0) {
// For onlost/onfound, we required hw support be available
return (mBluetoothAdapter.isOffloadedFilteringSupported()
&& mBluetoothAdapter.isHardwareTrackingFiltersAvailable());
}
return true;
}
}