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.ui;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.ValueAnimator;
22 import android.content.Context;
23 import android.content.res.Resources;
24 import android.support.annotation.Nullable;
25 import android.util.AttributeSet;
26 import android.util.Log;
27 import android.view.KeyEvent;
28 import android.view.LayoutInflater;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.view.animation.AnimationUtils;
32 import android.view.animation.Interpolator;
33 import android.widget.AdapterView;
34 import android.widget.BaseAdapter;
35 import android.widget.LinearLayout;
36 import android.widget.ListView;
37 import android.widget.TextView;
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.SoftPreconditions;
44 import com.android.tv.common.util.DurationTimer;
45 import com.android.tv.data.ChannelNumber;
46 import com.android.tv.data.api.Channel;
47 
48 import java.util.ArrayList;
49 import java.util.List;
50 
51 public class KeypadChannelSwitchView extends LinearLayout
52         implements TvTransitionManager.TransitionLayout {
53     private static final String TAG = "KeypadChannelSwitchView";
54 
55     private static final int MAX_CHANNEL_NUMBER_DIGIT = 5;
56     private static final int MAX_MINOR_CHANNEL_NUMBER_DIGIT = 3;
57     private static final int MAX_CHANNEL_ITEM = 8;
58     private static final String CHANNEL_DELIMITERS_REGEX = "[-\\.\\s]";
59     public static final String SCREEN_NAME = "Channel switch";
60 
61     private final MainActivity mMainActivity;
62     private final Tracker mTracker;
63     private final DurationTimer mViewDurationTimer = new DurationTimer();
64     private boolean mNavigated = false;
65     @Nullable // Once mChannels is set to null it should not be used again.
66     private List<Channel> mChannels;
67     private TextView mChannelNumberView;
68     private ListView mChannelItemListView;
69     private final ChannelNumber mTypedChannelNumber = new ChannelNumber();
70     private final ArrayList<Channel> mChannelCandidates = new ArrayList<>();
71     protected final ChannelItemAdapter mAdapter = new ChannelItemAdapter();
72     private final LayoutInflater mLayoutInflater;
73     private Channel mSelectedChannel;
74 
75     private final Runnable mHideRunnable =
76             new Runnable() {
77                 @Override
78                 public void run() {
79                     mCurrentHeight = 0;
80                     if (mSelectedChannel != null) {
81                         mMainActivity.tuneToChannel(mSelectedChannel);
82                         mTracker.sendChannelNumberItemChosenByTimeout();
83                     } else {
84                         mMainActivity
85                                 .getOverlayManager()
86                                 .hideOverlays(
87                                         TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_DIALOG
88                                                 | TvOverlayManager
89                                                         .FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS
90                                                 | TvOverlayManager
91                                                         .FLAG_HIDE_OVERLAYS_KEEP_PROGRAM_GUIDE
92                                                 | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_MENU
93                                                 | TvOverlayManager
94                                                         .FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT);
95                     }
96                 }
97             };
98     private final long mShowDurationMillis;
99     private final long mRippleAnimDurationMillis;
100     private final int mBaseViewHeight;
101     private final int mItemHeight;
102     private final int mResizeAnimDuration;
103     private Animator mResizeAnimator;
104     private final Interpolator mResizeInterpolator;
105     // NOTE: getHeight() will be updated after layout() is called. mCurrentHeight is needed for
106     // getting the latest updated value of the view height before layout().
107     private int mCurrentHeight;
108 
KeypadChannelSwitchView(Context context)109     public KeypadChannelSwitchView(Context context) {
110         this(context, null, 0);
111     }
112 
KeypadChannelSwitchView(Context context, AttributeSet attrs)113     public KeypadChannelSwitchView(Context context, AttributeSet attrs) {
114         this(context, attrs, 0);
115     }
116 
KeypadChannelSwitchView(Context context, AttributeSet attrs, int defStyleAttr)117     public KeypadChannelSwitchView(Context context, AttributeSet attrs, int defStyleAttr) {
118         super(context, attrs, defStyleAttr);
119 
120         mMainActivity = (MainActivity) context;
121         mTracker = TvSingletons.getSingletons(context).getTracker();
122         Resources resources = getResources();
123         mLayoutInflater = LayoutInflater.from(context);
124         mShowDurationMillis = resources.getInteger(R.integer.keypad_channel_switch_show_duration);
125         mRippleAnimDurationMillis =
126                 resources.getInteger(R.integer.keypad_channel_switch_ripple_anim_duration);
127         mBaseViewHeight =
128                 resources.getDimensionPixelSize(R.dimen.keypad_channel_switch_base_height);
129         mItemHeight = resources.getDimensionPixelSize(R.dimen.keypad_channel_switch_item_height);
130         mResizeAnimDuration = resources.getInteger(R.integer.keypad_channel_switch_anim_duration);
131         mResizeInterpolator =
132                 AnimationUtils.loadInterpolator(context, android.R.interpolator.linear_out_slow_in);
133     }
134 
135     @Override
onFinishInflate()136     protected void onFinishInflate() {
137         super.onFinishInflate();
138         mChannelNumberView = (TextView) findViewById(R.id.channel_number);
139         mChannelItemListView = (ListView) findViewById(R.id.channel_list);
140         mChannelItemListView.setAdapter(mAdapter);
141         mChannelItemListView.setOnItemClickListener(
142                 new AdapterView.OnItemClickListener() {
143                     @Override
144                     public void onItemClick(
145                             AdapterView<?> parent, View view, int position, long id) {
146                         if (position >= mAdapter.getCount()) {
147                             // It can happen during closing.
148                             return;
149                         }
150                         mChannelItemListView.setFocusable(false);
151                         final Channel channel = ((Channel) mAdapter.getItem(position));
152                         postDelayed(
153                                 () -> {
154                                     mChannelItemListView.setFocusable(true);
155                                     mMainActivity.tuneToChannel(channel);
156                                     mTracker.sendChannelNumberItemClicked();
157                                 },
158                                 mRippleAnimDurationMillis);
159                     }
160                 });
161         mChannelItemListView.setOnItemSelectedListener(
162                 new AdapterView.OnItemSelectedListener() {
163                     @Override
164                     public void onItemSelected(
165                             AdapterView<?> parent, View view, int position, long id) {
166                         if (position >= mAdapter.getCount()) {
167                             // It can happen during closing.
168                             mSelectedChannel = null;
169                         } else {
170                             mSelectedChannel = (Channel) mAdapter.getItem(position);
171                         }
172                         if (position != 0 && !mNavigated) {
173                             mNavigated = true;
174                             mTracker.sendChannelInputNavigated();
175                         }
176                     }
177 
178                     @Override
179                     public void onNothingSelected(AdapterView<?> parent) {
180                         mSelectedChannel = null;
181                     }
182                 });
183     }
184 
185     @Override
dispatchKeyEvent(KeyEvent event)186     public boolean dispatchKeyEvent(KeyEvent event) {
187         scheduleHide();
188         return super.dispatchKeyEvent(event);
189     }
190 
191     @Override
onKeyUp(int keyCode, KeyEvent event)192     public boolean onKeyUp(int keyCode, KeyEvent event) {
193         SoftPreconditions.checkNotNull(mChannels, TAG, "mChannels");
194         if (isChannelNumberKey(keyCode)) {
195             onNumberKeyUp(keyCode - KeyEvent.KEYCODE_0);
196             return true;
197         }
198         if (ChannelNumber.isChannelNumberDelimiterKey(keyCode)) {
199             onDelimiterKeyUp();
200             return true;
201         }
202         return super.onKeyUp(keyCode, event);
203     }
204 
205     @Override
onEnterAction(boolean fromEmptyScene)206     public void onEnterAction(boolean fromEmptyScene) {
207         reset();
208         if (fromEmptyScene) {
209             ViewUtils.setTransitionAlpha(mChannelItemListView, 1f);
210         }
211         mNavigated = false;
212         mViewDurationTimer.start();
213         mTracker.sendShowChannelSwitch();
214         mTracker.sendScreenView(SCREEN_NAME);
215         updateView();
216         scheduleHide();
217     }
218 
219     @Override
onExitAction()220     public void onExitAction() {
221         mCurrentHeight = 0;
222         mTracker.sendHideChannelSwitch(mViewDurationTimer.reset());
223         cancelHide();
224     }
225 
scheduleHide()226     private void scheduleHide() {
227         cancelHide();
228         postDelayed(mHideRunnable, mShowDurationMillis);
229     }
230 
cancelHide()231     private void cancelHide() {
232         removeCallbacks(mHideRunnable);
233     }
234 
reset()235     private void reset() {
236         mTypedChannelNumber.reset();
237         mSelectedChannel = null;
238         mChannelCandidates.clear();
239         mAdapter.notifyDataSetChanged();
240     }
241 
setChannels(@ullable List<Channel> channels)242     public void setChannels(@Nullable List<Channel> channels) {
243         mChannels = channels;
244     }
245 
isChannelNumberKey(int keyCode)246     public static boolean isChannelNumberKey(int keyCode) {
247         return keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9;
248     }
249 
onNumberKeyUp(int num)250     public void onNumberKeyUp(int num) {
251         // Reset typed channel number in some cases.
252         if (mTypedChannelNumber.majorNumber == null) {
253             Log.i(TAG, "Channel number reset because majorNumber is null");
254             mTypedChannelNumber.reset();
255         } else if (!mTypedChannelNumber.hasDelimiter
256                 && mTypedChannelNumber.majorNumber.length() >= MAX_CHANNEL_NUMBER_DIGIT) {
257             Log.i(
258                     TAG,
259                     "Channel number reset because majorNumber.length = "
260                             + mTypedChannelNumber.majorNumber.length()
261                             + " >= MAX_CHANNEL_NUMBER_DIGIT = "
262                             + MAX_CHANNEL_NUMBER_DIGIT);
263             mTypedChannelNumber.reset();
264         } else if (mTypedChannelNumber.hasDelimiter
265                 && mTypedChannelNumber.minorNumber != null
266                 && mTypedChannelNumber.minorNumber.length() >= MAX_MINOR_CHANNEL_NUMBER_DIGIT) {
267             Log.i(
268                     TAG,
269                     "Channel number reset because minorNumber.length = "
270                             + mTypedChannelNumber.minorNumber.length()
271                             + " >= MAX_MINOR_CHANNEL_NUMBER_DIGIT = "
272                             + MAX_MINOR_CHANNEL_NUMBER_DIGIT);
273             mTypedChannelNumber.reset();
274         }
275 
276         if (!mTypedChannelNumber.hasDelimiter) {
277             mTypedChannelNumber.majorNumber += String.valueOf(num);
278         } else {
279             mTypedChannelNumber.minorNumber += String.valueOf(num);
280         }
281         mTracker.sendChannelNumberInput();
282         updateView();
283     }
284 
onDelimiterKeyUp()285     private void onDelimiterKeyUp() {
286         if (mTypedChannelNumber.hasDelimiter || mTypedChannelNumber.majorNumber.length() == 0) {
287             return;
288         }
289         mTypedChannelNumber.hasDelimiter = true;
290         mTracker.sendChannelNumberInput();
291         updateView();
292     }
293 
updateView()294     private void updateView() {
295         mChannelNumberView.setText(mTypedChannelNumber.toString() + "_");
296         mChannelCandidates.clear();
297         ArrayList<Channel> secondaryChannelCandidates = new ArrayList<>();
298         for (Channel channel : mChannels) {
299             ChannelNumber chNumber = ChannelNumber.parseChannelNumber(channel.getDisplayNumber());
300             if (chNumber == null) {
301                 Log.i(
302                         TAG,
303                         "Malformed channel number (name="
304                                 + channel.getDisplayName()
305                                 + ", number="
306                                 + channel.getDisplayNumber()
307                                 + ")");
308                 continue;
309             }
310             if (matchChannelNumber(mTypedChannelNumber, chNumber)) {
311                 mChannelCandidates.add(channel);
312             } else if (!mTypedChannelNumber.hasDelimiter) {
313                 // Even if a user doesn't type '-', we need to match the typed number to not only
314                 // the major number but also the minor number. For example, when a user types '111'
315                 // without delimiter, it should be matched to '111', '1-11' and '11-1'.
316                 if (channel.getDisplayNumber()
317                         .replaceAll(CHANNEL_DELIMITERS_REGEX, "")
318                         .startsWith(mTypedChannelNumber.majorNumber)) {
319                     secondaryChannelCandidates.add(channel);
320                 }
321             }
322         }
323         mChannelCandidates.addAll(secondaryChannelCandidates);
324         mAdapter.notifyDataSetChanged();
325         if (mAdapter.getCount() > 0) {
326             mChannelItemListView.requestFocus();
327             mChannelItemListView.setSelection(0);
328             mSelectedChannel = mChannelCandidates.get(0);
329         }
330 
331         updateViewHeight();
332     }
333 
updateViewHeight()334     private void updateViewHeight() {
335         int itemListHeight = mItemHeight * Math.min(MAX_CHANNEL_ITEM, mAdapter.getCount());
336         int targetHeight = mBaseViewHeight + itemListHeight;
337         if (mResizeAnimator != null) {
338             mResizeAnimator.cancel();
339             mResizeAnimator = null;
340         }
341 
342         if (mCurrentHeight == 0) {
343             // Do not add the resize animation when the banner has not been shown before.
344             mCurrentHeight = targetHeight;
345             setViewHeight(this, targetHeight);
346         } else if (mCurrentHeight != targetHeight) {
347             mResizeAnimator = createResizeAnimator(targetHeight);
348             mResizeAnimator.start();
349         }
350     }
351 
createResizeAnimator(int targetHeight)352     private Animator createResizeAnimator(int targetHeight) {
353         ValueAnimator animator = ValueAnimator.ofInt(mCurrentHeight, targetHeight);
354         animator.addUpdateListener(
355                 new ValueAnimator.AnimatorUpdateListener() {
356                     @Override
357                     public void onAnimationUpdate(ValueAnimator animation) {
358                         int value = (Integer) animation.getAnimatedValue();
359                         setViewHeight(KeypadChannelSwitchView.this, value);
360                         mCurrentHeight = value;
361                     }
362                 });
363         animator.setDuration(mResizeAnimDuration);
364         animator.addListener(
365                 new AnimatorListenerAdapter() {
366                     @Override
367                     public void onAnimationEnd(Animator animator) {
368                         mResizeAnimator = null;
369                     }
370                 });
371         animator.setInterpolator(mResizeInterpolator);
372         return animator;
373     }
374 
setViewHeight(View view, int height)375     private void setViewHeight(View view, int height) {
376         ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
377         if (height != layoutParams.height) {
378             layoutParams.height = height;
379             view.setLayoutParams(layoutParams);
380         }
381     }
382 
matchChannelNumber(ChannelNumber typedChNumber, ChannelNumber chNumber)383     private static boolean matchChannelNumber(ChannelNumber typedChNumber, ChannelNumber chNumber) {
384         if (!chNumber.majorNumber.equals(typedChNumber.majorNumber)) {
385             return false;
386         }
387         if (typedChNumber.hasDelimiter) {
388             if (!chNumber.hasDelimiter) {
389                 return false;
390             }
391             if (!chNumber.minorNumber.startsWith(typedChNumber.minorNumber)) {
392                 return false;
393             }
394         }
395         return true;
396     }
397 
398     class ChannelItemAdapter extends BaseAdapter {
399         @Override
getCount()400         public int getCount() {
401             return mChannelCandidates.size();
402         }
403 
404         @Override
getItem(int position)405         public Object getItem(int position) {
406             return mChannelCandidates.get(position);
407         }
408 
409         @Override
getItemId(int position)410         public long getItemId(int position) {
411             return position;
412         }
413 
414         @Override
getView(int position, View convertView, ViewGroup parent)415         public View getView(int position, View convertView, ViewGroup parent) {
416             final Channel channel = mChannelCandidates.get(position);
417             View v = convertView;
418             if (v == null) {
419                 v = mLayoutInflater.inflate(R.layout.keypad_channel_switch_item, parent, false);
420             }
421 
422             TextView channelNumberView = (TextView) v.findViewById(R.id.number);
423             channelNumberView.setText(channel.getDisplayNumber());
424 
425             TextView channelNameView = (TextView) v.findViewById(R.id.name);
426             channelNameView.setText(channel.getDisplayName());
427             return v;
428         }
429     }
430 }
431