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