1 /*
2  * Copyright (C) 2015 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.tv.guide;
18 
19 import android.content.Context;
20 import android.graphics.Rect;
21 import androidx.recyclerview.widget.LinearLayoutManager;
22 import android.util.AttributeSet;
23 import android.util.Log;
24 import android.util.Range;
25 import android.view.View;
26 import android.view.ViewTreeObserver.OnGlobalLayoutListener;
27 import com.android.tv.data.api.Channel;
28 import com.android.tv.guide.ProgramManager.TableEntry;
29 import com.android.tv.util.Utils;
30 import java.util.concurrent.TimeUnit;
31 
32 public class ProgramRow extends TimelineGridView {
33     private static final String TAG = "ProgramRow";
34     private static final boolean DEBUG = false;
35 
36     private static final long ONE_HOUR_MILLIS = TimeUnit.HOURS.toMillis(1);
37     private static final long HALF_HOUR_MILLIS = ONE_HOUR_MILLIS / 2;
38 
39     private ProgramGuide mProgramGuide;
40     private ProgramManager mProgramManager;
41 
42     private boolean mKeepFocusToCurrentProgram;
43     private ChildFocusListener mChildFocusListener;
44 
45     interface ChildFocusListener {
46         /**
47          * Is called after focus is moved. Caller should check if old and new focuses are listener's
48          * children. See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}.
49          */
onChildFocus(View oldFocus, View newFocus)50         void onChildFocus(View oldFocus, View newFocus);
51     }
52 
53     /** Used only for debugging. */
54     private Channel mChannel;
55 
56     private final OnGlobalLayoutListener mLayoutListener =
57             new OnGlobalLayoutListener() {
58                 @Override
59                 public void onGlobalLayout() {
60                     getViewTreeObserver().removeOnGlobalLayoutListener(this);
61                     updateChildVisibleArea();
62                 }
63             };
64 
ProgramRow(Context context)65     public ProgramRow(Context context) {
66         this(context, null);
67     }
68 
ProgramRow(Context context, AttributeSet attrs)69     public ProgramRow(Context context, AttributeSet attrs) {
70         this(context, attrs, 0);
71     }
72 
ProgramRow(Context context, AttributeSet attrs, int defStyle)73     public ProgramRow(Context context, AttributeSet attrs, int defStyle) {
74         super(context, attrs, defStyle);
75         ProgramRowAccessibilityDelegate rowAccessibilityDelegate =
76                 new ProgramRowAccessibilityDelegate(this);
77         this.setAccessibilityDelegateCompat(rowAccessibilityDelegate);
78     }
79 
80     /** Registers a listener focus events occurring on children to the {@code ProgramRow}. */
setChildFocusListener(ChildFocusListener childFocusListener)81     public void setChildFocusListener(ChildFocusListener childFocusListener) {
82         mChildFocusListener = childFocusListener;
83     }
84 
85     @Override
onViewAdded(View child)86     public void onViewAdded(View child) {
87         super.onViewAdded(child);
88         ProgramItemView itemView = (ProgramItemView) child;
89         if (getLeft() <= itemView.getRight() && itemView.getLeft() <= getRight()) {
90             itemView.updateVisibleArea();
91         }
92     }
93 
94     @Override
onScrolled(int dx, int dy)95     public void onScrolled(int dx, int dy) {
96         // Remove callback to prevent updateChildVisibleArea being called twice.
97         getViewTreeObserver().removeOnGlobalLayoutListener(mLayoutListener);
98         super.onScrolled(dx, dy);
99         if (DEBUG) {
100             Log.d(TAG, "onScrolled by " + dx);
101             Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + getChildCount());
102             Log.d(TAG, "ProgramRow {" + Utils.toRectString(this) + "}");
103         }
104         updateChildVisibleArea();
105     }
106 
107     /** Moves focus to the current program. */
focusCurrentProgram()108     public void focusCurrentProgram() {
109         View currentProgram = getCurrentProgramView();
110         if (currentProgram == null) {
111             currentProgram = getChildAt(0);
112         }
113         if (mChildFocusListener != null) {
114             mChildFocusListener.onChildFocus(null, currentProgram);
115         }
116     }
117 
118     // Call this API after RTL is resolved. (i.e. View is measured.)
isDirectionStart(int direction)119     private boolean isDirectionStart(int direction) {
120         return getLayoutDirection() == LAYOUT_DIRECTION_LTR
121                 ? direction == View.FOCUS_LEFT
122                 : direction == View.FOCUS_RIGHT;
123     }
124 
125     // Call this API after RTL is resolved. (i.e. View is measured.)
isDirectionEnd(int direction)126     private boolean isDirectionEnd(int direction) {
127         return getLayoutDirection() == LAYOUT_DIRECTION_LTR
128                 ? direction == View.FOCUS_RIGHT
129                 : direction == View.FOCUS_LEFT;
130     }
131 
132     // When Accessibility is enabled, this API will keep next node visible
focusSearchAccessibility(View focused, int direction)133     void focusSearchAccessibility(View focused, int direction) {
134         TableEntry focusedEntry = ((ProgramItemView) focused).getTableEntry();
135         long toMillis = mProgramManager.getToUtcMillis();
136 
137         if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
138             if (focusedEntry.entryEndUtcMillis >= toMillis) {
139                 scrollByTime(focusedEntry.entryEndUtcMillis - toMillis + HALF_HOUR_MILLIS);
140             }
141         }
142     }
143 
144     @Override
focusSearch(View focused, int direction)145     public View focusSearch(View focused, int direction) {
146         TableEntry focusedEntry = ((ProgramItemView) focused).getTableEntry();
147         long fromMillis = mProgramManager.getFromUtcMillis();
148         long toMillis = mProgramManager.getToUtcMillis();
149 
150         if (!mProgramGuide.isAccessibilityEnabled()
151                 && (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD)) {
152             if (focusedEntry.entryStartUtcMillis < fromMillis) {
153                 // The current entry starts outside of the view; Align or scroll to the left.
154                 scrollByTime(
155                         Math.max(-ONE_HOUR_MILLIS, focusedEntry.entryStartUtcMillis - fromMillis));
156                 return focused;
157             }
158         } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
159             if (focusedEntry.entryEndUtcMillis >= toMillis + ONE_HOUR_MILLIS) {
160                 // The current entry ends outside of the view; Scroll to the right.
161                 scrollByTime(ONE_HOUR_MILLIS);
162                 return focused;
163             }
164         }
165 
166         View target = super.focusSearch(focused, direction);
167         if (!(target instanceof ProgramItemView)) {
168             if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
169                 if (focusedEntry.entryEndUtcMillis != toMillis) {
170                     // The focused entry is the last entry; Align to the right edge.
171                     scrollByTime(focusedEntry.entryEndUtcMillis - toMillis);
172                     return focused;
173                 }
174             }
175             return target;
176         }
177 
178         TableEntry targetEntry = ((ProgramItemView) target).getTableEntry();
179 
180         if (isDirectionStart(direction) || direction == View.FOCUS_BACKWARD) {
181             if (mProgramGuide.isAccessibilityEnabled()) {
182                 scrollByTime(targetEntry.entryStartUtcMillis - fromMillis);
183             } else if (targetEntry.entryStartUtcMillis < fromMillis
184                     && targetEntry.entryEndUtcMillis < fromMillis + HALF_HOUR_MILLIS) {
185                 // The target entry starts outside the view; Align or scroll to the left.
186                 scrollByTime(
187                         Math.max(-ONE_HOUR_MILLIS, targetEntry.entryStartUtcMillis - fromMillis));
188             }
189         } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
190             if (targetEntry.entryStartUtcMillis > fromMillis + ONE_HOUR_MILLIS + HALF_HOUR_MILLIS) {
191                 // The target entry starts outside the view; Align or scroll to the right.
192                 scrollByTime(
193                         Math.min(
194                                 ONE_HOUR_MILLIS,
195                                 targetEntry.entryStartUtcMillis - fromMillis - ONE_HOUR_MILLIS));
196             }
197         }
198 
199         return target;
200     }
201 
scrollByTime(long timeToScroll)202     private void scrollByTime(long timeToScroll) {
203         if (DEBUG) {
204             Log.d(
205                     TAG,
206                     "scrollByTime(timeToScroll="
207                             + TimeUnit.MILLISECONDS.toMinutes(timeToScroll)
208                             + "min)");
209         }
210         mProgramManager.shiftTime(timeToScroll);
211     }
212 
213     @Override
onChildDetachedFromWindow(View child)214     public void onChildDetachedFromWindow(View child) {
215         if (child.hasFocus()) {
216             // Focused view can be detached only if it's updated.
217             TableEntry entry = ((ProgramItemView) child).getTableEntry();
218             if (entry.program == null) {
219                 // The focus is lost due to information loaded. Requests focus immediately.
220                 // (Because this entry is detached after real entries attached, we can't take
221                 // the below approach to resume focus on entry being attached.)
222                 post(
223                         new Runnable() {
224                             @Override
225                             public void run() {
226                                 requestFocus();
227                             }
228                         });
229             } else if (entry.isCurrentProgram()) {
230                 if (DEBUG) Log.d(TAG, "Keep focus to the current program");
231                 // Current program is visible in the guide.
232                 // Updated entries including current program's will be attached again soon
233                 // so give focus back in onChildAttachedToWindow().
234                 mKeepFocusToCurrentProgram = true;
235             }
236         }
237         super.onChildDetachedFromWindow(child);
238     }
239 
240     @Override
onChildAttachedToWindow(View child)241     public void onChildAttachedToWindow(View child) {
242         super.onChildAttachedToWindow(child);
243         if (mKeepFocusToCurrentProgram) {
244             TableEntry entry = ((ProgramItemView) child).getTableEntry();
245             if (entry.isCurrentProgram()) {
246                 mKeepFocusToCurrentProgram = false;
247                 post(
248                         new Runnable() {
249                             @Override
250                             public void run() {
251                                 requestFocus();
252                             }
253                         });
254             }
255         }
256     }
257 
258     @Override
onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)259     public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
260         ProgramGrid programGrid = mProgramGuide.getProgramGrid();
261 
262         // Give focus according to the previous focused range
263         Range<Integer> focusRange = programGrid.getFocusRange();
264         View nextFocus =
265                 GuideUtils.findNextFocusedProgram(
266                         this,
267                         focusRange.getLower(),
268                         focusRange.getUpper(),
269                         programGrid.isKeepCurrentProgramFocused());
270 
271         if (nextFocus != null) {
272             return nextFocus.requestFocus();
273         }
274 
275         if (DEBUG) Log.d(TAG, "onRequestFocusInDescendants");
276         boolean result = super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
277         if (!result) {
278             // The default focus search logic of LeanbackLibrary is sometimes failed.
279             // As a fallback solution, we request focus to the first focusable view.
280             for (int i = 0; i < getChildCount(); ++i) {
281                 View child = getChildAt(i);
282                 if (child.isShown() && child.hasFocusable()) {
283                     return child.requestFocus();
284                 }
285             }
286         }
287         return result;
288     }
289 
getCurrentProgramView()290     private View getCurrentProgramView() {
291         for (int i = 0; i < getChildCount(); ++i) {
292             TableEntry entry = ((ProgramItemView) getChildAt(i)).getTableEntry();
293             if (entry.isCurrentProgram()) {
294                 return getChildAt(i);
295             }
296         }
297         return null;
298     }
299 
setChannel(Channel channel)300     public void setChannel(Channel channel) {
301         mChannel = channel;
302     }
303 
304     /** Sets the instance of {@link ProgramGuide} */
setProgramGuide(ProgramGuide programGuide)305     public void setProgramGuide(ProgramGuide programGuide) {
306         mProgramGuide = programGuide;
307         mProgramManager = programGuide.getProgramManager();
308     }
309 
310     /** Resets the scroll with the initial offset {@code scrollOffset}. */
resetScroll(int scrollOffset)311     public void resetScroll(int scrollOffset) {
312         long startTime =
313                 GuideUtils.convertPixelToMillis(scrollOffset) + mProgramManager.getStartTime();
314         int position =
315                 mChannel == null
316                         ? -1
317                         : mProgramManager.getProgramIndexAtTime(mChannel.getId(), startTime);
318         if (position < 0) {
319             getLayoutManager().scrollToPosition(0);
320         } else {
321             TableEntry entry = mProgramManager.getTableEntry(mChannel.getId(), position);
322             int offset =
323                     GuideUtils.convertMillisToPixel(
324                                     mProgramManager.getStartTime(), entry.entryStartUtcMillis)
325                             - scrollOffset;
326             ((LinearLayoutManager) getLayoutManager()).scrollToPositionWithOffset(position, offset);
327             // Workaround to b/31598505. When a program's duration is too long,
328             // RecyclerView.onScrolled() will not be called after scrollToPositionWithOffset().
329             // Therefore we have to update children's visible areas by ourselves in this case.
330             // Since scrollToPositionWithOffset() will call requestLayout(), we can listen to this
331             // behavior to ensure program items' visible areas are correctly updated after layouts
332             // are adjusted, i.e., scrolling is over.
333             getViewTreeObserver().addOnGlobalLayoutListener(mLayoutListener);
334         }
335     }
336 
updateChildVisibleArea()337     private void updateChildVisibleArea() {
338         for (int i = 0; i < getChildCount(); ++i) {
339             ProgramItemView child = (ProgramItemView) getChildAt(i);
340             if (getLeft() < child.getRight() && child.getLeft() < getRight()) {
341                 child.updateVisibleArea();
342             }
343         }
344     }
345 }
346