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