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.annotation.SuppressLint;
20 import android.content.Context;
21 import android.content.res.ColorStateList;
22 import android.content.res.Resources;
23 import android.graphics.drawable.Drawable;
24 import android.graphics.drawable.LayerDrawable;
25 import android.graphics.drawable.StateListDrawable;
26 import android.os.Build;
27 import android.os.Handler;
28 import android.text.SpannableStringBuilder;
29 import android.text.Spanned;
30 import android.text.TextUtils;
31 import android.text.style.TextAppearanceSpan;
32 import android.util.AttributeSet;
33 import android.util.Log;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.widget.TextView;
37 import android.widget.Toast;
38 
39 import com.android.tv.MainActivity;
40 import com.android.tv.R;
41 import com.android.tv.TvSingletons;
42 import com.android.tv.analytics.Tracker;
43 import com.android.tv.common.feature.CommonFeatures;
44 import com.android.tv.common.flags.DvrFlags;
45 import com.android.tv.common.util.Clock;
46 import com.android.tv.data.ChannelDataManager;
47 import com.android.tv.data.api.Channel;
48 import com.android.tv.data.api.Program;
49 import com.android.tv.dvr.DvrManager;
50 import com.android.tv.dvr.data.ScheduledRecording;
51 import com.android.tv.dvr.ui.DvrUiHelper;
52 import com.android.tv.guide.ProgramManager.TableEntry;
53 import com.android.tv.util.ToastUtils;
54 import com.android.tv.util.Utils;
55 
56 import dagger.android.HasAndroidInjector;
57 
58 import java.lang.reflect.InvocationTargetException;
59 import java.util.concurrent.TimeUnit;
60 
61 import javax.inject.Inject;
62 
63 public class ProgramItemView extends TextView {
64     private static final String TAG = "ProgramItemView";
65 
66     private static final long FOCUS_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1);
67     private static final int MAX_PROGRESS = 10000; // From android.widget.ProgressBar.MAX_VALUE
68 
69     // State indicating the focused program is the current program
70     private static final int[] STATE_CURRENT_PROGRAM = {R.attr.state_current_program};
71 
72     // Workaround state in order to not use too much texture memory for RippleDrawable
73     private static final int[] STATE_TOO_WIDE = {R.attr.state_program_too_wide};
74 
75     private static int sVisibleThreshold;
76     private static int sItemPadding;
77     private static int sCompoundDrawablePadding;
78     private static TextAppearanceSpan sProgramTitleStyle;
79     private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
80     private static TextAppearanceSpan sEpisodeTitleStyle;
81     private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
82 
83     private final DvrManager mDvrManager;
84     @Inject Clock mClock;
85     @Inject ChannelDataManager mChannelDataManager;
86     @Inject DvrFlags mDvrFlags;
87     private ProgramGuide mProgramGuide;
88     private TableEntry mTableEntry;
89     private int mMaxWidthForRipple;
90     private int mTextWidth;
91 
92     // If set this flag disables requests to re-layout the parent view as a result of changing
93     // this view, improving performance. This also prevents the parent view to lose child focus
94     // as a result of the re-layout (see b/21378855).
95     private boolean mPreventParentRelayout;
96 
97     private static final View.OnClickListener ON_CLICKED =
98             new View.OnClickListener() {
99                 @Override
100                 public void onClick(final View view) {
101                     TableEntry entry = ((ProgramItemView) view).mTableEntry;
102                     Clock clock = ((ProgramItemView) view).mClock;
103                     DvrFlags dvrFlags = ((ProgramItemView) view).mDvrFlags;
104                     if (entry == null) {
105                         // do nothing
106                         return;
107                     }
108                     TvSingletons singletons = TvSingletons.getSingletons(view.getContext());
109                     Tracker tracker = singletons.getTracker();
110                     tracker.sendEpgItemClicked();
111                     final MainActivity tvActivity = (MainActivity) view.getContext();
112                     final Channel channel =
113                             tvActivity.getChannelDataManager().getChannel(entry.channelId);
114                     if (entry.isCurrentProgram()) {
115                         view.postDelayed(
116                                 () -> {
117                                     tvActivity.tuneToChannel(channel);
118                                     tvActivity.hideOverlaysForTune();
119                                 },
120                                 entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple
121                                         ? 0
122                                         : view.getResources()
123                                                 .getInteger(
124                                                         R.integer
125                                                                 .program_guide_ripple_anim_duration));
126                     } else if (entry.program != null
127                             && CommonFeatures.DVR.isEnabled(view.getContext())) {
128                         DvrManager dvrManager = singletons.getDvrManager();
129                         if (entry.entryStartUtcMillis > clock.currentTimeMillis()
130                                 && dvrManager.isProgramRecordable(entry.program)) {
131                             if (entry.scheduledRecording == null) {
132                                 if (!entry.program.isEpisodic() &&
133                                         dvrFlags.startEarlyEndLateEnabled()) {
134                                     DvrUiHelper.startRecordingSettingsActivity(view.getContext(),
135                                             entry.program);
136                                 } else {
137                                     DvrUiHelper.checkStorageStatusAndShowErrorMessage(
138                                             tvActivity,
139                                             channel.getInputId(),
140                                             () ->
141                                                     DvrUiHelper.requestRecordingFutureProgram(
142                                                             tvActivity, entry.program, false));
143                                 }
144                             } else {
145                                 dvrManager.removeScheduledRecording(entry.scheduledRecording);
146                                 String msg =
147                                         view.getResources()
148                                                 .getString(
149                                                         R.string.dvr_schedules_deletion_info,
150                                                         entry.program.getTitle());
151                                 ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
152                             }
153                         } else {
154                             ToastUtils.show(
155                                     view.getContext(),
156                                     view.getResources()
157                                             .getString(R.string.dvr_msg_cannot_record_program),
158                                     Toast.LENGTH_SHORT);
159                         }
160                     }
161                 }
162             };
163 
164     private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
165             new View.OnFocusChangeListener() {
166                 @Override
167                 public void onFocusChange(View view, boolean hasFocus) {
168                     if (hasFocus) {
169                         ((ProgramItemView) view).mUpdateFocus.run();
170                     } else {
171                         Handler handler = view.getHandler();
172                         if (handler != null) {
173                             handler.removeCallbacks(((ProgramItemView) view).mUpdateFocus);
174                         }
175                     }
176                 }
177             };
178 
179     private final Runnable mUpdateFocus =
180             new Runnable() {
181                 @Override
182                 public void run() {
183                     refreshDrawableState();
184                     TableEntry entry = mTableEntry;
185                     if (entry == null) {
186                         // do nothing
187                         return;
188                     }
189                     if (entry.isCurrentProgram()) {
190                         Drawable background = getBackground();
191                         if (!mProgramGuide.isActive() || mProgramGuide.isRunningAnimation()) {
192                             // If program guide is not active or is during showing/hiding,
193                             // the animation is unnecessary, skip it.
194                             background.jumpToCurrentState();
195                         }
196                         int progress =
197                                 getProgress(
198                                         mClock, entry.entryStartUtcMillis, entry.entryEndUtcMillis);
199                         setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress);
200                     }
201                     if (getHandler() != null) {
202                         getHandler()
203                                 .postAtTime(
204                                         this,
205                                         Utils.ceilTime(
206                                                 mClock.uptimeMillis(), FOCUS_UPDATE_FREQUENCY));
207                     }
208                 }
209             };
210 
ProgramItemView(Context context)211     public ProgramItemView(Context context) {
212         this(context, null);
213     }
214 
ProgramItemView(Context context, AttributeSet attrs)215     public ProgramItemView(Context context, AttributeSet attrs) {
216         this(context, attrs, 0);
217     }
218 
ProgramItemView(Context context, AttributeSet attrs, int defStyle)219     public ProgramItemView(Context context, AttributeSet attrs, int defStyle) {
220         super(context, attrs, defStyle);
221         ((HasAndroidInjector) context).androidInjector().inject(this);
222         setOnClickListener(ON_CLICKED);
223         setOnFocusChangeListener(ON_FOCUS_CHANGED);
224         TvSingletons singletons = TvSingletons.getSingletons(getContext());
225         mDvrManager = singletons.getDvrManager();
226     }
227 
initIfNeeded()228     private void initIfNeeded() {
229         if (sVisibleThreshold != 0) {
230             return;
231         }
232         Resources res = getContext().getResources();
233 
234         sVisibleThreshold =
235                 res.getDimensionPixelOffset(R.dimen.program_guide_table_item_visible_threshold);
236 
237         sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
238         sCompoundDrawablePadding =
239                 res.getDimensionPixelOffset(
240                         R.dimen.program_guide_table_item_compound_drawable_padding);
241 
242         ColorStateList programTitleColor =
243                 ColorStateList.valueOf(
244                         res.getColor(
245                                 R.color.program_guide_table_item_program_title_text_color, null));
246         ColorStateList grayedOutProgramTitleColor =
247                 res.getColorStateList(
248                         R.color.program_guide_table_item_grayed_out_program_text_color, null);
249         ColorStateList episodeTitleColor =
250                 ColorStateList.valueOf(
251                         res.getColor(
252                                 R.color.program_guide_table_item_program_episode_title_text_color,
253                                 null));
254         ColorStateList grayedOutEpisodeTitleColor =
255                 ColorStateList.valueOf(
256                         res.getColor(
257                                 R.color
258                                         .program_guide_table_item_grayed_out_program_episode_title_text_color,
259                                 null));
260         int programTitleSize =
261                 res.getDimensionPixelSize(R.dimen.program_guide_table_item_program_title_font_size);
262         int episodeTitleSize =
263                 res.getDimensionPixelSize(
264                         R.dimen.program_guide_table_item_program_episode_title_font_size);
265 
266         sProgramTitleStyle =
267                 new TextAppearanceSpan(null, 0, programTitleSize, programTitleColor, null);
268         sGrayedOutProgramTitleStyle =
269                 new TextAppearanceSpan(null, 0, programTitleSize, grayedOutProgramTitleColor, null);
270         sEpisodeTitleStyle =
271                 new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, null);
272         sGrayedOutEpisodeTitleStyle =
273                 new TextAppearanceSpan(null, 0, episodeTitleSize, grayedOutEpisodeTitleColor, null);
274     }
275 
276     @Override
onFinishInflate()277     protected void onFinishInflate() {
278         super.onFinishInflate();
279         initIfNeeded();
280     }
281 
282     @Override
onCreateDrawableState(int extraSpace)283     protected int[] onCreateDrawableState(int extraSpace) {
284         if (mTableEntry != null) {
285             int[] states =
286                     super.onCreateDrawableState(
287                             extraSpace + STATE_CURRENT_PROGRAM.length + STATE_TOO_WIDE.length);
288             if (mTableEntry.isCurrentProgram()) {
289                 mergeDrawableStates(states, STATE_CURRENT_PROGRAM);
290             }
291             if (mTableEntry.getWidth() > mMaxWidthForRipple) {
292                 mergeDrawableStates(states, STATE_TOO_WIDE);
293             }
294             return states;
295         }
296         return super.onCreateDrawableState(extraSpace);
297     }
298 
getTableEntry()299     public TableEntry getTableEntry() {
300         return mTableEntry;
301     }
302 
303     @SuppressLint("SwitchIntDef")
setValues( ProgramGuide programGuide, TableEntry entry, int selectedGenreId, long fromUtcMillis, long toUtcMillis, String gapTitle)304     public void setValues(
305             ProgramGuide programGuide,
306             TableEntry entry,
307             int selectedGenreId,
308             long fromUtcMillis,
309             long toUtcMillis,
310             String gapTitle) {
311         mProgramGuide = programGuide;
312         mTableEntry = entry;
313 
314         ViewGroup.LayoutParams layoutParams = getLayoutParams();
315         if (layoutParams != null) {
316             // There is no layoutParams in the tests so we skip this
317             layoutParams.width = entry.getWidth();
318             setLayoutParams(layoutParams);
319         }
320         String title = mTableEntry.program != null ? mTableEntry.program.getTitle() : null;
321         if (mTableEntry.isGap()) {
322             title = gapTitle;
323         }
324         if (TextUtils.isEmpty(title)) {
325             title = getResources().getString(R.string.program_title_for_no_information);
326         }
327         updateText(selectedGenreId, title);
328         updateIcons();
329         updateContentDescription(title);
330         measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
331         mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
332         // Maximum width for us to use a ripple
333         mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis);
334     }
335 
isEntryWideEnough()336     private boolean isEntryWideEnough() {
337         return mTableEntry != null && mTableEntry.getWidth() >= sVisibleThreshold;
338     }
339 
updateText(int selectedGenreId, String title)340     private void updateText(int selectedGenreId, String title) {
341         if (!isEntryWideEnough()) {
342             setText(null);
343             return;
344         }
345 
346         String episode =
347                 mTableEntry.program != null
348                         ? mTableEntry.program.getEpisodeDisplayTitle(getContext())
349                         : null;
350 
351         TextAppearanceSpan titleStyle = sGrayedOutProgramTitleStyle;
352         TextAppearanceSpan episodeStyle = sGrayedOutEpisodeTitleStyle;
353         if (mTableEntry.isGap()) {
354 
355             episode = null;
356         } else if (mTableEntry.hasGenre(selectedGenreId)) {
357             titleStyle = sProgramTitleStyle;
358             episodeStyle = sEpisodeTitleStyle;
359         }
360         SpannableStringBuilder description = new SpannableStringBuilder();
361         description.append(title);
362         if (!TextUtils.isEmpty(episode)) {
363             description.append('\n');
364 
365             // Add a 'zero-width joiner'/ZWJ in order to ensure we have the same line height for
366             // all lines. This is a non-printing character so it will not change the horizontal
367             // spacing however it will affect the line height. As we ensure the ZWJ has the same
368             // text style as the title it will make sure the line height is consistent.
369             description.append('\u200D');
370 
371             int middle = description.length();
372             description.append(episode);
373 
374             description.setSpan(titleStyle, 0, middle, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
375             description.setSpan(
376                     episodeStyle, middle, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
377         } else {
378             description.setSpan(
379                     titleStyle, 0, description.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
380         }
381         setText(description);
382     }
383 
updateIcons()384     private void updateIcons() {
385         // Sets recording icons if needed.
386         int iconResId = 0;
387         if (isEntryWideEnough() && mTableEntry.scheduledRecording != null) {
388             if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
389                 iconResId = R.drawable.quantum_ic_warning_white_18;
390             } else {
391                 switch (mTableEntry.scheduledRecording.getState()) {
392                     case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
393                         iconResId = R.drawable.ic_scheduled_recording;
394                         break;
395                     case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
396                         iconResId = R.drawable.ic_recording_program;
397                         break;
398                     default:
399                         // leave the iconResId=0
400                 }
401             }
402         }
403         setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0);
404         setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0);
405     }
406 
updateContentDescription(String title)407     private void updateContentDescription(String title) {
408         // The content description includes extra information that is displayed on the detail view
409         Resources resources = getResources();
410         String description = title;
411         // TODO(b/73282818): only say channel name when the row changes
412         Channel channel = mChannelDataManager.getChannel(mTableEntry.channelId);
413         if (channel != null) {
414             description = channel.getDisplayNumber() + " " + description;
415         }
416         Program program = mTableEntry.program;
417         if (program != null) {
418             description += " " + program.getDurationString(getContext());
419             String episodeDescription = program.getEpisodeContentDescription(getContext());
420             if (!TextUtils.isEmpty(episodeDescription)) {
421                 description += " " + episodeDescription;
422             }
423         } else {
424             description +=
425                     " "
426                             + Utils.getDurationString(
427                                     getContext(),
428                                     mClock,
429                                     mTableEntry.entryStartUtcMillis,
430                                     mTableEntry.entryEndUtcMillis,
431                                     true);
432         }
433         if (mTableEntry.scheduledRecording != null) {
434             if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
435                 description +=
436                         " " + resources.getString(R.string.dvr_epg_program_recording_conflict);
437             } else {
438                 switch (mTableEntry.scheduledRecording.getState()) {
439                     case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
440                         description +=
441                                 " "
442                                         + resources.getString(
443                                                 R.string.dvr_epg_program_recording_scheduled);
444                         break;
445                     case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
446                         description +=
447                                 " "
448                                         + resources.getString(
449                                                 R.string.dvr_epg_program_recording_in_progress);
450                         break;
451                     default:
452                         // do nothing
453                 }
454             }
455         }
456         if (mTableEntry.isBlocked()) {
457             description += " " + resources.getString(R.string.program_guide_content_locked);
458         } else if (program != null) {
459             String programDescription = program.getDescription();
460             if (!TextUtils.isEmpty(programDescription)) {
461                 description += " " + programDescription;
462             }
463         }
464         setContentDescription(description);
465     }
466 
467     /** Update programItemView to handle alignments of text. */
updateVisibleArea()468     public void updateVisibleArea() {
469         View parentView = ((View) getParent());
470         if (parentView == null) {
471             return;
472         }
473         if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
474             layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight());
475         } else {
476             layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft());
477         }
478     }
479 
480     /**
481      * Layout title and episode according to visible area.
482      *
483      * <p>Here's the spec. 1. Don't show text if it's shorter than 48dp. 2. Try showing whole text
484      * in visible area by placing and wrapping text, but do not wrap text less than 30min. 3.
485      * Episode title is visible only if title isn't multi-line.
486      *
487      * @param startOffset Offset of the start position from the enclosing view's start position.
488      * @param endOffset Offset of the end position from the enclosing view's end position.
489      */
layoutVisibleArea(int startOffset, int endOffset)490     private void layoutVisibleArea(int startOffset, int endOffset) {
491         int width = mTableEntry.getWidth();
492         int startPadding = Math.max(0, startOffset);
493         int endPadding = Math.max(0, endOffset);
494         int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
495         if (startPadding > 0 && width - startPadding < minWidth) {
496             startPadding = Math.max(0, width - minWidth);
497         }
498         if (endPadding > 0 && width - endPadding < minWidth) {
499             endPadding = Math.max(0, width - minWidth);
500         }
501 
502         if (startPadding + sItemPadding != getPaddingStart()
503                 || endPadding + sItemPadding != getPaddingEnd()) {
504             mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
505             setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0);
506             mPreventParentRelayout = false;
507         }
508     }
509 
clearValues()510     public void clearValues() {
511         if (getHandler() != null) {
512             getHandler().removeCallbacks(mUpdateFocus);
513         }
514 
515         setTag(null);
516         mProgramGuide = null;
517         mTableEntry = null;
518     }
519 
getProgress(Clock clock, long start, long end)520     private static int getProgress(Clock clock, long start, long end) {
521         long currentTime = clock.currentTimeMillis();
522         if (currentTime <= start) {
523             return 0;
524         } else if (currentTime >= end) {
525             return MAX_PROGRESS;
526         }
527         return (int) (((currentTime - start) * MAX_PROGRESS) / (end - start));
528     }
529 
setProgress(Drawable drawable, int id, int progress)530     private static void setProgress(Drawable drawable, int id, int progress) {
531         if (drawable instanceof StateListDrawable) {
532             StateListDrawable stateDrawable = (StateListDrawable) drawable;
533             for (int i = 0; i < getStateCount(stateDrawable); ++i) {
534                 setProgress(getStateDrawable(stateDrawable, i), id, progress);
535             }
536         } else if (drawable instanceof LayerDrawable) {
537             LayerDrawable layerDrawable = (LayerDrawable) drawable;
538             for (int i = 0; i < layerDrawable.getNumberOfLayers(); ++i) {
539                 setProgress(layerDrawable.getDrawable(i), id, progress);
540                 if (layerDrawable.getId(i) == id) {
541                     layerDrawable.getDrawable(i).setLevel(progress);
542                 }
543             }
544         }
545     }
546 
getStateCount(StateListDrawable stateListDrawable)547     private static int getStateCount(StateListDrawable stateListDrawable) {
548         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
549             return stateListDrawable.getStateCount();
550         }
551         try {
552             Object stateCount =
553                     StateListDrawable.class
554                             .getDeclaredMethod("getStateCount")
555                             .invoke(stateListDrawable);
556             return (int) stateCount;
557         } catch (NoSuchMethodException
558                 | IllegalAccessException
559                 | IllegalArgumentException
560                 | InvocationTargetException e) {
561             Log.e(TAG, "Failed to call StateListDrawable.getStateCount()", e);
562             return 0;
563         }
564     }
565 
getStateDrawable(StateListDrawable stateListDrawable, int index)566     private static Drawable getStateDrawable(StateListDrawable stateListDrawable, int index) {
567         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
568             return stateListDrawable.getStateDrawable(index);
569         }
570         try {
571             Object drawable =
572                     StateListDrawable.class
573                             .getDeclaredMethod("getStateDrawable", Integer.TYPE)
574                             .invoke(stateListDrawable, index);
575             return (Drawable) drawable;
576         } catch (NoSuchMethodException
577                 | IllegalAccessException
578                 | IllegalArgumentException
579                 | InvocationTargetException e) {
580             Log.e(TAG, "Failed to call StateListDrawable.getStateDrawable(" + index + ")", e);
581             return null;
582         }
583     }
584 
585     @Override
requestLayout()586     public void requestLayout() {
587         if (mPreventParentRelayout) {
588             // Trivial layout, no need to tell parent.
589             forceLayout();
590         } else {
591             super.requestLayout();
592         }
593     }
594 }
595