1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.nearby.fastpair.cache; 18 19 import static com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress.maskBluetoothAddress; 20 import static com.android.server.nearby.fastpair.UserActionHandler.EXTRA_FAST_PAIR_SECRET; 21 22 import android.annotation.IntDef; 23 import android.annotation.Nullable; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.graphics.Bitmap; 27 import android.graphics.BitmapFactory; 28 import android.text.TextUtils; 29 import android.util.Log; 30 31 import com.android.internal.annotations.VisibleForTesting; 32 import com.android.server.nearby.common.ble.util.RangingUtils; 33 import com.android.server.nearby.common.fastpair.IconUtils; 34 import com.android.server.nearby.common.locator.Locator; 35 import com.android.server.nearby.common.locator.LocatorContextWrapper; 36 37 import java.lang.annotation.Retention; 38 import java.lang.annotation.RetentionPolicy; 39 import java.net.URISyntaxException; 40 import java.time.Clock; 41 import java.util.Objects; 42 43 import service.proto.Cache; 44 45 /** 46 * Wrapper class around StoredDiscoveryItem. A centralized place for methods related to 47 * updating/parsing StoredDiscoveryItem. 48 */ 49 public class DiscoveryItem implements Comparable<DiscoveryItem> { 50 51 private static final String ACTION_FAST_PAIR = 52 "com.android.server.nearby:ACTION_FAST_PAIR"; 53 private static final int BEACON_STALENESS_MILLIS = 120000; 54 private static final int ITEM_EXPIRATION_MILLIS = 20000; 55 private static final int APP_INSTALL_EXPIRATION_MILLIS = 600000; 56 private static final int ITEM_DELETABLE_MILLIS = 15000; 57 58 private final FastPairCacheManager mFastPairCacheManager; 59 private final Clock mClock; 60 61 private Cache.StoredDiscoveryItem mStoredDiscoveryItem; 62 63 /** IntDef for StoredDiscoveryItem.State */ 64 @IntDef({ 65 Cache.StoredDiscoveryItem.State.STATE_ENABLED_VALUE, 66 Cache.StoredDiscoveryItem.State.STATE_MUTED_VALUE, 67 Cache.StoredDiscoveryItem.State.STATE_DISABLED_BY_SYSTEM_VALUE 68 }) 69 @Retention(RetentionPolicy.SOURCE) 70 public @interface ItemState { 71 } 72 DiscoveryItem(LocatorContextWrapper locatorContextWrapper, Cache.StoredDiscoveryItem mStoredDiscoveryItem)73 public DiscoveryItem(LocatorContextWrapper locatorContextWrapper, 74 Cache.StoredDiscoveryItem mStoredDiscoveryItem) { 75 this.mFastPairCacheManager = 76 locatorContextWrapper.getLocator().get(FastPairCacheManager.class); 77 this.mClock = 78 locatorContextWrapper.getLocator().get(Clock.class); 79 this.mStoredDiscoveryItem = mStoredDiscoveryItem; 80 } 81 DiscoveryItem(Context context, Cache.StoredDiscoveryItem mStoredDiscoveryItem)82 public DiscoveryItem(Context context, Cache.StoredDiscoveryItem mStoredDiscoveryItem) { 83 this.mFastPairCacheManager = Locator.get(context, FastPairCacheManager.class); 84 this.mClock = Locator.get(context, Clock.class); 85 this.mStoredDiscoveryItem = mStoredDiscoveryItem; 86 } 87 88 /** @return A new StoredDiscoveryItem with state fields set to their defaults. */ newStoredDiscoveryItem()89 public static Cache.StoredDiscoveryItem newStoredDiscoveryItem() { 90 Cache.StoredDiscoveryItem.Builder storedDiscoveryItem = 91 Cache.StoredDiscoveryItem.newBuilder(); 92 storedDiscoveryItem.setState(Cache.StoredDiscoveryItem.State.STATE_ENABLED); 93 return storedDiscoveryItem.build(); 94 } 95 96 /** 97 * Checks if store discovery item support fast pair or not. 98 */ isFastPair()99 public boolean isFastPair() { 100 Intent intent = parseIntentScheme(mStoredDiscoveryItem.getActionUrl()); 101 if (intent == null) { 102 Log.w("FastPairDiscovery", "FastPair: fail to parse action url" 103 + mStoredDiscoveryItem.getActionUrl()); 104 return false; 105 } 106 return ACTION_FAST_PAIR.equals(intent.getAction()); 107 } 108 109 /** 110 * Checks if the item is expired. Expired items are those over getItemExpirationMillis() eg. 2 111 * minutes 112 */ isExpired( long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis)113 public static boolean isExpired( 114 long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis) { 115 if (lastObservationTimestampMillis == null) { 116 return true; 117 } 118 return (currentTimestampMillis - lastObservationTimestampMillis) 119 >= ITEM_EXPIRATION_MILLIS; 120 } 121 122 /** 123 * Checks if the item is deletable for saving disk space. Deletable items are those over 124 * getItemDeletableMillis eg. over 25 hrs. 125 */ isDeletable( long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis)126 public static boolean isDeletable( 127 long currentTimestampMillis, @Nullable Long lastObservationTimestampMillis) { 128 if (lastObservationTimestampMillis == null) { 129 return true; 130 } 131 return currentTimestampMillis - lastObservationTimestampMillis 132 >= ITEM_DELETABLE_MILLIS; 133 } 134 135 /** Checks if the item has a pending app install */ isPendingAppInstallValid()136 public boolean isPendingAppInstallValid() { 137 return isPendingAppInstallValid(mClock.millis()); 138 } 139 140 /** 141 * Checks if pending app valid. 142 */ isPendingAppInstallValid(long appInstallMillis)143 public boolean isPendingAppInstallValid(long appInstallMillis) { 144 return isPendingAppInstallValid(appInstallMillis, mStoredDiscoveryItem); 145 } 146 147 /** 148 * Checks if the app install time expired. 149 */ isPendingAppInstallValid( long currentMillis, Cache.StoredDiscoveryItem storedItem)150 public static boolean isPendingAppInstallValid( 151 long currentMillis, Cache.StoredDiscoveryItem storedItem) { 152 return currentMillis - storedItem.getPendingAppInstallTimestampMillis() 153 < APP_INSTALL_EXPIRATION_MILLIS; 154 } 155 156 157 /** Checks if the item has enough data to be shown */ isReadyForDisplay()158 public boolean isReadyForDisplay() { 159 boolean hasUrlOrPopularApp = !mStoredDiscoveryItem.getActionUrl().isEmpty(); 160 161 return !TextUtils.isEmpty(mStoredDiscoveryItem.getTitle()) && hasUrlOrPopularApp; 162 } 163 164 /** Checks if the action url is app install */ isApp()165 public boolean isApp() { 166 return mStoredDiscoveryItem.getActionUrlType() == Cache.ResolvedUrlType.APP; 167 } 168 169 /** Returns true if an item is muted, or if state is unavailable. */ isMuted()170 public boolean isMuted() { 171 return mStoredDiscoveryItem.getState() != Cache.StoredDiscoveryItem.State.STATE_ENABLED; 172 } 173 174 /** 175 * Returns the state of store discovery item. 176 */ getState()177 public Cache.StoredDiscoveryItem.State getState() { 178 return mStoredDiscoveryItem.getState(); 179 } 180 181 /** Checks if it's device item. e.g. Chromecast / Wear */ isDeviceType(Cache.NearbyType type)182 public static boolean isDeviceType(Cache.NearbyType type) { 183 return type == Cache.NearbyType.NEARBY_CHROMECAST 184 || type == Cache.NearbyType.NEARBY_WEAR 185 || type == Cache.NearbyType.NEARBY_DEVICE; 186 } 187 188 /** 189 * Check if the type is supported. 190 */ isTypeEnabled(Cache.NearbyType type)191 public static boolean isTypeEnabled(Cache.NearbyType type) { 192 switch (type) { 193 case NEARBY_WEAR: 194 case NEARBY_CHROMECAST: 195 case NEARBY_DEVICE: 196 return true; 197 default: 198 Log.e("FastPairDiscoveryItem", "Invalid item type " + type.name()); 199 return false; 200 } 201 } 202 203 /** Gets hash code of UI related data so we can collapse identical items. */ getUiHashCode()204 public int getUiHashCode() { 205 return Objects.hash( 206 mStoredDiscoveryItem.getTitle(), 207 mStoredDiscoveryItem.getDescription(), 208 mStoredDiscoveryItem.getAppName(), 209 mStoredDiscoveryItem.getDisplayUrl(), 210 mStoredDiscoveryItem.getMacAddress()); 211 } 212 213 // Getters below 214 215 /** 216 * Returns the id of store discovery item. 217 */ 218 @Nullable getId()219 public String getId() { 220 return mStoredDiscoveryItem.getId(); 221 } 222 223 /** 224 * Returns the title of discovery item. 225 */ 226 @Nullable getTitle()227 public String getTitle() { 228 return mStoredDiscoveryItem.getTitle(); 229 } 230 231 /** 232 * Returns the description of discovery item. 233 */ 234 @Nullable getDescription()235 public String getDescription() { 236 return mStoredDiscoveryItem.getDescription(); 237 } 238 239 /** 240 * Returns the mac address of discovery item. 241 */ 242 @Nullable getMacAddress()243 public String getMacAddress() { 244 return mStoredDiscoveryItem.getMacAddress(); 245 } 246 247 /** 248 * Returns the display url of discovery item. 249 */ 250 @Nullable getDisplayUrl()251 public String getDisplayUrl() { 252 return mStoredDiscoveryItem.getDisplayUrl(); 253 } 254 255 /** 256 * Returns the public key of discovery item. 257 */ 258 @Nullable getAuthenticationPublicKeySecp256R1()259 public byte[] getAuthenticationPublicKeySecp256R1() { 260 return mStoredDiscoveryItem.getAuthenticationPublicKeySecp256R1().toByteArray(); 261 } 262 263 /** 264 * Returns the pairing secret. 265 */ 266 @Nullable getFastPairSecretKey()267 public String getFastPairSecretKey() { 268 Intent intent = parseIntentScheme(mStoredDiscoveryItem.getActionUrl()); 269 if (intent == null) { 270 Log.d("FastPairDiscoveryItem", "FastPair: fail to parse action url " 271 + mStoredDiscoveryItem.getActionUrl()); 272 return null; 273 } 274 return intent.getStringExtra(EXTRA_FAST_PAIR_SECRET); 275 } 276 277 /** 278 * Returns the fast pair info of discovery item. 279 */ 280 @Nullable getFastPairInformation()281 public Cache.FastPairInformation getFastPairInformation() { 282 return mStoredDiscoveryItem.hasFastPairInformation() 283 ? mStoredDiscoveryItem.getFastPairInformation() : null; 284 } 285 286 /** 287 * Returns the app name of discovery item. 288 */ 289 @Nullable 290 @VisibleForTesting getAppName()291 protected String getAppName() { 292 return mStoredDiscoveryItem.getAppName(); 293 } 294 295 /** 296 * Returns the package name of discovery item. 297 */ 298 @Nullable getAppPackageName()299 public String getAppPackageName() { 300 return mStoredDiscoveryItem.getPackageName(); 301 } 302 303 /** 304 * Returns the action url of discovery item. 305 */ 306 @Nullable getActionUrl()307 public String getActionUrl() { 308 return mStoredDiscoveryItem.getActionUrl(); 309 } 310 311 /** 312 * Returns the rssi value of discovery item. 313 */ 314 @Nullable getRssi()315 public Integer getRssi() { 316 return mStoredDiscoveryItem.getRssi(); 317 } 318 319 /** 320 * Returns the TX power of discovery item. 321 */ 322 @Nullable getTxPower()323 public Integer getTxPower() { 324 return mStoredDiscoveryItem.getTxPower(); 325 } 326 327 /** 328 * Returns the first observed time stamp of discovery item. 329 */ 330 @Nullable getFirstObservationTimestampMillis()331 public Long getFirstObservationTimestampMillis() { 332 return mStoredDiscoveryItem.getFirstObservationTimestampMillis(); 333 } 334 335 /** 336 * Returns the last observed time stamp of discovery item. 337 */ 338 @Nullable getLastObservationTimestampMillis()339 public Long getLastObservationTimestampMillis() { 340 return mStoredDiscoveryItem.getLastObservationTimestampMillis(); 341 } 342 343 /** 344 * Calculates an estimated distance for the item, computed from the TX power (at 1m) and RSSI. 345 * 346 * @return estimated distance, or null if there is no RSSI or no TX power. 347 */ 348 @Nullable getEstimatedDistance()349 public Double getEstimatedDistance() { 350 // In the future, we may want to do a foreground subscription to leverage onDistanceChanged. 351 return RangingUtils.distanceFromRssiAndTxPower(mStoredDiscoveryItem.getRssi(), 352 mStoredDiscoveryItem.getTxPower()); 353 } 354 355 /** 356 * Gets icon Bitmap from icon store. 357 * 358 * @return null if no icon or icon size is incorrect. 359 */ 360 @Nullable getIcon()361 public Bitmap getIcon() { 362 Bitmap icon = 363 BitmapFactory.decodeByteArray( 364 mStoredDiscoveryItem.getIconPng().toByteArray(), 365 0 /* offset */, mStoredDiscoveryItem.getIconPng().size()); 366 if (IconUtils.isIconSizeCorrect(icon)) { 367 return icon; 368 } else { 369 return null; 370 } 371 } 372 373 /** Gets a FIFE URL of the icon. */ 374 @Nullable getIconFifeUrl()375 public String getIconFifeUrl() { 376 return mStoredDiscoveryItem.getIconFifeUrl(); 377 } 378 379 /** 380 * Compares this object to the specified object: 1. By device type. Device setups are 'greater 381 * than' beacons. 2. By relevance. More relevant items are 'greater than' less relevant items. 382 * 3.By distance. Nearer items are 'greater than' further items. 383 * 384 * <p>In the list view, we sort in descending order, i.e. we put the most relevant items first. 385 */ 386 @Override compareTo(DiscoveryItem another)387 public int compareTo(DiscoveryItem another) { 388 // For items of the same relevance, compare distance. 389 Double distance1 = getEstimatedDistance(); 390 Double distance2 = another.getEstimatedDistance(); 391 distance1 = distance1 != null ? distance1 : Double.MAX_VALUE; 392 distance2 = distance2 != null ? distance2 : Double.MAX_VALUE; 393 // Negate because closer items are better ("greater than") further items. 394 return -distance1.compareTo(distance2); 395 } 396 397 @Nullable getTriggerId()398 public String getTriggerId() { 399 return mStoredDiscoveryItem.getTriggerId(); 400 } 401 402 @Override equals(Object another)403 public boolean equals(Object another) { 404 if (another instanceof DiscoveryItem) { 405 return ((DiscoveryItem) another).mStoredDiscoveryItem.equals(mStoredDiscoveryItem); 406 } 407 return false; 408 } 409 410 @Override hashCode()411 public int hashCode() { 412 return mStoredDiscoveryItem.hashCode(); 413 } 414 415 @Override toString()416 public String toString() { 417 return String.format( 418 "[triggerId=%s], [id=%s], [title=%s], [url=%s], [ready=%s], [macAddress=%s]", 419 getTriggerId(), 420 getId(), 421 getTitle(), 422 getActionUrl(), 423 isReadyForDisplay(), 424 maskBluetoothAddress(getMacAddress())); 425 } 426 427 /** 428 * Gets a copy of the StoredDiscoveryItem proto backing this DiscoveryItem. Currently needed for 429 * Fast Pair 2.0: We store the item in the cloud associated with a user's account, to enable 430 * pairing with other devices owned by the user. 431 */ getCopyOfStoredItem()432 public Cache.StoredDiscoveryItem getCopyOfStoredItem() { 433 return mStoredDiscoveryItem; 434 } 435 436 /** 437 * Gets the StoredDiscoveryItem represented by this DiscoveryItem. This lets tests manipulate 438 * values that production code should not manipulate. 439 */ 440 getStoredItemForTest()441 public Cache.StoredDiscoveryItem getStoredItemForTest() { 442 return mStoredDiscoveryItem; 443 } 444 445 /** 446 * Sets the StoredDiscoveryItem represented by this DiscoveryItem. This lets tests manipulate 447 * values that production code should not manipulate. 448 */ setStoredItemForTest(Cache.StoredDiscoveryItem s)449 public void setStoredItemForTest(Cache.StoredDiscoveryItem s) { 450 mStoredDiscoveryItem = s; 451 } 452 453 /** 454 * Parse the intent from item url. 455 */ parseIntentScheme(String uri)456 public static Intent parseIntentScheme(String uri) { 457 try { 458 return Intent.parseUri(uri, Intent.URI_INTENT_SCHEME); 459 } catch (URISyntaxException e) { 460 return null; 461 } 462 } 463 } 464