| /* |
| * Copyright (C) 2021 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.server.uwb; |
| |
| import static android.Manifest.permission.UWB_RANGING; |
| import static android.permission.PermissionManager.PERMISSION_GRANTED; |
| |
| import static java.lang.Math.toRadians; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.ActivityManager; |
| import android.app.AlarmManager; |
| import android.content.ApexEnvironment; |
| import android.content.AttributionSource; |
| import android.content.Context; |
| import android.content.pm.ApplicationInfo; |
| import android.content.pm.PackageManager; |
| import android.database.ContentObserver; |
| import android.location.Geocoder; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.os.Looper; |
| import android.os.Process; |
| import android.os.SystemClock; |
| import android.os.SystemProperties; |
| import android.os.UserHandle; |
| import android.os.UserManager; |
| import android.permission.PermissionManager; |
| import android.provider.Settings; |
| import android.util.AtomicFile; |
| import android.util.Log; |
| |
| import com.android.server.uwb.advertisement.UwbAdvertiseManager; |
| import com.android.server.uwb.correction.UwbFilterEngine; |
| import com.android.server.uwb.correction.filtering.IFilter; |
| import com.android.server.uwb.correction.filtering.MedAvgFilter; |
| import com.android.server.uwb.correction.filtering.MedAvgRotationFilter; |
| import com.android.server.uwb.correction.filtering.PositionFilterImpl; |
| import com.android.server.uwb.correction.pose.GyroPoseSource; |
| import com.android.server.uwb.correction.pose.IPoseSource; |
| import com.android.server.uwb.correction.pose.IntegPoseSource; |
| import com.android.server.uwb.correction.pose.RotationPoseSource; |
| import com.android.server.uwb.correction.pose.SixDofPoseSource; |
| import com.android.server.uwb.correction.primers.AoaPrimer; |
| import com.android.server.uwb.correction.primers.BackAzimuthPrimer; |
| import com.android.server.uwb.correction.primers.ElevationPrimer; |
| import com.android.server.uwb.correction.primers.FovPrimer; |
| import com.android.server.uwb.data.ServiceProfileData; |
| import com.android.server.uwb.jni.NativeUwbManager; |
| import com.android.server.uwb.multchip.UwbMultichipData; |
| import com.android.server.uwb.pm.ProfileManager; |
| import com.android.uwb.flags.FeatureFlags; |
| |
| import java.io.File; |
| import java.util.HashMap; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.FutureTask; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.TimeoutException; |
| import java.util.concurrent.locks.ReentrantLock; |
| |
| /** |
| * To be used for dependency injection (especially helps mocking static dependencies). |
| */ |
| public class UwbInjector { |
| private static final String TAG = "UwbInjector"; |
| private static final String APEX_NAME = "com.android.uwb"; |
| private static final String VENDOR_SERVICE_NAME = "uwb_vendor"; |
| private static final String BOOT_DEFAULT_UWB_COUNTRY_CODE = "ro.boot.uwbcountrycode"; |
| |
| /** |
| * The path where the Uwb apex is mounted. |
| * Current value = "/apex/com.android.uwb" |
| */ |
| private static final String UWB_APEX_PATH = |
| new File("/apex", APEX_NAME).getAbsolutePath(); |
| private static final int APP_INFO_FLAGS_SYSTEM_APP = |
| ApplicationInfo.FLAG_SYSTEM | ApplicationInfo.FLAG_UPDATED_SYSTEM_APP; |
| |
| private final UwbContext mContext; |
| private final Looper mLooper; |
| private final PermissionManager mPermissionManager; |
| private final UserManager mUserManager; |
| private final UwbConfigStore mUwbConfigStore; |
| private final ProfileManager mProfileManager; |
| private final UwbSettingsStore mUwbSettingsStore; |
| private final NativeUwbManager mNativeUwbManager; |
| private final UwbCountryCode mUwbCountryCode; |
| private final UciLogModeStore mUciLogModeStore; |
| private final UwbServiceCore mUwbService; |
| private final UwbMetrics mUwbMetrics; |
| private final DeviceConfigFacade mDeviceConfigFacade; |
| private final UwbMultichipData mUwbMultichipData; |
| private final SystemBuildProperties mSystemBuildProperties; |
| private final UwbDiagnostics mUwbDiagnostics; |
| private IPoseSource mDefaultPoseSource; |
| private final ReentrantLock mPoseLock = new ReentrantLock(); |
| private int mPoseSourceRefCount = 0; |
| |
| private final UwbSessionManager mUwbSessionManager; |
| private final FeatureFlags mFeatureFlags; |
| |
| public UwbInjector(@NonNull UwbContext context) { |
| // Create UWB service thread. |
| HandlerThread uwbHandlerThread = new HandlerThread("UwbService"); |
| uwbHandlerThread.start(); |
| mLooper = uwbHandlerThread.getLooper(); |
| |
| mContext = context; |
| mPermissionManager = context.getSystemService(PermissionManager.class); |
| mUserManager = mContext.getSystemService(UserManager.class); |
| mUwbConfigStore = new UwbConfigStore(context, new Handler(mLooper), this, |
| UwbConfigStore.createSharedFiles()); |
| mProfileManager = new ProfileManager(context, new Handler(mLooper), |
| mUwbConfigStore, this); |
| mUwbSettingsStore = new UwbSettingsStore( |
| context, new Handler(mLooper), |
| new AtomicFile(new File(getDeviceProtectedDataDir(), |
| UwbSettingsStore.FILE_NAME)), this); |
| mUwbMultichipData = new UwbMultichipData(mContext); |
| mUciLogModeStore = new UciLogModeStore(mUwbSettingsStore); |
| mNativeUwbManager = new NativeUwbManager(this, mUciLogModeStore, mUwbMultichipData); |
| mUwbCountryCode = |
| new UwbCountryCode(mContext, mNativeUwbManager, new Handler(mLooper), this); |
| mUwbMetrics = new UwbMetrics(this); |
| mDeviceConfigFacade = new DeviceConfigFacade(new Handler(mLooper), mContext); |
| UwbConfigurationManager uwbConfigurationManager = |
| new UwbConfigurationManager(mNativeUwbManager, this); |
| UwbSessionNotificationManager uwbSessionNotificationManager = |
| new UwbSessionNotificationManager(this); |
| UwbAdvertiseManager uwbAdvertiseManager = new UwbAdvertiseManager(this, |
| mDeviceConfigFacade); |
| mUwbSessionManager = |
| new UwbSessionManager(uwbConfigurationManager, mNativeUwbManager, mUwbMetrics, |
| uwbAdvertiseManager, uwbSessionNotificationManager, this, |
| mContext.getSystemService(AlarmManager.class), |
| mContext.getSystemService(ActivityManager.class), |
| mLooper); |
| mUwbService = new UwbServiceCore(mContext, mNativeUwbManager, mUwbMetrics, |
| mUwbCountryCode, mUwbSessionManager, uwbConfigurationManager, this, mLooper); |
| mSystemBuildProperties = new SystemBuildProperties(); |
| mUwbDiagnostics = new UwbDiagnostics(mContext, this, mSystemBuildProperties); |
| mFeatureFlags = new com.android.uwb.flags.FeatureFlagsImpl(); |
| } |
| |
| public FeatureFlags getFeatureFlags() { |
| return mFeatureFlags; |
| } |
| |
| public Looper getUwbServiceLooper() { |
| return mLooper; |
| } |
| |
| public UserManager getUserManager() { |
| return mUserManager; |
| } |
| |
| /** |
| * Construct an instance of {@link ServiceProfileData}. |
| */ |
| public ServiceProfileData makeServiceProfileData(ServiceProfileData.DataSource dataSource) { |
| return new ServiceProfileData(dataSource); |
| } |
| |
| public ProfileManager getProfileManager() { |
| return mProfileManager; |
| } |
| |
| public UwbConfigStore getUwbConfigStore() { |
| return mUwbConfigStore; |
| } |
| |
| public UwbSettingsStore getUwbSettingsStore() { |
| return mUwbSettingsStore; |
| } |
| |
| public NativeUwbManager getNativeUwbManager() { |
| return mNativeUwbManager; |
| } |
| |
| public UwbCountryCode getUwbCountryCode() { |
| return mUwbCountryCode; |
| } |
| |
| public UciLogModeStore getUciLogModeStore() { |
| return mUciLogModeStore; |
| } |
| |
| public UwbMetrics getUwbMetrics() { |
| return mUwbMetrics; |
| } |
| |
| public DeviceConfigFacade getDeviceConfigFacade() { |
| return mDeviceConfigFacade; |
| } |
| |
| public UwbMultichipData getMultichipData() { |
| return mUwbMultichipData; |
| } |
| |
| public UwbServiceCore getUwbServiceCore() { |
| return mUwbService; |
| } |
| |
| public UwbDiagnostics getUwbDiagnostics() { |
| return mUwbDiagnostics; |
| } |
| |
| public UwbSessionManager getUwbSessionManager() { |
| return mUwbSessionManager; |
| } |
| |
| /** |
| * Create a UwbShellCommand instance. |
| */ |
| public UwbShellCommand makeUwbShellCommand(UwbServiceImpl uwbService) { |
| return new UwbShellCommand(this, uwbService, mContext); |
| } |
| |
| /** |
| * Creates a Geocoder. |
| */ |
| @Nullable |
| public Geocoder makeGeocoder() { |
| return new Geocoder(mContext); |
| } |
| |
| /** |
| * Returns whether geocoder is supported on this device or not. |
| */ |
| public boolean isGeocoderPresent() { |
| return Geocoder.isPresent(); |
| } |
| |
| /** |
| * Throws security exception if the UWB_RANGING permission is not granted for the calling app. |
| * |
| * <p>Should be used in situations where the app op should not be noted. |
| */ |
| public void enforceUwbRangingPermissionForPreflight( |
| @NonNull AttributionSource attributionSource) { |
| if (!attributionSource.checkCallingUid()) { |
| throw new SecurityException("Invalid attribution source " + attributionSource |
| + ", callingUid: " + Binder.getCallingUid()); |
| } |
| int permissionCheckResult = mPermissionManager.checkPermissionForPreflight( |
| UWB_RANGING, attributionSource); |
| if (permissionCheckResult != PERMISSION_GRANTED) { |
| throw new SecurityException("Caller does not hold UWB_RANGING permission"); |
| } |
| } |
| |
| /** |
| * Returns true if the UWB_RANGING permission is granted for the calling app. |
| * |
| * <p>Used for checking permission before first data delivery for the session. |
| */ |
| public boolean checkUwbRangingPermissionForStartDataDelivery( |
| @NonNull AttributionSource attributionSource, @NonNull String message) { |
| int permissionCheckResult = mPermissionManager.checkPermissionForStartDataDelivery( |
| UWB_RANGING, attributionSource, message); |
| return permissionCheckResult == PERMISSION_GRANTED; |
| } |
| |
| /** Indicate permission manager that the ranging session is done or stopped. */ |
| public void finishUwbRangingPermissionForDataDelivery( |
| @NonNull AttributionSource attributionSource) { |
| mPermissionManager.finishDataDelivery(UWB_RANGING, attributionSource); |
| } |
| |
| /** |
| * Get device protected storage dir for the UWB apex. |
| */ |
| @NonNull |
| public static File getDeviceProtectedDataDir() { |
| return ApexEnvironment.getApexEnvironment(APEX_NAME).getDeviceProtectedDataDir(); |
| } |
| |
| /** |
| * Get integer value from Settings. |
| * |
| * @throws Settings.SettingNotFoundException |
| */ |
| public int getGlobalSettingsInt(@NonNull String key) throws Settings.SettingNotFoundException { |
| return Settings.Global.getInt(mContext.getContentResolver(), key); |
| } |
| |
| /** |
| * Get integer value from Settings. |
| */ |
| public int getGlobalSettingsInt(@NonNull String key, int defValue) { |
| return Settings.Global.getInt(mContext.getContentResolver(), key, defValue); |
| } |
| |
| /** |
| * Get string value from Settings. |
| */ |
| @Nullable |
| public String getGlobalSettingsString(@NonNull String key) { |
| return Settings.Global.getString(mContext.getContentResolver(), key); |
| } |
| |
| /** |
| * Helper method for classes to register a ContentObserver |
| * {@see ContentResolver#registerContentObserver(Uri,boolean,ContentObserver)}. |
| */ |
| public void registerContentObserver(Uri uri, boolean notifyForDescendants, |
| ContentObserver contentObserver) { |
| mContext.getContentResolver().registerContentObserver(uri, notifyForDescendants, |
| contentObserver); |
| } |
| |
| /** |
| * Helper method for classes to unregister a ContentObserver |
| * {@see ContentResolver#unregisterContentObserver(ContentObserver)}. |
| */ |
| public void unregisterContentObserver(ContentObserver contentObserver) { |
| mContext.getContentResolver().unregisterContentObserver(contentObserver); |
| } |
| |
| /** |
| * Uwb user specific folder. |
| */ |
| public static File getCredentialProtectedDataDirForUser(int userId) { |
| return ApexEnvironment.getApexEnvironment(APEX_NAME) |
| .getCredentialProtectedDataDirForUser(UserHandle.of(userId)); |
| } |
| |
| /** |
| * Returns true if the app is in the Uwb apex, false otherwise. |
| * Checks if the app's path starts with "/apex/com.android.uwb". |
| */ |
| public static boolean isAppInUwbApex(ApplicationInfo appInfo) { |
| return appInfo.sourceDir.startsWith(UWB_APEX_PATH); |
| } |
| |
| /** |
| * Get the current time of the clock in milliseconds. |
| * |
| * @return Current time in milliseconds. |
| */ |
| public long getWallClockMillis() { |
| return System.currentTimeMillis(); |
| } |
| |
| /** |
| * Returns milliseconds since boot, including time spent in sleep. |
| * |
| * @return Current time since boot in milliseconds. |
| */ |
| public long getElapsedSinceBootMillis() { |
| return SystemClock.elapsedRealtime(); |
| } |
| |
| /** |
| * Returns nanoseconds since boot, including time spent in sleep. |
| * |
| * @return Current time since boot in milliseconds. |
| */ |
| public long getElapsedSinceBootNanos() { |
| return SystemClock.elapsedRealtimeNanos(); |
| } |
| |
| /** |
| * Is this a valid country code |
| * |
| * @param countryCode A 2-Character alphanumeric country code. |
| * @return true if the countryCode is valid, false otherwise. |
| */ |
| private static boolean isValidCountryCode(String countryCode) { |
| return countryCode != null && countryCode.length() == 2 |
| && countryCode.chars().allMatch(Character::isLetterOrDigit); |
| } |
| |
| /** |
| * Default country code stored in system property |
| * |
| * @return Country code if available, null otherwise. |
| */ |
| public String getOemDefaultCountryCode() { |
| String country = SystemProperties.get(BOOT_DEFAULT_UWB_COUNTRY_CODE); |
| return isValidCountryCode(country) ? country.toUpperCase(Locale.US) : null; |
| } |
| |
| /** |
| * Helper method creating a context based on the app's uid (to deal with multi user scenarios) |
| */ |
| @Nullable |
| private Context createPackageContextAsUser(int uid) { |
| Context userContext; |
| try { |
| userContext = mContext.createPackageContextAsUser(mContext.getPackageName(), 0, |
| UserHandle.getUserHandleForUid(uid)); |
| } catch (PackageManager.NameNotFoundException e) { |
| Log.e(TAG, "Unknown package name"); |
| return null; |
| } |
| if (userContext == null) { |
| Log.e(TAG, "Unable to retrieve user context for " + uid); |
| return null; |
| } |
| return userContext; |
| } |
| |
| /** Helper method to check if the app is a system app. */ |
| public boolean isSystemApp(int uid, @NonNull String packageName) { |
| try { |
| ApplicationInfo info = createPackageContextAsUser(uid) |
| .getPackageManager() |
| .getApplicationInfo(packageName, 0); |
| return (info.flags & APP_INFO_FLAGS_SYSTEM_APP) != 0; |
| } catch (PackageManager.NameNotFoundException e) { |
| // In case of exception, assume unknown app (more strict checking) |
| // Note: This case will never happen since checkPackage is |
| // called to verify validity before checking App's version. |
| Log.e(TAG, "Failed to get the app info", e); |
| } |
| return false; |
| } |
| |
| /** Whether the uid is signed with the same key as the platform. */ |
| public boolean isAppSignedWithPlatformKey(int uid) { |
| return mContext.getPackageManager().checkSignatures(uid, Process.SYSTEM_UID) |
| == PackageManager.SIGNATURE_MATCH; |
| } |
| |
| private static Map<String, Integer> sOverridePackageImportance = new HashMap(); |
| public void setOverridePackageImportance(String packageName, int importance) { |
| sOverridePackageImportance.put(packageName, importance); |
| } |
| public void resetOverridePackageImportance(String packageName) { |
| sOverridePackageImportance.remove(packageName); |
| } |
| |
| /** Helper method to retrieve app importance. */ |
| private int getPackageImportance(int uid, @NonNull String packageName) { |
| if (sOverridePackageImportance.containsKey(packageName)) { |
| Log.w(TAG, "Overriding package importance for testing"); |
| return sOverridePackageImportance.get(packageName); |
| } |
| try { |
| return createPackageContextAsUser(uid) |
| .getSystemService(ActivityManager.class) |
| .getPackageImportance(packageName); |
| } catch (SecurityException e) { |
| Log.e(TAG, "Failed to retrieve the app importance", e); |
| return ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE; |
| } |
| } |
| |
| /** Helper method to check if the app is from foreground app/service. */ |
| public static boolean isForegroundAppOrServiceImportance(int importance) { |
| return importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE; |
| } |
| |
| /** Helper method to check if the app is from foreground app/service. */ |
| public boolean isForegroundAppOrService(int uid, @NonNull String packageName) { |
| long identity = Binder.clearCallingIdentity(); |
| try { |
| return isForegroundAppOrServiceImportance(getPackageImportance(uid, packageName)); |
| } catch (SecurityException e) { |
| Log.e(TAG, "Failed to retrieve the app importance", e); |
| return false; |
| } finally { |
| Binder.restoreCallingIdentity(identity); |
| } |
| } |
| |
| /* Helps to mock the executor for tests */ |
| public int runTaskOnSingleThreadExecutor(FutureTask<Integer> task, int timeoutMs) |
| throws InterruptedException, TimeoutException, ExecutionException { |
| ExecutorService executor = Executors.newSingleThreadExecutor(); |
| executor.submit(task); |
| try { |
| return task.get(timeoutMs, TimeUnit.MILLISECONDS); |
| } catch (TimeoutException e) { |
| executor.shutdownNow(); |
| throw e; |
| } |
| } |
| |
| public boolean isMulticastListNtfV2Supported() { |
| return mContext.getResources().getBoolean( |
| com.android.uwb.resources.R.bool.is_multicast_list_update_ntf_v2_supported); |
| } |
| |
| /** |
| * Gets the configured pose source, which is reference counted. If there are no references |
| * to the pose source, one will be created based on the device configuration. This may |
| * @return A shared or new pose source, or null if one is not configured or available. |
| */ |
| public IPoseSource acquirePoseSource() { |
| mPoseLock.lock(); |
| try { |
| // Keep our ref counts accurate because isEnableFilters can change at runtime. |
| mPoseSourceRefCount++; |
| |
| if (!getDeviceConfigFacade().isEnableFilters()) { |
| return null; |
| } |
| |
| if (mDefaultPoseSource != null) { |
| // Already have a pose source. |
| return mDefaultPoseSource; |
| } |
| |
| switch (mDeviceConfigFacade.getPoseSourceType()) { |
| case NONE: |
| mDefaultPoseSource = null; |
| break; |
| case ROTATION_VECTOR: |
| mDefaultPoseSource = new RotationPoseSource(mContext, 100); |
| break; |
| case GYRO: |
| mDefaultPoseSource = new GyroPoseSource(mContext, 100); |
| break; |
| case SIXDOF: |
| mDefaultPoseSource = new SixDofPoseSource(mContext, 100); |
| break; |
| case DOUBLE_INTEGRATE: |
| mDefaultPoseSource = new IntegPoseSource(mContext, 100); |
| break; |
| } |
| |
| return mDefaultPoseSource; |
| } catch (Exception ex) { |
| Log.e(TAG, "Unable to create the configured UWB pose source: " |
| + ex.getMessage()); |
| mPoseSourceRefCount--; |
| return null; |
| } finally { |
| mPoseLock.unlock(); |
| } |
| } |
| |
| /** |
| * Decrements the reference counts to the default pose source, and closes the source once the |
| * count reaches zero. This must be called once for each time acquirePoseSource() is called. |
| */ |
| public void releasePoseSource() { |
| mPoseLock.lock(); |
| try { |
| // Keep our ref counts accurate because isEnableFilters can change at runtime. |
| --mPoseSourceRefCount; |
| if (mPoseSourceRefCount <= 0 && mDefaultPoseSource != null) { |
| mDefaultPoseSource.close(); |
| mDefaultPoseSource = null; |
| } |
| } finally { |
| mPoseLock.unlock(); |
| } |
| } |
| |
| /** |
| * Creates a filter engine using the default pose source. A default pose source must first be |
| * acquired with {@link #acquirePoseSource()}. |
| * |
| * @return A fully configured filter engine, or null if filtering is disabled. |
| */ |
| public UwbFilterEngine createFilterEngine(IPoseSource poseSource) { |
| DeviceConfigFacade cfg = getDeviceConfigFacade(); |
| if (!cfg.isEnableFilters()) { |
| return null; |
| } |
| |
| // This could go wrong if the config flags or overlay have bad values. |
| try { |
| IFilter azimuthFilter = new MedAvgRotationFilter( |
| cfg.getFilterAngleWindow(), |
| cfg.getFilterAngleInliersPercent() / 100f); |
| IFilter elevationFilter = new MedAvgRotationFilter( |
| cfg.getFilterAngleWindow(), |
| cfg.getFilterAngleInliersPercent() / 100f); |
| IFilter distanceFilter = new MedAvgFilter( |
| cfg.getFilterDistanceWindow(), |
| cfg.getFilterDistanceInliersPercent() / 100f); |
| |
| PositionFilterImpl posFilter = new PositionFilterImpl( |
| azimuthFilter, |
| elevationFilter, |
| distanceFilter); |
| |
| UwbFilterEngine.Builder builder = new UwbFilterEngine.Builder().setFilter(posFilter); |
| |
| if (poseSource != null) { |
| builder.setPoseSource(poseSource); |
| } |
| |
| // Order is important. |
| if (cfg.isEnablePrimerEstElevation()) { |
| builder.addPrimer(new ElevationPrimer()); |
| } |
| |
| // AoAPrimer requires an elevation estimation in order to convert to spherical coords. |
| if (cfg.isEnablePrimerAoA()) { |
| builder.addPrimer(new AoaPrimer()); |
| } |
| |
| // Fov requires an elevation and a spherical coord. |
| if (cfg.isEnablePrimerFov()) { |
| builder.addPrimer(new FovPrimer((float) toRadians(cfg.getPrimerFovDegree()))); |
| } |
| |
| // Back azimuth detection requires true spherical. |
| if (cfg.isEnableBackAzimuth()) { |
| builder.addPrimer(new BackAzimuthPrimer( |
| cfg.getFrontAzimuthRadiansPerSecond(), |
| cfg.getBackAzimuthRadiansPerSecond(), |
| cfg.getBackAzimuthWindow(), |
| cfg.isEnableBackAzimuthMasking(), |
| cfg.getMirrorScoreStdRadians(), |
| cfg.getBackNoiseInfluenceCoeff())); |
| } |
| |
| return builder.build(); |
| } catch (Exception ex) { |
| Log.e(TAG, "Unable to create UWB filter engine: " + ex.getMessage()); |
| return null; |
| } |
| } |
| } |