1 /* 2 * Copyright (C) 2020 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 package com.android.car.rotary; 17 18 import android.os.SystemClock; 19 import android.util.LruCache; 20 import android.view.View; 21 import android.view.accessibility.AccessibilityNodeInfo; 22 23 import androidx.annotation.IntDef; 24 import androidx.annotation.NonNull; 25 import androidx.annotation.Nullable; 26 import androidx.annotation.VisibleForTesting; 27 28 import java.lang.annotation.Retention; 29 import java.lang.annotation.RetentionPolicy; 30 import java.util.ArrayList; 31 import java.util.Collections; 32 import java.util.List; 33 import java.util.Map; 34 import java.util.Objects; 35 36 /** 37 * Cache of rotation and nudge history of rotary controller. With this cache, the users can reverse 38 * course and go back where they were if they accidentally nudge too far. 39 */ 40 class RotaryCache { 41 42 /** The cache is disabled. */ 43 @VisibleForTesting 44 static final int CACHE_TYPE_DISABLED = 1; 45 /** Entries in the cache will expire after a period of time. */ 46 @VisibleForTesting 47 static final int CACHE_TYPE_EXPIRED_AFTER_SOME_TIME = 2; 48 /** Entries in the cache will never expire as long as RotaryService is alive. */ 49 @VisibleForTesting 50 static final int CACHE_TYPE_NEVER_EXPIRE = 3; 51 52 @IntDef(flag = true, value = { 53 CACHE_TYPE_DISABLED, CACHE_TYPE_EXPIRED_AFTER_SOME_TIME, CACHE_TYPE_NEVER_EXPIRE}) 54 @Retention(RetentionPolicy.SOURCE) 55 public @interface CacheType { 56 } 57 58 @NonNull 59 private NodeCopier mNodeCopier = new NodeCopier(); 60 61 /** Cache of last focused node by focus area. */ 62 @NonNull 63 private final FocusHistoryCache mFocusHistoryCache; 64 65 /** Cache of target focus area by source focus area and direction (up, down, left or right). */ 66 @NonNull 67 private final FocusAreaHistoryCache mFocusAreaHistoryCache; 68 69 /** 70 * Cache of recently focused nodes in recently focused windows. Used to recover when the 71 * focused window closes. 72 */ 73 @NonNull 74 private final FocusWindowCache mFocusWindowCache; 75 76 /** A record of when a node was focused. */ 77 private static class FocusHistory { 78 79 /** 80 * A node representing a focusable {@link View} or a {@link com.android.car.ui.FocusArea}. 81 */ 82 @NonNull 83 final AccessibilityNodeInfo node; 84 85 /** The {@link SystemClock#uptimeMillis} when this history was recorded. */ 86 final long timestamp; 87 FocusHistory(@onNull AccessibilityNodeInfo node, long timestamp)88 FocusHistory(@NonNull AccessibilityNodeInfo node, long timestamp) { 89 this.node = node; 90 this.timestamp = timestamp; 91 } 92 } 93 94 /** 95 * A combination of a source focus area and a direction (up, down, left or right). Used as a key 96 * in {@link #mFocusAreaHistoryCache}. 97 */ 98 private static class FocusAreaHistory { 99 100 @NonNull 101 final AccessibilityNodeInfo sourceFocusArea; 102 final int direction; 103 FocusAreaHistory(@onNull AccessibilityNodeInfo sourceFocusArea, int direction)104 FocusAreaHistory(@NonNull AccessibilityNodeInfo sourceFocusArea, int direction) { 105 this.sourceFocusArea = sourceFocusArea; 106 this.direction = direction; 107 } 108 109 @Override equals(Object o)110 public boolean equals(Object o) { 111 if (this == o) { 112 return true; 113 } 114 if (o == null || getClass() != o.getClass()) { 115 return false; 116 } 117 FocusAreaHistory that = (FocusAreaHistory) o; 118 return direction == that.direction 119 && Objects.equals(sourceFocusArea, that.sourceFocusArea); 120 } 121 122 @Override hashCode()123 public int hashCode() { 124 return Objects.hash(sourceFocusArea, direction); 125 } 126 } 127 128 /** An entry in {@link #mFocusWindowCache}. */ 129 private static class FocusWindowHistory { 130 /** A node in a window representing a focused {@link View}. */ 131 @NonNull 132 final AccessibilityNodeInfo mNode; 133 134 /** The {@link SystemClock#uptimeMillis} when this history was recorded. */ 135 final long mTimestamp; 136 FocusWindowHistory(@onNull AccessibilityNodeInfo node, long timestamp)137 FocusWindowHistory(@NonNull AccessibilityNodeInfo node, long timestamp) { 138 this.mNode = node; 139 this.mTimestamp = timestamp; 140 } 141 recycle()142 void recycle() { 143 this.mNode.recycle(); 144 } 145 } 146 147 /** A cache of the last focused node by focus area. */ 148 private class FocusHistoryCache extends LruCache<AccessibilityNodeInfo, FocusHistory> { 149 150 /** Type of the cache. */ 151 private final @CacheType int mCacheType; 152 153 /** How many milliseconds before an entry in the cache expires. */ 154 private final int mExpirationTimeMs; 155 FocusHistoryCache(@acheType int cacheType, int size, int expirationTimeMs)156 FocusHistoryCache(@CacheType int cacheType, int size, int expirationTimeMs) { 157 super(size); 158 mCacheType = cacheType; 159 mExpirationTimeMs = expirationTimeMs; 160 if (mCacheType == CACHE_TYPE_EXPIRED_AFTER_SOME_TIME && mExpirationTimeMs <= 0) { 161 throw new IllegalArgumentException( 162 "Expiration time must be positive if CacheType is " 163 + "CACHE_TYPE_EXPIRED_AFTER_SOME_TIME"); 164 } 165 } 166 enabled()167 boolean enabled() { 168 return mCacheType != CACHE_TYPE_DISABLED; 169 } 170 isValidFocusHistory(@ullable FocusHistory focusHistory, long elapsedRealtime)171 boolean isValidFocusHistory(@Nullable FocusHistory focusHistory, long elapsedRealtime) { 172 if (focusHistory == null || focusHistory.node == null) { 173 return false; 174 } 175 switch (mCacheType) { 176 case CACHE_TYPE_NEVER_EXPIRE: 177 return true; 178 case CACHE_TYPE_EXPIRED_AFTER_SOME_TIME: 179 return elapsedRealtime - focusHistory.timestamp < mExpirationTimeMs; 180 default: 181 return false; 182 } 183 } 184 185 @Override entryRemoved(boolean evicted, AccessibilityNodeInfo key, FocusHistory oldValue, FocusHistory newValue)186 protected void entryRemoved(boolean evicted, AccessibilityNodeInfo key, 187 FocusHistory oldValue, FocusHistory newValue) { 188 Utils.recycleNode(key); 189 Utils.recycleNode(oldValue.node); 190 } 191 } 192 193 /** 194 * A cache of the target focus area to nudge to, by source focus area and direction (up, down, 195 * left or right). 196 */ 197 private class FocusAreaHistoryCache extends LruCache<FocusAreaHistory, FocusHistory> { 198 199 /** Type of the cache. */ 200 private final @CacheType int mCacheType; 201 202 /** How many milliseconds before an entry in the cache expires. */ 203 private final int mExpirationTimeMs; 204 FocusAreaHistoryCache(@acheType int cacheType, int size, int expirationTimeMs)205 FocusAreaHistoryCache(@CacheType int cacheType, int size, int expirationTimeMs) { 206 super(size); 207 mCacheType = cacheType; 208 mExpirationTimeMs = expirationTimeMs; 209 if (mCacheType == CACHE_TYPE_EXPIRED_AFTER_SOME_TIME && mExpirationTimeMs <= 0) { 210 throw new IllegalArgumentException( 211 "Expiration time must be positive if CacheType is " 212 + "CACHE_TYPE_EXPIRED_AFTER_SOME_TIME"); 213 } 214 } 215 enabled()216 boolean enabled() { 217 return mCacheType != CACHE_TYPE_DISABLED; 218 } 219 isValidFocusHistory(@ullable FocusHistory focusHistory, long elapsedRealtime)220 boolean isValidFocusHistory(@Nullable FocusHistory focusHistory, long elapsedRealtime) { 221 if (focusHistory == null || focusHistory.node == null) { 222 return false; 223 } 224 switch (mCacheType) { 225 case CACHE_TYPE_NEVER_EXPIRE: 226 return true; 227 case CACHE_TYPE_EXPIRED_AFTER_SOME_TIME: 228 return elapsedRealtime - focusHistory.timestamp < mExpirationTimeMs; 229 default: 230 return false; 231 } 232 } 233 234 @Override entryRemoved(boolean evicted, FocusAreaHistory key, FocusHistory oldValue, FocusHistory newValue)235 protected void entryRemoved(boolean evicted, FocusAreaHistory key, FocusHistory oldValue, 236 FocusHistory newValue) { 237 Utils.recycleNode(key.sourceFocusArea); 238 Utils.recycleNode(oldValue.node); 239 } 240 } 241 242 /** 243 * A cache of recently focused nodes in recently focused windows. Used to recover when the 244 * focused window closes. 245 */ 246 private class FocusWindowCache extends LruCache<Integer, FocusWindowHistory> { 247 @CacheType 248 final int mCacheType; 249 final int mExpirationTimeMs; 250 FocusWindowCache(@acheType int cacheType, int size, int expirationTimeMs)251 FocusWindowCache(@CacheType int cacheType, int size, int expirationTimeMs) { 252 super(size); 253 mCacheType = cacheType; 254 mExpirationTimeMs = expirationTimeMs; 255 if (cacheType == CACHE_TYPE_EXPIRED_AFTER_SOME_TIME && expirationTimeMs <= 0) { 256 throw new IllegalArgumentException( 257 "Expiration time must be positive if CacheType is " 258 + "CACHE_TYPE_EXPIRED_AFTER_SOME_TIME"); 259 } 260 } 261 262 /** 263 * Returns whether an entry in this cache is valid. To be valid: 264 * <ul> 265 * <li>the cached node must still be in the view tree 266 * <li>the cached node must still be able to take focus 267 * <li>the cache entry must not have expired 268 * </ul> 269 */ isValidEntry(@onNull FocusWindowHistory focusWindowHistory, long elapsedRealtime)270 boolean isValidEntry(@NonNull FocusWindowHistory focusWindowHistory, long elapsedRealtime) { 271 if (!focusWindowHistory.mNode.refresh() 272 || !Utils.canTakeFocus(focusWindowHistory.mNode)) { 273 return false; 274 } 275 276 switch (mCacheType) { 277 case CACHE_TYPE_NEVER_EXPIRE: 278 return true; 279 case CACHE_TYPE_EXPIRED_AFTER_SOME_TIME: 280 return elapsedRealtime - focusWindowHistory.mTimestamp < mExpirationTimeMs; 281 default: 282 return false; 283 } 284 } 285 286 /** 287 * Stores the given (window ID, node) pair, overwriting the existing pair with the given 288 * window ID, if any. 289 */ put(int windowId, @NonNull AccessibilityNodeInfo node, long elapsedRealtime)290 void put(int windowId, @NonNull AccessibilityNodeInfo node, long elapsedRealtime) { 291 if (mCacheType == CACHE_TYPE_DISABLED) { 292 return; 293 } 294 put(windowId, new FocusWindowHistory(copyNode(node), elapsedRealtime)); 295 } 296 297 /** 298 * Returns the most recently focused valid node or {@code null} if there are no valid 299 * nodes in the cache. The caller is responsible for recycling the result. 300 */ 301 @Nullable getMostRecentValidNode(long elapsedRealtime)302 AccessibilityNodeInfo getMostRecentValidNode(long elapsedRealtime) { 303 Map<Integer, FocusWindowHistory> snapshot = snapshot(); 304 List<FocusWindowHistory> focusWindowHistories = new ArrayList<>(snapshot.values()); 305 Collections.reverse(focusWindowHistories); 306 for (FocusWindowHistory focusWindowHistory : focusWindowHistories) { 307 if (isValidEntry(focusWindowHistory, elapsedRealtime)) { 308 return copyNode(focusWindowHistory.mNode); 309 } 310 } 311 return null; 312 } 313 314 @Override entryRemoved(boolean evicted, Integer windowId, FocusWindowHistory oldValue, FocusWindowHistory newValue)315 protected void entryRemoved(boolean evicted, Integer windowId, FocusWindowHistory oldValue, 316 FocusWindowHistory newValue) { 317 oldValue.recycle(); 318 } 319 } 320 RotaryCache(@acheType int focusHistoryCacheType, int focusHistoryCacheSize, int focusHistoryExpirationTimeMs, @CacheType int focusAreaHistoryCacheType, int focusAreaHistoryCacheSize, int focusAreaHistoryExpirationTimeMs, @CacheType int focusWindowCacheType, int focusWindowCacheSize, int focusWindowExpirationTimeMs)321 RotaryCache(@CacheType int focusHistoryCacheType, 322 int focusHistoryCacheSize, 323 int focusHistoryExpirationTimeMs, 324 @CacheType int focusAreaHistoryCacheType, 325 int focusAreaHistoryCacheSize, 326 int focusAreaHistoryExpirationTimeMs, 327 @CacheType int focusWindowCacheType, 328 int focusWindowCacheSize, 329 int focusWindowExpirationTimeMs) { 330 mFocusHistoryCache = new FocusHistoryCache( 331 focusHistoryCacheType, focusHistoryCacheSize, focusHistoryExpirationTimeMs); 332 mFocusAreaHistoryCache = new FocusAreaHistoryCache(focusAreaHistoryCacheType, 333 focusAreaHistoryCacheSize, focusAreaHistoryExpirationTimeMs); 334 mFocusWindowCache = new FocusWindowCache(focusWindowCacheType, focusWindowCacheSize, 335 focusWindowExpirationTimeMs); 336 } 337 338 /** 339 * Searches the cache to find the last focused node in the given {@code focusArea}. Returns the 340 * node, or null if there is nothing in the cache, the cache is stale, the view represented 341 * by the node is no longer in the view tree, or the node's state has changed so that it can't 342 * take focus any more. The caller is responsible for recycling the result. 343 */ getFocusedNode(@onNull AccessibilityNodeInfo focusArea, long elapsedRealtime)344 AccessibilityNodeInfo getFocusedNode(@NonNull AccessibilityNodeInfo focusArea, 345 long elapsedRealtime) { 346 if (mFocusHistoryCache.enabled()) { 347 FocusHistory focusHistory = mFocusHistoryCache.get(focusArea); 348 if (mFocusHistoryCache.isValidFocusHistory(focusHistory, elapsedRealtime)) { 349 AccessibilityNodeInfo node = copyNode(focusHistory.node); 350 // Refresh the node in case the view represented by the node is no longer in the 351 // view tree, or the node's state (e.g., isFocused()) has changed. 352 AccessibilityNodeInfo refreshedNode = Utils.refreshNode(node); 353 354 // If the node's state has changed so that it can't take focus any more, return 355 // null. 356 if (refreshedNode != null && !Utils.canTakeFocus(refreshedNode)) { 357 Utils.recycleNode(refreshedNode); 358 refreshedNode = null; 359 } 360 return refreshedNode; 361 } 362 } 363 return null; 364 } 365 366 /** 367 * Caches the last focused node by focus area. A copy of {@code focusArea} and {@code 368 * focusedNode} will be saved in the cache. 369 */ saveFocusedNode(@onNull AccessibilityNodeInfo focusArea, @NonNull AccessibilityNodeInfo focusedNode, long elapsedRealtime)370 void saveFocusedNode(@NonNull AccessibilityNodeInfo focusArea, 371 @NonNull AccessibilityNodeInfo focusedNode, long elapsedRealtime) { 372 if (mFocusHistoryCache.enabled()) { 373 mFocusHistoryCache.put( 374 copyNode(focusArea), new FocusHistory(copyNode(focusedNode), elapsedRealtime)); 375 } 376 } 377 378 /** 379 * Searches the cache to find the target focus area for a nudge in a given {@code direction} 380 * from a given focus area. Returns the focus area, or null if there is nothing in the cache, 381 * the cache is stale, or the view represented by the node is no longer in the view tree. 382 * The caller is responsible for recycling the result. 383 */ getTargetFocusArea(@onNull AccessibilityNodeInfo sourceFocusArea, int direction, long elapsedRealtime)384 AccessibilityNodeInfo getTargetFocusArea(@NonNull AccessibilityNodeInfo sourceFocusArea, 385 int direction, long elapsedRealtime) { 386 if (mFocusAreaHistoryCache.enabled()) { 387 FocusHistory focusHistory = 388 mFocusAreaHistoryCache.get(new FocusAreaHistory(sourceFocusArea, direction)); 389 if (mFocusAreaHistoryCache.isValidFocusHistory(focusHistory, elapsedRealtime)) { 390 AccessibilityNodeInfo focusArea = copyNode(focusHistory.node); 391 // Refresh the node in case the view represented by the node is no longer in the 392 // view tree. 393 return Utils.refreshNode(focusArea); 394 } 395 } 396 return null; 397 } 398 399 /** 400 * Caches the focus area nudge history. A copy of {@code sourceFocusArea} and {@code 401 * targetFocusArea} will be saved in the cache. 402 */ saveTargetFocusArea(@onNull AccessibilityNodeInfo sourceFocusArea, @NonNull AccessibilityNodeInfo targetFocusArea, int direction, long elapsedRealtime)403 void saveTargetFocusArea(@NonNull AccessibilityNodeInfo sourceFocusArea, 404 @NonNull AccessibilityNodeInfo targetFocusArea, int direction, long elapsedRealtime) { 405 if (mFocusAreaHistoryCache.enabled()) { 406 int oppositeDirection = getOppositeDirection(direction); 407 mFocusAreaHistoryCache 408 .put(new FocusAreaHistory(copyNode(targetFocusArea), oppositeDirection), 409 new FocusHistory(copyNode(sourceFocusArea), elapsedRealtime)); 410 } 411 } 412 413 /** Clears the focus area nudge history cache. */ clearFocusAreaHistory()414 void clearFocusAreaHistory() { 415 if (mFocusAreaHistoryCache.enabled()) { 416 mFocusAreaHistoryCache.evictAll(); 417 } 418 } 419 420 @VisibleForTesting isFocusAreaHistoryCacheEmpty()421 boolean isFocusAreaHistoryCacheEmpty() { 422 return mFocusAreaHistoryCache.size() == 0; 423 } 424 425 /** Saves the most recently focused node within a window. */ saveWindowFocus(@onNull AccessibilityNodeInfo focusedNode, long elapsedRealtime)426 void saveWindowFocus(@NonNull AccessibilityNodeInfo focusedNode, long elapsedRealtime) { 427 mFocusWindowCache.put(focusedNode.getWindowId(), focusedNode, elapsedRealtime); 428 } 429 430 /** 431 * Returns the most recently focused valid node or {@code null} if there are no valid nodes 432 * saved by {@link #saveWindowFocus}. The caller is responsible for recycling the result. 433 */ 434 @Nullable getMostRecentFocus(long elapsedRealtime)435 AccessibilityNodeInfo getMostRecentFocus(long elapsedRealtime) { 436 return mFocusWindowCache.getMostRecentValidNode(elapsedRealtime); 437 } 438 439 /** Returns the direction opposite the given {@code direction} */ 440 @VisibleForTesting getOppositeDirection(int direction)441 static int getOppositeDirection(int direction) { 442 switch (direction) { 443 case View.FOCUS_LEFT: 444 return View.FOCUS_RIGHT; 445 case View.FOCUS_RIGHT: 446 return View.FOCUS_LEFT; 447 case View.FOCUS_UP: 448 return View.FOCUS_DOWN; 449 case View.FOCUS_DOWN: 450 return View.FOCUS_UP; 451 } 452 throw new IllegalArgumentException("direction must be " 453 + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT."); 454 } 455 456 /** Sets a mock {@link NodeCopier} instance for testing. */ 457 @VisibleForTesting setNodeCopier(@onNull NodeCopier nodeCopier)458 void setNodeCopier(@NonNull NodeCopier nodeCopier) { 459 mNodeCopier = nodeCopier; 460 } 461 copyNode(@ullable AccessibilityNodeInfo node)462 private AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) { 463 return mNodeCopier.copy(node); 464 } 465 } 466