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