| /* |
| * Copyright (C) 2012 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 com.android.bluetooth; |
| |
| import static android.Manifest.permission.ACCESS_COARSE_LOCATION; |
| import static android.Manifest.permission.ACCESS_FINE_LOCATION; |
| import static android.Manifest.permission.BLUETOOTH_ADVERTISE; |
| import static android.Manifest.permission.BLUETOOTH_CONNECT; |
| import static android.Manifest.permission.BLUETOOTH_SCAN; |
| import static android.Manifest.permission.RENOUNCE_PERMISSIONS; |
| import static android.bluetooth.BluetoothUtils.USER_HANDLE_NULL; |
| import static android.content.pm.PackageManager.GET_PERMISSIONS; |
| import static android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES; |
| import static android.content.pm.PackageManager.PERMISSION_GRANTED; |
| import static android.os.PowerExemptionManager.TEMPORARY_ALLOW_LIST_TYPE_FOREGROUND_SERVICE_ALLOWED; |
| import static android.permission.PermissionManager.PERMISSION_HARD_DENIED; |
| |
| import android.Manifest; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.RequiresPermission; |
| import android.annotation.SuppressLint; |
| import android.app.BroadcastOptions; |
| import android.bluetooth.BluetoothAdapter; |
| import android.bluetooth.BluetoothDevice; |
| import android.companion.AssociationInfo; |
| import android.companion.CompanionDeviceManager; |
| import android.content.AttributionSource; |
| import android.content.BroadcastReceiver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.pm.PackageInfo; |
| import android.content.pm.PackageManager; |
| import android.location.LocationManager; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.ParcelUuid; |
| import android.os.PowerExemptionManager; |
| import android.os.Process; |
| import android.os.SystemProperties; |
| import android.os.UserHandle; |
| import android.os.UserManager; |
| import android.permission.PermissionManager; |
| import android.provider.DeviceConfig; |
| import android.provider.Telephony; |
| import android.util.Log; |
| |
| import androidx.annotation.RequiresApi; |
| |
| import com.android.bluetooth.btservice.AdapterService; |
| import com.android.bluetooth.btservice.ProfileService; |
| |
| import org.xmlpull.v1.XmlPullParser; |
| import org.xmlpull.v1.XmlPullParserException; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.math.BigInteger; |
| import java.nio.ByteBuffer; |
| import java.nio.ByteOrder; |
| import java.nio.charset.Charset; |
| import java.nio.charset.CharsetDecoder; |
| import java.time.Instant; |
| import java.time.ZoneId; |
| import java.time.format.DateTimeFormatter; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.UUID; |
| import java.util.concurrent.TimeUnit; |
| |
| /** |
| * @hide |
| */ |
| public final class Utils { |
| private static final String TAG = "BluetoothUtils"; |
| private static final int MICROS_PER_UNIT = 625; |
| private static final String PTS_TEST_MODE_PROPERTY = "persist.bluetooth.pts"; |
| |
| private static final String ENABLE_DUAL_MODE_AUDIO = |
| "persist.bluetooth.enable_dual_mode_audio"; |
| private static boolean sDualModeEnabled = |
| SystemProperties.getBoolean(ENABLE_DUAL_MODE_AUDIO, false); |
| |
| private static final String KEY_TEMP_ALLOW_LIST_DURATION_MS = "temp_allow_list_duration_ms"; |
| private static final long DEFAULT_TEMP_ALLOW_LIST_DURATION_MS = 20_000; |
| |
| static final int BD_ADDR_LEN = 6; // bytes |
| static final int BD_UUID_LEN = 16; // bytes |
| |
| /* |
| * Special character |
| * |
| * (See "What is a phone number?" doc) |
| * 'p' --- GSM pause character, same as comma |
| * 'n' --- GSM wild character |
| * 'w' --- GSM wait character |
| */ |
| public static final char PAUSE = ','; |
| public static final char WAIT = ';'; |
| public static final String PAIRING_UI_PROPERTY = "bluetooth.pairing_ui_package.name"; |
| |
| private static boolean isPause(char c) { |
| return c == 'p' || c == 'P'; |
| } |
| |
| private static boolean isToneWait(char c) { |
| return c == 'w' || c == 'W'; |
| } |
| |
| /** |
| * Check if dual mode audio is enabled. This is set via the system property |
| * persist.bluetooth.enable_dual_mode_audio. |
| * <p> |
| * When set to {@code false}, we will not connect A2DP and HFP on a dual mode (BR/EDR + BLE) |
| * device. We will only attempt to use BLE Audio in this scenario. |
| * <p> |
| * When set to {@code true}, we will connect all the supported audio profiles |
| * (A2DP, HFP, and LE Audio) at the same time. In this state, we will respect calls to |
| * profile-specific APIs (e.g. if a SCO API is invoked, we will route audio over HFP). If no |
| * profile-specific API is invoked to route audio (e.g. Telecom routed phone calls, media, |
| * game audio, etc.), then audio will be routed in accordance with the preferred audio profiles |
| * for the remote device. You can get the preferred audio profiles for a remote device by |
| * calling {@link BluetoothAdapter#getPreferredAudioProfiles(BluetoothDevice)}. |
| * |
| * @return true if dual mode audio is enabled, false otherwise |
| */ |
| public static boolean isDualModeAudioEnabled() { |
| Log.i(TAG, "Dual mode enable state is: " + sDualModeEnabled); |
| return sDualModeEnabled; |
| } |
| |
| /** |
| * Only exposed for testing, do not invoke this method outside of tests. |
| * @param enabled true if the dual mode state is enabled, false otherwise |
| */ |
| public static void setDualModeAudioStateForTesting(boolean enabled) { |
| Log.i(TAG, "Updating dual mode audio state for testing to: " + enabled); |
| sDualModeEnabled = enabled; |
| } |
| |
| public static @Nullable String getName(@Nullable BluetoothDevice device) { |
| final AdapterService service = AdapterService.getAdapterService(); |
| if (service != null && device != null) { |
| return service.getRemoteName(device); |
| } else { |
| return null; |
| } |
| } |
| |
| public static String getLoggableAddress(@Nullable BluetoothDevice device) { |
| if (device == null) { |
| return "00:00:00:00:00:00"; |
| } else { |
| return "xx:xx:xx:xx:" + device.toString().substring(12); |
| } |
| } |
| |
| public static String getAddressStringFromByte(byte[] address) { |
| if (address == null || address.length != BD_ADDR_LEN) { |
| return null; |
| } |
| |
| return String.format("%02X:%02X:%02X:%02X:%02X:%02X", address[0], address[1], address[2], |
| address[3], address[4], address[5]); |
| } |
| |
| public static String getRedactedAddressStringFromByte(byte[] address) { |
| if (address == null || address.length != BD_ADDR_LEN) { |
| return null; |
| } |
| |
| return String.format("XX:XX:XX:XX:%02X:%02X", address[4], address[5]); |
| } |
| |
| public static byte[] getByteAddress(BluetoothDevice device) { |
| return getBytesFromAddress(device.getAddress()); |
| } |
| |
| public static byte[] getBytesFromAddress(String address) { |
| int i, j = 0; |
| byte[] output = new byte[BD_ADDR_LEN]; |
| |
| for (i = 0; i < address.length(); i++) { |
| if (address.charAt(i) != ':') { |
| output[j] = (byte) Integer.parseInt(address.substring(i, i + 2), BD_UUID_LEN); |
| j++; |
| i++; |
| } |
| } |
| |
| return output; |
| } |
| |
| public static int byteArrayToInt(byte[] valueBuf) { |
| return byteArrayToInt(valueBuf, 0); |
| } |
| |
| public static short byteArrayToShort(byte[] valueBuf) { |
| ByteBuffer converter = ByteBuffer.wrap(valueBuf); |
| converter.order(ByteOrder.nativeOrder()); |
| return converter.getShort(); |
| } |
| |
| public static int byteArrayToInt(byte[] valueBuf, int offset) { |
| ByteBuffer converter = ByteBuffer.wrap(valueBuf); |
| converter.order(ByteOrder.nativeOrder()); |
| return converter.getInt(offset); |
| } |
| |
| public static String byteArrayToString(byte[] valueBuf) { |
| StringBuilder sb = new StringBuilder(); |
| for (int idx = 0; idx < valueBuf.length; idx++) { |
| if (idx != 0) { |
| sb.append(" "); |
| } |
| sb.append(String.format("%02x", valueBuf[idx])); |
| } |
| return sb.toString(); |
| } |
| |
| /** |
| * A parser to transfer a byte array to a UTF8 string |
| * |
| * @param valueBuf the byte array to transfer |
| * @return the transferred UTF8 string |
| */ |
| public static String byteArrayToUtf8String(byte[] valueBuf) { |
| CharsetDecoder decoder = Charset.forName("UTF8").newDecoder(); |
| ByteBuffer byteBuffer = ByteBuffer.wrap(valueBuf); |
| String valueStr = ""; |
| try { |
| valueStr = decoder.decode(byteBuffer).toString(); |
| } catch (Exception ex) { |
| Log.e(TAG, "Error when parsing byte array to UTF8 String. " + ex); |
| } |
| return valueStr; |
| } |
| |
| public static byte[] intToByteArray(int value) { |
| ByteBuffer converter = ByteBuffer.allocate(4); |
| converter.order(ByteOrder.nativeOrder()); |
| converter.putInt(value); |
| return converter.array(); |
| } |
| |
| public static byte[] uuidToByteArray(ParcelUuid pUuid) { |
| int length = BD_UUID_LEN; |
| ByteBuffer converter = ByteBuffer.allocate(length); |
| converter.order(ByteOrder.BIG_ENDIAN); |
| long msb, lsb; |
| UUID uuid = pUuid.getUuid(); |
| msb = uuid.getMostSignificantBits(); |
| lsb = uuid.getLeastSignificantBits(); |
| converter.putLong(msb); |
| converter.putLong(8, lsb); |
| return converter.array(); |
| } |
| |
| public static byte[] uuidsToByteArray(ParcelUuid[] uuids) { |
| int length = uuids.length * BD_UUID_LEN; |
| ByteBuffer converter = ByteBuffer.allocate(length); |
| converter.order(ByteOrder.BIG_ENDIAN); |
| UUID uuid; |
| long msb, lsb; |
| for (int i = 0; i < uuids.length; i++) { |
| uuid = uuids[i].getUuid(); |
| msb = uuid.getMostSignificantBits(); |
| lsb = uuid.getLeastSignificantBits(); |
| converter.putLong(i * BD_UUID_LEN, msb); |
| converter.putLong(i * BD_UUID_LEN + 8, lsb); |
| } |
| return converter.array(); |
| } |
| |
| public static ParcelUuid[] byteArrayToUuid(byte[] val) { |
| int numUuids = val.length / BD_UUID_LEN; |
| ParcelUuid[] puuids = new ParcelUuid[numUuids]; |
| int offset = 0; |
| |
| ByteBuffer converter = ByteBuffer.wrap(val); |
| converter.order(ByteOrder.BIG_ENDIAN); |
| |
| for (int i = 0; i < numUuids; i++) { |
| puuids[i] = new ParcelUuid( |
| new UUID(converter.getLong(offset), converter.getLong(offset + 8))); |
| offset += BD_UUID_LEN; |
| } |
| return puuids; |
| } |
| |
| public static String debugGetAdapterStateString(int state) { |
| switch (state) { |
| case BluetoothAdapter.STATE_OFF: |
| return "STATE_OFF"; |
| case BluetoothAdapter.STATE_ON: |
| return "STATE_ON"; |
| case BluetoothAdapter.STATE_TURNING_ON: |
| return "STATE_TURNING_ON"; |
| case BluetoothAdapter.STATE_TURNING_OFF: |
| return "STATE_TURNING_OFF"; |
| default: |
| return "UNKNOWN"; |
| } |
| } |
| |
| public static String ellipsize(String s) { |
| // Only ellipsize release builds |
| if (!Build.TYPE.equals("user")) { |
| return s; |
| } |
| if (s == null) { |
| return null; |
| } |
| if (s.length() < 3) { |
| return s; |
| } |
| return s.charAt(0) + "⋯" + s.charAt(s.length() - 1); |
| } |
| |
| public static void copyStream(InputStream is, OutputStream os, int bufferSize) |
| throws IOException { |
| if (is != null && os != null) { |
| byte[] buffer = new byte[bufferSize]; |
| int bytesRead = 0; |
| while ((bytesRead = is.read(buffer)) >= 0) { |
| os.write(buffer, 0, bytesRead); |
| } |
| } |
| } |
| |
| public static void safeCloseStream(InputStream is) { |
| if (is != null) { |
| try { |
| is.close(); |
| } catch (Throwable t) { |
| Log.d(TAG, "Error closing stream", t); |
| } |
| } |
| } |
| |
| public static void safeCloseStream(OutputStream os) { |
| if (os != null) { |
| try { |
| os.close(); |
| } catch (Throwable t) { |
| Log.d(TAG, "Error closing stream", t); |
| } |
| } |
| } |
| |
| static int sSystemUiUid = USER_HANDLE_NULL.getIdentifier(); |
| public static void setSystemUiUid(int uid) { |
| Utils.sSystemUiUid = uid; |
| } |
| |
| static int sForegroundUserId = USER_HANDLE_NULL.getIdentifier(); |
| |
| public static int getForegroundUserId() { |
| return Utils.sForegroundUserId; |
| } |
| |
| public static void setForegroundUserId(int userId) { |
| Utils.sForegroundUserId = userId; |
| } |
| |
| /** |
| * Enforces that a Companion Device Manager (CDM) association exists between the calling |
| * application and the Bluetooth Device. |
| * |
| * @param cdm the CompanionDeviceManager object |
| * @param context the Bluetooth AdapterService context |
| * @param callingPackage the calling package |
| * @param callingUid the calling app uid |
| * @param device the remote BluetoothDevice |
| * @return {@code true} if there is a CDM association |
| * @throws SecurityException if the package name does not match the uid or the association |
| * doesn't exist |
| */ |
| @RequiresPermission("android.permission.MANAGE_COMPANION_DEVICES") |
| public static boolean enforceCdmAssociation(CompanionDeviceManager cdm, Context context, |
| String callingPackage, int callingUid, BluetoothDevice device) { |
| if (!isPackageNameAccurate(context, callingPackage, callingUid)) { |
| throw new SecurityException("hasCdmAssociation: Package name " + callingPackage |
| + " is inaccurate for calling uid " + callingUid); |
| } |
| |
| for (AssociationInfo association : getCdmAssociations(cdm)) { |
| if (association.getPackageName().equals(callingPackage) |
| && !association.isSelfManaged() && device.getAddress() != null |
| && association.getDeviceMacAddress() != null |
| && device.getAddress().equalsIgnoreCase( |
| association.getDeviceMacAddress().toString())) { |
| return true; |
| } |
| } |
| throw new SecurityException("The application with package name " + callingPackage |
| + " does not have a CDM association with the Bluetooth Device"); |
| } |
| |
| /** |
| * Obtains the complete list of registered CDM associations. |
| * |
| * @param cdm the CompanionDeviceManager object |
| * @return the list of AssociationInfo objects |
| */ |
| @RequiresPermission("android.permission.MANAGE_COMPANION_DEVICES") |
| // TODO(b/193460475): Android Lint handles change from SystemApi to public incorrectly. |
| // CompanionDeviceManager#getAllAssociations() is public in U, |
| // but existed in T as an identical SystemApi. |
| @SuppressLint("NewApi") |
| public static List<AssociationInfo> getCdmAssociations(CompanionDeviceManager cdm) { |
| return cdm.getAllAssociations(); |
| } |
| |
| /** |
| * Verifies whether the calling package name matches the calling app uid |
| * @param context the Bluetooth AdapterService context |
| * @param callingPackage the calling application package name |
| * @param callingUid the calling application uid |
| * @return {@code true} if the package name matches the calling app uid, {@code false} otherwise |
| */ |
| public static boolean isPackageNameAccurate(Context context, String callingPackage, |
| int callingUid) { |
| UserHandle callingUser = UserHandle.getUserHandleForUid(callingUid); |
| |
| // Verifies the integrity of the calling package name |
| try { |
| int packageUid = context.createContextAsUser(callingUser, 0) |
| .getPackageManager().getPackageUid(callingPackage, 0); |
| if (packageUid != callingUid) { |
| Log.e(TAG, "isPackageNameAccurate: App with package name " + callingPackage |
| + " is UID " + packageUid + " but caller is " + callingUid); |
| return false; |
| } |
| } catch (PackageManager.NameNotFoundException e) { |
| Log.e(TAG, "isPackageNameAccurate: App with package name " + callingPackage |
| + " does not exist"); |
| return false; |
| } |
| return true; |
| } |
| |
| /** |
| * Checks whether the caller has the BLUETOOTH_PRIVILEGED permission |
| * |
| * @param context the Bluetooth AdapterService context |
| * @return {@code true} if the caller has the BLUETOOTH_PRIVILEGED permission, {@code false} |
| * otherwise |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean hasBluetoothPrivilegedPermission(Context context) { |
| return context.checkCallingOrSelfPermission(Manifest.permission.BLUETOOTH_PRIVILEGED) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_PRIVILEGED) |
| public static void enforceBluetoothPrivilegedPermission(Context context) { |
| context.enforceCallingOrSelfPermission( |
| android.Manifest.permission.BLUETOOTH_PRIVILEGED, |
| "Need BLUETOOTH PRIVILEGED permission"); |
| } |
| |
| @RequiresPermission(android.Manifest.permission.LOCAL_MAC_ADDRESS) |
| public static void enforceLocalMacAddressPermission(Context context) { |
| context.enforceCallingOrSelfPermission( |
| android.Manifest.permission.LOCAL_MAC_ADDRESS, |
| "Need LOCAL_MAC_ADDRESS permission"); |
| } |
| |
| @RequiresPermission(android.Manifest.permission.DUMP) |
| public static void enforceDumpPermission(Context context) { |
| context.enforceCallingOrSelfPermission( |
| android.Manifest.permission.DUMP, |
| "Need DUMP permission"); |
| } |
| |
| public static AttributionSource getCallingAttributionSource(Context context) { |
| int callingUid = Binder.getCallingUid(); |
| if (callingUid == android.os.Process.ROOT_UID) { |
| callingUid = android.os.Process.SYSTEM_UID; |
| } |
| return new AttributionSource.Builder(callingUid) |
| .setPackageName(context.getPackageManager().getPackagesForUid(callingUid)[0]) |
| .build(); |
| } |
| |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| private static boolean checkPermissionForPreflight(Context context, String permission) { |
| PermissionManager pm = context.getSystemService(PermissionManager.class); |
| if (pm == null) { |
| return false; |
| } |
| final int result = pm.checkPermissionForPreflight(permission, |
| context.getAttributionSource()); |
| if (result == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| final String msg = "Need " + permission + " permission"; |
| if (result == PERMISSION_HARD_DENIED) { |
| throw new SecurityException(msg); |
| } else { |
| Log.w(TAG, msg); |
| return false; |
| } |
| } |
| |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| private static boolean checkPermissionForDataDelivery(Context context, String permission, |
| AttributionSource attributionSource, String message) { |
| if (isInstrumentationTestMode()) { |
| return true; |
| } |
| // STOPSHIP(b/188391719): enable this security enforcement |
| // attributionSource.enforceCallingUid(); |
| AttributionSource currentAttribution = new AttributionSource |
| .Builder(context.getAttributionSource()) |
| .setNext(attributionSource) |
| .build(); |
| PermissionManager pm = context.getSystemService(PermissionManager.class); |
| if (pm == null) { |
| return false; |
| } |
| final int result = pm.checkPermissionForDataDeliveryFromDataSource(permission, |
| currentAttribution, message); |
| if (result == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| final String msg = "Need " + permission + " permission for " + attributionSource + ": " |
| + message; |
| if (result == PERMISSION_HARD_DENIED) { |
| throw new SecurityException(msg); |
| } else { |
| Log.w(TAG, msg); |
| return false; |
| } |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_CONNECT permission is granted for the calling app. Returns |
| * false if the result is a soft denial. Throws SecurityException if the result is a hard |
| * denial. |
| * |
| * <p>Should be used in situations where the app op should not be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT) |
| public static boolean checkConnectPermissionForPreflight(Context context) { |
| return checkPermissionForPreflight(context, BLUETOOTH_CONNECT); |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_CONNECT permission is granted for the calling app. Returns |
| * false if the result is a soft denial. Throws SecurityException if the result is a hard |
| * denial. |
| * |
| * <p>Should be used in situations where data will be delivered and hence the app op should |
| * be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT) |
| public static boolean checkConnectPermissionForDataDelivery( |
| Context context, AttributionSource attributionSource, String message) { |
| return checkPermissionForDataDelivery(context, BLUETOOTH_CONNECT, |
| attributionSource, message); |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_SCAN permission is granted for the calling app. Returns false |
| * if the result is a soft denial. Throws SecurityException if the result is a hard denial. |
| * |
| * <p>Should be used in situations where the app op should not be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN) |
| public static boolean checkScanPermissionForPreflight(Context context) { |
| return checkPermissionForPreflight(context, BLUETOOTH_SCAN); |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_SCAN permission is granted for the calling app. Returns false |
| * if the result is a soft denial. Throws SecurityException if the result is a hard denial. |
| * |
| * <p>Should be used in situations where data will be delivered and hence the app op should |
| * be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN) |
| public static boolean checkScanPermissionForDataDelivery( |
| Context context, AttributionSource attributionSource, String message) { |
| return checkPermissionForDataDelivery(context, BLUETOOTH_SCAN, |
| attributionSource, message); |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_ADVERTISE permission is granted for the |
| * calling app. Returns false if the result is a soft denial. Throws |
| * SecurityException if the result is a hard denial. |
| * <p> |
| * Should be used in situations where the app op should not be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE) |
| public static boolean checkAdvertisePermissionForPreflight(Context context) { |
| return checkPermissionForPreflight(context, BLUETOOTH_ADVERTISE); |
| } |
| |
| /** |
| * Returns true if the BLUETOOTH_ADVERTISE permission is granted for the |
| * calling app. Returns false if the result is a soft denial. Throws |
| * SecurityException if the result is a hard denial. |
| * <p> |
| * Should be used in situations where data will be delivered and hence the |
| * app op should be noted. |
| */ |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| @RequiresPermission(android.Manifest.permission.BLUETOOTH_ADVERTISE) |
| public static boolean checkAdvertisePermissionForDataDelivery( |
| Context context, AttributionSource attributionSource, String message) { |
| return checkPermissionForDataDelivery(context, BLUETOOTH_ADVERTISE, |
| attributionSource, message); |
| } |
| |
| /** |
| * Returns true if the specified package has disavowed the use of bluetooth scans for location, |
| * that is, if they have specified the {@code neverForLocation} flag on the BLUETOOTH_SCAN |
| * permission. |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean hasDisavowedLocationForScan( |
| Context context, AttributionSource attributionSource, boolean inTestMode) { |
| |
| // Check every step along the attribution chain for a renouncement. |
| // If location has been renounced anywhere in the chain we treat it as a disavowal. |
| AttributionSource currentAttrib = attributionSource; |
| while (true) { |
| if (currentAttrib.getRenouncedPermissions().contains(ACCESS_FINE_LOCATION) |
| && (inTestMode || context.checkPermission(RENOUNCE_PERMISSIONS, -1, |
| currentAttrib.getUid()) |
| == PackageManager.PERMISSION_GRANTED)) { |
| return true; |
| } |
| AttributionSource nextAttrib = currentAttrib.getNext(); |
| if (nextAttrib == null) { |
| break; |
| } |
| currentAttrib = nextAttrib; |
| } |
| |
| // Check the last attribution in the chain for a neverForLocation disavowal. |
| String packageName = currentAttrib.getPackageName(); |
| PackageManager pm = context.getPackageManager(); |
| try { |
| // TODO(b/183478032): Cache PackageInfo for use here. |
| PackageInfo pkgInfo = |
| pm.getPackageInfo(packageName, GET_PERMISSIONS | MATCH_UNINSTALLED_PACKAGES); |
| for (int i = 0; i < pkgInfo.requestedPermissions.length; i++) { |
| if (pkgInfo.requestedPermissions[i].equals(BLUETOOTH_SCAN)) { |
| return (pkgInfo.requestedPermissionsFlags[i] |
| & PackageInfo.REQUESTED_PERMISSION_NEVER_FOR_LOCATION) != 0; |
| } |
| } |
| } catch (PackageManager.NameNotFoundException e) { |
| Log.w(TAG, "Could not find package for disavowal check: " + packageName); |
| } |
| return false; |
| } |
| |
| private static boolean checkCallerIsSystem() { |
| int callingUid = Binder.getCallingUid(); |
| return UserHandle.getAppId(Process.SYSTEM_UID) == UserHandle.getAppId(callingUid); |
| } |
| |
| private static boolean checkCallerIsSystemOrActiveUser() { |
| int callingUid = Binder.getCallingUid(); |
| UserHandle callingUser = UserHandle.getUserHandleForUid(callingUid); |
| |
| return (sForegroundUserId == callingUser.getIdentifier()) |
| || (UserHandle.getAppId(sSystemUiUid) == UserHandle.getAppId(callingUid)) |
| || (UserHandle.getAppId(Process.SYSTEM_UID) == UserHandle.getAppId(callingUid)); |
| } |
| |
| public static boolean checkCallerIsSystemOrActiveUser(String tag) { |
| final boolean res = checkCallerIsSystemOrActiveUser(); |
| if (!res) { |
| Log.w(TAG, tag + " - Not allowed for non-active user and non-system user"); |
| } |
| return res; |
| } |
| |
| public static boolean callerIsSystemOrActiveUser(String tag, String method) { |
| return checkCallerIsSystemOrActiveUser(tag + "." + method + "()"); |
| } |
| |
| /** |
| * Checks if the caller to the method is system server. |
| * |
| * @param tag the log tag to use in case the caller is not system server |
| * @param method the API method name |
| * @return {@code true} if the caller is system server, {@code false} otherwise |
| */ |
| public static boolean callerIsSystem(String tag, String method) { |
| if (isInstrumentationTestMode()) { |
| return true; |
| } |
| final boolean res = checkCallerIsSystem(); |
| if (!res) { |
| Log.w(TAG, tag + "." + method + "()" + " - Not allowed outside system server"); |
| } |
| return res; |
| } |
| |
| private static boolean checkCallerIsSystemOrActiveOrManagedUser(Context context) { |
| if (context == null) { |
| return checkCallerIsSystemOrActiveUser(); |
| } |
| int callingUid = Binder.getCallingUid(); |
| UserHandle callingUser = UserHandle.getUserHandleForUid(callingUid); |
| |
| // Use the Bluetooth process identity when making call to get parent user |
| final long ident = Binder.clearCallingIdentity(); |
| try { |
| UserManager um = context.getSystemService(UserManager.class); |
| UserHandle uh = um.getProfileParent(callingUser); |
| int parentUser = (uh != null) ? uh.getIdentifier() : USER_HANDLE_NULL.getIdentifier(); |
| |
| // Always allow SystemUI/System access. |
| return (sForegroundUserId == callingUser.getIdentifier()) |
| || (sForegroundUserId == parentUser) |
| || (UserHandle.getAppId(sSystemUiUid) == UserHandle.getAppId(callingUid)) |
| || (UserHandle.getAppId(Process.SYSTEM_UID) == UserHandle.getAppId(callingUid)); |
| } catch (Exception ex) { |
| Log.e(TAG, "checkCallerAllowManagedProfiles: Exception ex=" + ex); |
| return false; |
| } finally { |
| Binder.restoreCallingIdentity(ident); |
| } |
| } |
| |
| public static boolean checkCallerIsSystemOrActiveOrManagedUser(Context context, String tag) { |
| if (isInstrumentationTestMode()) { |
| return true; |
| } |
| final boolean res = checkCallerIsSystemOrActiveOrManagedUser(context); |
| if (!res) { |
| Log.w(TAG, tag + " - Not allowed for" |
| + " non-active user and non-system and non-managed user"); |
| } |
| return res; |
| } |
| |
| public static boolean callerIsSystemOrActiveOrManagedUser(Context context, String tag, |
| String method) { |
| return checkCallerIsSystemOrActiveOrManagedUser(context, tag + "." + method + "()"); |
| } |
| |
| public static boolean checkServiceAvailable(ProfileService service, String tag) { |
| if (service == null) { |
| Log.w(TAG, tag + " - Not present"); |
| return false; |
| } |
| if (!service.isAvailable()) { |
| Log.w(TAG, tag + " - Not available"); |
| return false; |
| } |
| return true; |
| } |
| |
| /** |
| * Checks whether location is off and must be on for us to perform some operation |
| */ |
| public static boolean blockedByLocationOff(Context context, UserHandle userHandle) { |
| return !context.getSystemService(LocationManager.class) |
| .isLocationEnabledForUser(userHandle); |
| } |
| |
| /** |
| * Checks that calling process has android.Manifest.permission.ACCESS_COARSE_LOCATION and |
| * OP_COARSE_LOCATION is allowed |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasCoarseLocation( |
| Context context, AttributionSource attributionSource, UserHandle userHandle) { |
| if (blockedByLocationOff(context, userHandle)) { |
| Log.e(TAG, "Permission denial: Location is off."); |
| return false; |
| } |
| AttributionSource currentAttribution = new AttributionSource |
| .Builder(context.getAttributionSource()) |
| .setNext(attributionSource) |
| .build(); |
| // STOPSHIP(b/188391719): enable this security enforcement |
| // attributionSource.enforceCallingUid(); |
| PermissionManager pm = context.getSystemService(PermissionManager.class); |
| if (pm == null) { |
| return false; |
| } |
| if (pm.checkPermissionForDataDeliveryFromDataSource(ACCESS_COARSE_LOCATION, |
| currentAttribution, "Bluetooth location check") == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| Log.e(TAG, "Permission denial: Need ACCESS_COARSE_LOCATION " |
| + "permission to get scan results"); |
| return false; |
| } |
| |
| /** |
| * Checks that calling process has android.Manifest.permission.ACCESS_COARSE_LOCATION and |
| * OP_COARSE_LOCATION is allowed or android.Manifest.permission.ACCESS_FINE_LOCATION and |
| * OP_FINE_LOCATION is allowed |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasCoarseOrFineLocation( |
| Context context, AttributionSource attributionSource, UserHandle userHandle) { |
| if (blockedByLocationOff(context, userHandle)) { |
| Log.e(TAG, "Permission denial: Location is off."); |
| return false; |
| } |
| |
| final AttributionSource currentAttribution = new AttributionSource |
| .Builder(context.getAttributionSource()) |
| .setNext(attributionSource) |
| .build(); |
| // STOPSHIP(b/188391719): enable this security enforcement |
| // attributionSource.enforceCallingUid(); |
| PermissionManager pm = context.getSystemService(PermissionManager.class); |
| if (pm == null) { |
| return false; |
| } |
| if (pm.checkPermissionForDataDeliveryFromDataSource(ACCESS_FINE_LOCATION, |
| currentAttribution, "Bluetooth location check") == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| if (pm.checkPermissionForDataDeliveryFromDataSource(ACCESS_COARSE_LOCATION, |
| currentAttribution, "Bluetooth location check") == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| Log.e(TAG, "Permission denial: Need ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION" |
| + "permission to get scan results"); |
| return false; |
| } |
| |
| /** |
| * Checks that calling process has android.Manifest.permission.ACCESS_FINE_LOCATION and |
| * OP_FINE_LOCATION is allowed |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasFineLocation( |
| Context context, AttributionSource attributionSource, UserHandle userHandle) { |
| if (blockedByLocationOff(context, userHandle)) { |
| Log.e(TAG, "Permission denial: Location is off."); |
| return false; |
| } |
| |
| AttributionSource currentAttribution = new AttributionSource |
| .Builder(context.getAttributionSource()) |
| .setNext(attributionSource) |
| .build(); |
| // STOPSHIP(b/188391719): enable this security enforcement |
| // attributionSource.enforceCallingUid(); |
| PermissionManager pm = context.getSystemService(PermissionManager.class); |
| if (pm == null) { |
| return false; |
| } |
| if (pm.checkPermissionForDataDeliveryFromDataSource(ACCESS_FINE_LOCATION, |
| currentAttribution, "Bluetooth location check") == PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| Log.e(TAG, "Permission denial: Need ACCESS_FINE_LOCATION " |
| + "permission to get scan results"); |
| return false; |
| } |
| |
| /** |
| * Returns true if the caller holds NETWORK_SETTINGS |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasNetworkSettingsPermission(Context context) { |
| return context.checkCallingOrSelfPermission(android.Manifest.permission.NETWORK_SETTINGS) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| /** |
| * Returns true if the caller holds NETWORK_SETUP_WIZARD |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasNetworkSetupWizardPermission(Context context) { |
| return context.checkCallingOrSelfPermission( |
| android.Manifest.permission.NETWORK_SETUP_WIZARD) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| /** |
| * Returns true if the caller holds RADIO_SCAN_WITHOUT_LOCATION |
| */ |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasScanWithoutLocationPermission(Context context) { |
| return context.checkCallingOrSelfPermission( |
| android.Manifest.permission.RADIO_SCAN_WITHOUT_LOCATION) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasPrivilegedPermission(Context context) { |
| return context.checkCallingOrSelfPermission( |
| android.Manifest.permission.BLUETOOTH_PRIVILEGED) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| // Suppressed since we're not actually enforcing here |
| @SuppressLint("AndroidFrameworkRequiresPermission") |
| public static boolean checkCallerHasWriteSmsPermission(Context context) { |
| return context.checkCallingOrSelfPermission( |
| android.Manifest.permission.WRITE_SMS) == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| /** |
| * Checks that the target sdk of the app corresponding to the provided package name is greater |
| * than or equal to the passed in target sdk. |
| * <p> |
| * For example, if the calling app has target SDK {@link Build.VERSION_CODES#S} and we pass in |
| * the targetSdk {@link Build.VERSION_CODES#R}, the API will return true because S >= R. |
| * |
| * @param context Bluetooth service context |
| * @param pkgName caller's package name |
| * @param expectedMinimumTargetSdk one of the values from {@link Build.VERSION_CODES} |
| * @return {@code true} if the caller's target sdk is greater than or equal to |
| * expectedMinimumTargetSdk, {@code false} otherwise |
| */ |
| public static boolean checkCallerTargetSdk(Context context, String pkgName, |
| int expectedMinimumTargetSdk) { |
| try { |
| return context.getPackageManager().getApplicationInfo(pkgName, 0).targetSdkVersion |
| >= expectedMinimumTargetSdk; |
| } catch (PackageManager.NameNotFoundException e) { |
| // In case of exception, assume true |
| } |
| return true; |
| } |
| |
| /** |
| * Converts {@code milliseconds} to unit. Each unit is 0.625 millisecond. |
| */ |
| public static int millsToUnit(int milliseconds) { |
| return (int) (TimeUnit.MILLISECONDS.toMicros(milliseconds) / MICROS_PER_UNIT); |
| } |
| |
| private static boolean sIsInstrumentationTestModeCacheSet = false; |
| private static boolean sInstrumentationTestModeCache = false; |
| |
| /** |
| * Check if we are running in BluetoothInstrumentationTest context by trying to load |
| * com.android.bluetooth.FileSystemWriteTest. If we are not in Instrumentation test mode, this |
| * class should not be found. Thus, the assumption is that FileSystemWriteTest must exist. |
| * If FileSystemWriteTest is removed in the future, another test class in |
| * BluetoothInstrumentationTest should be used instead |
| * |
| * @return true if in BluetoothInstrumentationTest, false otherwise |
| */ |
| public static boolean isInstrumentationTestMode() { |
| if (!sIsInstrumentationTestModeCacheSet) { |
| try { |
| sInstrumentationTestModeCache = |
| Class.forName("com.android.bluetooth.FileSystemWriteTest") != null; |
| } catch (ClassNotFoundException exception) { |
| sInstrumentationTestModeCache = false; |
| } |
| sIsInstrumentationTestModeCacheSet = true; |
| } |
| return sInstrumentationTestModeCache; |
| } |
| |
| /** |
| * Throws {@link IllegalStateException} if we are not in BluetoothInstrumentationTest. Useful |
| * for ensuring certain methods only get called in BluetoothInstrumentationTest |
| */ |
| public static void enforceInstrumentationTestMode() { |
| if (!isInstrumentationTestMode()) { |
| throw new IllegalStateException("Not in BluetoothInstrumentationTest"); |
| } |
| } |
| |
| /** |
| * Check if we are running in PTS test mode. To enable/disable PTS test mode, invoke |
| * {@code adb shell setprop persist.bluetooth.pts true/false} |
| * |
| * @return true if in PTS Test mode, false otherwise |
| */ |
| public static boolean isPtsTestMode() { |
| return SystemProperties.getBoolean(PTS_TEST_MODE_PROPERTY, false); |
| } |
| |
| /** |
| * Get uid/pid string in a binder call |
| * |
| * @return "uid/pid=xxxx/yyyy" |
| */ |
| public static String getUidPidString() { |
| return "uid/pid=" + Binder.getCallingUid() + "/" + Binder.getCallingPid(); |
| } |
| |
| /** |
| * Get system local time |
| * |
| * @return "MM-dd HH:mm:ss.SSS" |
| */ |
| public static String getLocalTimeString() { |
| return DateTimeFormatter.ofPattern("MM-dd HH:mm:ss.SSS") |
| .withZone(ZoneId.systemDefault()).format(Instant.now()); |
| } |
| |
| public static void skipCurrentTag(XmlPullParser parser) |
| throws XmlPullParserException, IOException { |
| int outerDepth = parser.getDepth(); |
| int type; |
| while ((type = parser.next()) != XmlPullParser.END_DOCUMENT |
| && (type != XmlPullParser.END_TAG |
| || parser.getDepth() > outerDepth)) { |
| } |
| } |
| |
| /** |
| * Converts pause and tonewait pause characters |
| * to Android representation. |
| * RFC 3601 says pause is 'p' and tonewait is 'w'. |
| */ |
| public static String convertPreDial(String phoneNumber) { |
| if (phoneNumber == null) { |
| return null; |
| } |
| int len = phoneNumber.length(); |
| StringBuilder ret = new StringBuilder(len); |
| |
| for (int i = 0; i < len; i++) { |
| char c = phoneNumber.charAt(i); |
| |
| if (isPause(c)) { |
| c = PAUSE; |
| } else if (isToneWait(c)) { |
| c = WAIT; |
| } |
| ret.append(c); |
| } |
| return ret.toString(); |
| } |
| |
| /** |
| * Move a message to the given folder. |
| * |
| * @param context the context to use |
| * @param uri the message to move |
| * @param messageSent if the message is SENT or FAILED |
| * @return true if the operation succeeded |
| */ |
| public static boolean moveMessageToFolder(Context context, Uri uri, boolean messageSent) { |
| if (uri == null) { |
| return false; |
| } |
| |
| ContentValues values = new ContentValues(3); |
| if (messageSent) { |
| values.put(Telephony.Sms.READ, 1); |
| values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_SENT); |
| } else { |
| values.put(Telephony.Sms.READ, 0); |
| values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_FAILED); |
| } |
| values.put(Telephony.Sms.ERROR_CODE, 0); |
| |
| return 1 == BluetoothMethodProxy.getInstance().contentResolverUpdate( |
| context.getContentResolver(), uri, values, null, null); |
| } |
| |
| /** |
| * Returns bundled broadcast options. |
| */ |
| // TODO(b/193460475): Remove when tooling supports SystemApi to public API. |
| @SuppressLint("NewApi") |
| public static @NonNull Bundle getTempAllowlistBroadcastOptions() { |
| return getTempBroadcastOptions().toBundle(); |
| } |
| |
| /** |
| * Returns broadcast options. |
| */ |
| // TODO(b/193460475): Remove when tooling supports SystemApi to public API. |
| @SuppressLint("NewApi") |
| public static @NonNull BroadcastOptions getTempBroadcastOptions() { |
| final BroadcastOptions bOptions = BroadcastOptions.makeBasic(); |
| // Use the Bluetooth process identity to pass permission check when reading DeviceConfig |
| final long ident = Binder.clearCallingIdentity(); |
| try { |
| final long durationMs = DeviceConfig.getLong(DeviceConfig.NAMESPACE_BLUETOOTH, |
| KEY_TEMP_ALLOW_LIST_DURATION_MS, DEFAULT_TEMP_ALLOW_LIST_DURATION_MS); |
| bOptions.setTemporaryAppAllowlist(durationMs, |
| TEMPORARY_ALLOW_LIST_TYPE_FOREGROUND_SERVICE_ALLOWED, |
| PowerExemptionManager.REASON_BLUETOOTH_BROADCAST, ""); |
| } finally { |
| Binder.restoreCallingIdentity(ident); |
| } |
| return bOptions; |
| } |
| |
| /** |
| * Sends the {@code intent} as a broadcast in the provided {@code context} to receivers that |
| * have been granted the specified {@code receiverPermission} with the {@link BroadcastOptions} |
| * {@code options}. |
| * |
| * @see Context#sendBroadcast(Intent, String, Bundle) |
| */ |
| // TODO(b/193460475): Remove when tooling supports SystemApi to public API. |
| @SuppressLint("NewApi") |
| public static void sendBroadcast(@NonNull Context context, @NonNull Intent intent, |
| @Nullable String receiverPermission, @Nullable Bundle options) { |
| context.sendBroadcast(intent, receiverPermission, options); |
| } |
| |
| /** |
| * @see Context#sendOrderedBroadcast(Intent, String, Bundle, BroadcastReceiver, Handler, |
| * int, String, Bundle) |
| */ |
| // TODO(b/193460475): Remove when tooling supports SystemApi to public API. |
| @SuppressLint("NewApi") |
| public static void sendOrderedBroadcast(@NonNull Context context, @NonNull Intent intent, |
| @Nullable String receiverPermission, @Nullable Bundle options, |
| @Nullable BroadcastReceiver resultReceiver, @Nullable Handler scheduler, |
| int initialCode, @Nullable String initialData, @Nullable Bundle initialExtras) { |
| context.sendOrderedBroadcast(intent, receiverPermission, options, resultReceiver, scheduler, |
| initialCode, initialData, initialExtras); |
| } |
| |
| /** |
| * Checks that value is present as at least one of the elements of the array. |
| * @param array the array to check in |
| * @param value the value to check for |
| * @return true if the value is present in the array |
| */ |
| public static <T> boolean arrayContains(@Nullable T[] array, T value) { |
| if (array == null) return false; |
| for (T element : array) { |
| if (Objects.equals(element, value)) return true; |
| } |
| return false; |
| } |
| |
| /** |
| * CCC descriptor short integer value to string. |
| * @param cccValue the short value of CCC descriptor |
| * @return String value representing CCC state |
| */ |
| public static String cccIntToStr(Short cccValue) { |
| String string = ""; |
| |
| if (cccValue == 0) { |
| return string += "NO SUBSCRIPTION"; |
| } |
| |
| if (BigInteger.valueOf(cccValue).testBit(0)) { |
| string += "NOTIFICATION"; |
| } |
| if (BigInteger.valueOf(cccValue).testBit(1)) { |
| string += string.isEmpty() ? "INDICATION" : "|INDICATION"; |
| } |
| |
| return string; |
| } |
| |
| /** |
| * Check if BLE is supported by this platform |
| * @param context current device context |
| * @return true if BLE is supported, false otherwise |
| */ |
| public static boolean isBleSupported(Context context) { |
| return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE); |
| } |
| |
| /** |
| * Check if this is an automotive device |
| * @param context current device context |
| * @return true if this Android device is an automotive device, false otherwise |
| */ |
| public static boolean isAutomotive(Context context) { |
| return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE); |
| } |
| |
| /** |
| * Check if this is a watch device |
| * @param context current device context |
| * @return true if this Android device is a watch device, false otherwise |
| */ |
| public static boolean isWatch(Context context) { |
| return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH); |
| } |
| |
| /** |
| * Check if this is a TV device |
| * @param context current device context |
| * @return true if this Android device is a TV device, false otherwise |
| */ |
| public static boolean isTv(Context context) { |
| PackageManager pm = context.getPackageManager(); |
| return pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) |
| || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); |
| } |
| } |