1 /*
2  * Copyright (C) 2014 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.dialog;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorInflater;
21 import android.app.Dialog;
22 import android.content.Context;
23 import android.content.DialogInterface;
24 import android.content.SharedPreferences;
25 import android.content.res.Resources;
26 import android.os.Bundle;
27 import android.os.Handler;
28 import android.preference.PreferenceManager;
29 import android.text.TextUtils;
30 import android.text.format.DateUtils;
31 import android.util.AttributeSet;
32 import android.util.Log;
33 import android.util.TypedValue;
34 import android.view.KeyEvent;
35 import android.view.LayoutInflater;
36 import android.view.View;
37 import android.view.ViewGroup;
38 import android.widget.FrameLayout;
39 import android.widget.OverScroller;
40 import android.widget.TextView;
41 import android.widget.Toast;
42 
43 import com.android.tv.settings.R;
44 
45 public abstract class PinDialogFragment extends SafeDismissDialogFragment {
46     private static final String TAG = "PinDialogFragment";
47     private static boolean DEBUG = false;
48 
49     /**
50      * PIN code dialog for unlock channel
51      */
52     public static final int PIN_DIALOG_TYPE_UNLOCK_CHANNEL = 0;
53 
54     /**
55      * PIN code dialog for unlock content.
56      * Only difference between {@code PIN_DIALOG_TYPE_UNLOCK_CHANNEL} is it's title.
57      */
58     public static final int PIN_DIALOG_TYPE_UNLOCK_PROGRAM = 1;
59 
60     /**
61      * PIN code dialog for change parental control settings
62      */
63     public static final int PIN_DIALOG_TYPE_ENTER_PIN = 2;
64 
65     /**
66      * PIN code dialog for set new PIN
67      */
68     public static final int PIN_DIALOG_TYPE_NEW_PIN = 3;
69 
70     // PIN code dialog for checking old PIN. This is intenal only.
71     private static final int PIN_DIALOG_TYPE_OLD_PIN = 4;
72 
73     private static final int PIN_DIALOG_RESULT_SUCCESS = 0;
74     private static final int PIN_DIALOG_RESULT_FAIL = 1;
75 
76     private static final int MAX_WRONG_PIN_COUNT = 5;
77     private static final int DISABLE_PIN_DURATION_MILLIS = 60 * 1000; // 1 minute
78 
79     public interface ResultListener {
done(boolean success)80         void done(boolean success);
81     }
82 
83     public static final String DIALOG_TAG = PinDialogFragment.class.getName();
84 
85     private static final int NUMBER_PICKERS_RES_ID[] = {
86             R.id.first, R.id.second, R.id.third, R.id.fourth };
87 
88     private int mType;
89     private final ResultListener mListener;
90     private int mRetCode;
91 
92     private TextView mWrongPinView;
93     private View mEnterPinView;
94     private TextView mTitleView;
95     private PinNumberPicker[] mPickers;
96     private String mPrevPin;
97     private int mWrongPinCount;
98     private long mDisablePinUntil;
99     private final Handler mHandler = new Handler();
100 
getPinDisabledUntil()101     public abstract long getPinDisabledUntil();
setPinDisabledUntil(long retryDisableTimeout)102     public abstract void setPinDisabledUntil(long retryDisableTimeout);
setPin(String pin)103     public abstract void setPin(String pin);
isPinCorrect(String pin)104     public abstract boolean isPinCorrect(String pin);
isPinSet()105     public abstract boolean isPinSet();
106 
PinDialogFragment(int type, ResultListener listener)107     public PinDialogFragment(int type, ResultListener listener) {
108         mType = type;
109         mListener = listener;
110         mRetCode = PIN_DIALOG_RESULT_FAIL;
111     }
112 
113     @Override
onCreate(Bundle savedInstanceState)114     public void onCreate(Bundle savedInstanceState) {
115         super.onCreate(savedInstanceState);
116         setStyle(STYLE_NO_TITLE, 0);
117         mDisablePinUntil = getPinDisabledUntil();
118     }
119 
120     @Override
onCreateDialog(Bundle savedInstanceState)121     public Dialog onCreateDialog(Bundle savedInstanceState) {
122         Dialog dlg = super.onCreateDialog(savedInstanceState);
123         dlg.getWindow().getAttributes().windowAnimations = R.style.pin_dialog_animation;
124         PinNumberPicker.loadResources(dlg.getContext());
125         return dlg;
126     }
127 
128     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)129     public View onCreateView(LayoutInflater inflater, ViewGroup container,
130             Bundle savedInstanceState) {
131         final View v = inflater.inflate(R.layout.pin_dialog, container, false);
132 
133         mWrongPinView = (TextView) v.findViewById(R.id.wrong_pin);
134         mEnterPinView = v.findViewById(R.id.enter_pin);
135         mTitleView = (TextView) mEnterPinView.findViewById(R.id.title);
136         if (!isPinSet()) {
137             // If PIN isn't set, user should set a PIN.
138             // Successfully setting a new set is considered as entering correct PIN.
139             mType = PIN_DIALOG_TYPE_NEW_PIN;
140         }
141         switch (mType) {
142             case PIN_DIALOG_TYPE_UNLOCK_CHANNEL:
143                 mTitleView.setText(R.string.pin_enter_unlock_channel);
144                 break;
145             case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
146                 mTitleView.setText(R.string.pin_enter_unlock_program);
147                 break;
148             case PIN_DIALOG_TYPE_ENTER_PIN:
149                 mTitleView.setText(R.string.pin_enter_pin);
150                 break;
151             case PIN_DIALOG_TYPE_NEW_PIN:
152                 if (!isPinSet()) {
153                     mTitleView.setText(R.string.pin_enter_new_pin);
154                 } else {
155                     mTitleView.setText(R.string.pin_enter_old_pin);
156                     mType = PIN_DIALOG_TYPE_OLD_PIN;
157                 }
158         }
159 
160         mPickers = new PinNumberPicker[NUMBER_PICKERS_RES_ID.length];
161         for (int i = 0; i < NUMBER_PICKERS_RES_ID.length; i++) {
162             mPickers[i] = (PinNumberPicker) v.findViewById(NUMBER_PICKERS_RES_ID[i]);
163             mPickers[i].setValueRange(0, 9);
164             mPickers[i].setPinDialogFragment(this);
165             mPickers[i].updateFocus();
166         }
167         for (int i = 0; i < NUMBER_PICKERS_RES_ID.length - 1; i++) {
168             mPickers[i].setNextNumberPicker(mPickers[i + 1]);
169         }
170 
171         if (mType != PIN_DIALOG_TYPE_NEW_PIN) {
172             updateWrongPin();
173         }
174         return v;
175     }
176 
177     private final Runnable mUpdateEnterPinRunnable = new Runnable() {
178         @Override
179         public void run() {
180             updateWrongPin();
181         }
182     };
183 
updateWrongPin()184     private void updateWrongPin() {
185         if (getActivity() == null) {
186             // The activity is already detached. No need to update.
187             mHandler.removeCallbacks(null);
188             return;
189         }
190 
191         boolean enabled = (mDisablePinUntil - System.currentTimeMillis()) / 1000 < 1;
192         if (enabled) {
193             mWrongPinView.setVisibility(View.INVISIBLE);
194             mEnterPinView.setVisibility(View.VISIBLE);
195             mWrongPinCount = 0;
196         } else {
197             mEnterPinView.setVisibility(View.INVISIBLE);
198             mWrongPinView.setVisibility(View.VISIBLE);
199             mWrongPinView.setText(getResources().getString(R.string.pin_enter_wrong,
200                     DateUtils.getRelativeTimeSpanString(mDisablePinUntil,
201                             System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS)));
202             mHandler.postDelayed(mUpdateEnterPinRunnable, 1000);
203         }
204     }
205 
206     private void exit(int retCode) {
207         mRetCode = retCode;
208         dismiss();
209     }
210 
211     @Override
212     public void onDismiss(DialogInterface dialog) {
213         super.onDismiss(dialog);
214         if (DEBUG) Log.d(TAG, "onDismiss: mRetCode=" + mRetCode);
215         if (mListener != null) {
216             mListener.done(mRetCode == PIN_DIALOG_RESULT_SUCCESS);
217         }
218     }
219 
220     private void handleWrongPin() {
221         if (++mWrongPinCount >= MAX_WRONG_PIN_COUNT) {
222             mDisablePinUntil = System.currentTimeMillis() + DISABLE_PIN_DURATION_MILLIS;
223             setPinDisabledUntil(mDisablePinUntil);
224             updateWrongPin();
225         } else {
226             showToast(R.string.pin_toast_wrong);
227         }
228     }
229 
230     private void showToast(int resId) {
231         Toast.makeText(getActivity(), resId, Toast.LENGTH_SHORT).show();
232     }
233 
234     private void done(String pin) {
235         if (DEBUG) Log.d(TAG, "done: mType=" + mType + " pin=" + pin);
236         switch (mType) {
237             case PIN_DIALOG_TYPE_UNLOCK_CHANNEL:
238             case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
239             case PIN_DIALOG_TYPE_ENTER_PIN:
240                 // TODO: Implement limited number of retrials and timeout logic.
241                 if (!isPinSet() || isPinCorrect(pin)) {
242                     exit(PIN_DIALOG_RESULT_SUCCESS);
243                 } else {
244                     resetPinInput();
245                     handleWrongPin();
246                 }
247                 break;
248             case PIN_DIALOG_TYPE_NEW_PIN:
249                 resetPinInput();
250                 if (mPrevPin == null) {
251                     mPrevPin = pin;
252                     mTitleView.setText(R.string.pin_enter_again);
253                 } else {
254                     if (pin.equals(mPrevPin)) {
255                         setPin(pin);
256                         exit(PIN_DIALOG_RESULT_SUCCESS);
257                     } else {
258                         mTitleView.setText(R.string.pin_enter_new_pin);
259                         mPrevPin = null;
260                         showToast(R.string.pin_toast_not_match);
261                     }
262                 }
263                 break;
264             case PIN_DIALOG_TYPE_OLD_PIN:
265                 if (isPinCorrect(pin)) {
266                     mType = PIN_DIALOG_TYPE_NEW_PIN;
267                     resetPinInput();
268                     mTitleView.setText(R.string.pin_enter_new_pin);
269                 } else {
270                     handleWrongPin();
271                 }
272                 break;
273         }
274     }
275 
276     public int getType() {
277         return mType;
278     }
279 
280     private String getPinInput() {
281         String result = "";
282         try {
283             for (PinNumberPicker pnp : mPickers) {
284                 pnp.updateText();
285                 result += pnp.getValue();
286             }
287         } catch (IllegalStateException e) {
288             result = "";
289         }
290         return result;
291     }
292 
293     private void resetPinInput() {
294         for (PinNumberPicker pnp : mPickers) {
295             pnp.setValueRange(0, 9);
296         }
297         mPickers[0].requestFocus();
298     }
299 
300     public static final class PinNumberPicker extends FrameLayout {
301         private static final int NUMBER_VIEWS_RES_ID[] = {
302             R.id.previous2_number,
303             R.id.previous_number,
304             R.id.current_number,
305             R.id.next_number,
306             R.id.next2_number };
307         private static final int CURRENT_NUMBER_VIEW_INDEX = 2;
308 
309         private static Animator sFocusedNumberEnterAnimator;
310         private static Animator sFocusedNumberExitAnimator;
311         private static Animator sAdjacentNumberEnterAnimator;
312         private static Animator sAdjacentNumberExitAnimator;
313 
314         private static float sAlphaForFocusedNumber;
315         private static float sAlphaForAdjacentNumber;
316 
317         private int mMinValue;
318         private int mMaxValue;
319         private int mCurrentValue;
320         private int mNextValue;
321         private int mNumberViewHeight;
322         private PinDialogFragment mDialog;
323         private PinNumberPicker mNextNumberPicker;
324         private boolean mCancelAnimation;
325 
326         private final View mNumberViewHolder;
327         private final View mBackgroundView;
328         private final TextView[] mNumberViews;
329         private final OverScroller mScroller;
330 
331         public PinNumberPicker(Context context) {
332             this(context, null);
333         }
334 
335         public PinNumberPicker(Context context, AttributeSet attrs) {
336             this(context, attrs, 0);
337         }
338 
339         public PinNumberPicker(Context context, AttributeSet attrs, int defStyleAttr) {
340             this(context, attrs, defStyleAttr, 0);
341         }
342 
343         public PinNumberPicker(Context context, AttributeSet attrs, int defStyleAttr,
344                 int defStyleRes) {
345             super(context, attrs, defStyleAttr, defStyleRes);
346             View view = inflate(context, R.layout.pin_number_picker, this);
347             mNumberViewHolder = view.findViewById(R.id.number_view_holder);
348             mBackgroundView = view.findViewById(R.id.focused_background);
349             mNumberViews = new TextView[NUMBER_VIEWS_RES_ID.length];
350             for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
351                 mNumberViews[i] = (TextView) view.findViewById(NUMBER_VIEWS_RES_ID[i]);
352             }
353             Resources resources = context.getResources();
354             mNumberViewHeight = resources.getDimensionPixelOffset(
355                     R.dimen.pin_number_picker_text_view_height);
356 
357             mScroller = new OverScroller(context);
358 
359             mNumberViewHolder.setOnFocusChangeListener(new OnFocusChangeListener() {
360                 @Override
361                 public void onFocusChange(View v, boolean hasFocus) {
362                     updateFocus();
363                 }
364             });
365 
366             mNumberViewHolder.setOnKeyListener(new OnKeyListener() {
367                 @Override
368                 public boolean onKey(View v, int keyCode, KeyEvent event) {
369                     if (event.getAction() == KeyEvent.ACTION_DOWN) {
370                         switch (keyCode) {
371                             case KeyEvent.KEYCODE_DPAD_UP:
372                             case KeyEvent.KEYCODE_DPAD_DOWN: {
373                                 if (!mScroller.isFinished() || mCancelAnimation) {
374                                     endScrollAnimation();
375                                 }
376                                 if (mScroller.isFinished() || mCancelAnimation) {
377                                     mCancelAnimation = false;
378                                     if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
379                                         mNextValue = adjustValueInValidRange(mCurrentValue + 1);
380                                         startScrollAnimation(true);
381                                         mScroller.startScroll(0, 0, 0, mNumberViewHeight,
382                                                 getResources().getInteger(
383                                                         R.integer.pin_number_scroll_duration));
384                                     } else {
385                                         mNextValue = adjustValueInValidRange(mCurrentValue - 1);
386                                         startScrollAnimation(false);
387                                         mScroller.startScroll(0, 0, 0, -mNumberViewHeight,
388                                                 getResources().getInteger(
389                                                         R.integer.pin_number_scroll_duration));
390                                     }
391                                     updateText();
392                                     invalidate();
393                                 }
394                                 return true;
395                             }
396                         }
397                     } else if (event.getAction() == KeyEvent.ACTION_UP) {
398                         switch (keyCode) {
399                             case KeyEvent.KEYCODE_DPAD_UP:
400                             case KeyEvent.KEYCODE_DPAD_DOWN: {
401                                 mCancelAnimation = true;
402                                 return true;
403                             }
404                         }
405                     }
406                     return false;
407                 }
408             });
409             mNumberViewHolder.setScrollY(mNumberViewHeight);
410         }
411 
412         static void loadResources(Context context) {
413             if (sFocusedNumberEnterAnimator == null) {
414                 TypedValue outValue = new TypedValue();
415                 context.getResources().getValue(
416                         R.float_type.pin_alpha_for_focused_number, outValue, true);
417                 sAlphaForFocusedNumber = outValue.getFloat();
418                 context.getResources().getValue(
419                         R.float_type.pin_alpha_for_adjacent_number, outValue, true);
420                 sAlphaForAdjacentNumber = outValue.getFloat();
421 
422                 sFocusedNumberEnterAnimator = AnimatorInflater.loadAnimator(context,
423                         R.animator.pin_focused_number_enter);
424                 sFocusedNumberExitAnimator = AnimatorInflater.loadAnimator(context,
425                         R.animator.pin_focused_number_exit);
426                 sAdjacentNumberEnterAnimator = AnimatorInflater.loadAnimator(context,
427                         R.animator.pin_adjacent_number_enter);
428                 sAdjacentNumberExitAnimator = AnimatorInflater.loadAnimator(context,
429                         R.animator.pin_adjacent_number_exit);
430             }
431         }
432 
433         @Override
434         public void computeScroll() {
435             super.computeScroll();
436             if (mScroller.computeScrollOffset()) {
437                 mNumberViewHolder.setScrollY(mScroller.getCurrY() + mNumberViewHeight);
438                 updateText();
439                 invalidate();
440             } else if (mCurrentValue != mNextValue) {
441                 mCurrentValue = mNextValue;
442             }
443         }
444 
445         @Override
446         public boolean dispatchKeyEvent(KeyEvent event) {
447             if (event.getAction() == KeyEvent.ACTION_UP) {
448                 int keyCode = event.getKeyCode();
449                 if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {
450                     setNextValue(keyCode - KeyEvent.KEYCODE_0);
451                 } else if (keyCode != KeyEvent.KEYCODE_DPAD_CENTER
452                         && keyCode != KeyEvent.KEYCODE_ENTER) {
453                     return super.dispatchKeyEvent(event);
454                 }
455                 if (mNextNumberPicker == null) {
456                     String pin = mDialog.getPinInput();
457                     if (!TextUtils.isEmpty(pin)) {
458                         mDialog.done(pin);
459                     }
460                 } else {
461                     mNextNumberPicker.requestFocus();
462                 }
463                 return true;
464             }
465             return super.dispatchKeyEvent(event);
466         }
467 
468         @Override
469         public void setEnabled(boolean enabled) {
470             super.setEnabled(enabled);
471             mNumberViewHolder.setFocusable(enabled);
472             for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
473                 mNumberViews[i].setEnabled(enabled);
474             }
475         }
476 
477         void startScrollAnimation(boolean scrollUp) {
478             if (scrollUp) {
479                 sAdjacentNumberExitAnimator.setTarget(mNumberViews[1]);
480                 sFocusedNumberExitAnimator.setTarget(mNumberViews[2]);
481                 sFocusedNumberEnterAnimator.setTarget(mNumberViews[3]);
482                 sAdjacentNumberEnterAnimator.setTarget(mNumberViews[4]);
483             } else {
484                 sAdjacentNumberEnterAnimator.setTarget(mNumberViews[0]);
485                 sFocusedNumberEnterAnimator.setTarget(mNumberViews[1]);
486                 sFocusedNumberExitAnimator.setTarget(mNumberViews[2]);
487                 sAdjacentNumberExitAnimator.setTarget(mNumberViews[3]);
488             }
489             sAdjacentNumberExitAnimator.start();
490             sFocusedNumberExitAnimator.start();
491             sFocusedNumberEnterAnimator.start();
492             sAdjacentNumberEnterAnimator.start();
493         }
494 
495         void endScrollAnimation() {
496             sAdjacentNumberExitAnimator.end();
497             sFocusedNumberExitAnimator.end();
498             sFocusedNumberEnterAnimator.end();
499             sAdjacentNumberEnterAnimator.end();
500             mCurrentValue = mNextValue;
501             mNumberViews[1].setAlpha(sAlphaForAdjacentNumber);
502             mNumberViews[2].setAlpha(sAlphaForFocusedNumber);
503             mNumberViews[3].setAlpha(sAlphaForAdjacentNumber);
504         }
505 
506         void setValueRange(int min, int max) {
507             if (min > max) {
508                 throw new IllegalArgumentException(
509                         "The min value should be greater than or equal to the max value");
510             }
511             mMinValue = min;
512             mMaxValue = max;
513             mNextValue = mCurrentValue = mMinValue - 1;
514             clearText();
515             mNumberViews[CURRENT_NUMBER_VIEW_INDEX].setText("—");
516         }
517 
518         void setPinDialogFragment(PinDialogFragment dlg) {
519             mDialog = dlg;
520         }
521 
522         void setNextNumberPicker(PinNumberPicker picker) {
523             mNextNumberPicker = picker;
524         }
525 
526         int getValue() {
527             if (mCurrentValue < mMinValue || mCurrentValue > mMaxValue) {
528                 throw new IllegalStateException("Value is not set");
529             }
530             return mCurrentValue;
531         }
532 
533         // Will take effect when the focus is updated.
534         void setNextValue(int value) {
535             if (value < mMinValue || value > mMaxValue) {
536                 throw new IllegalStateException("Value is not set");
537             }
538             mNextValue = adjustValueInValidRange(value);
539         }
540 
541         void updateFocus() {
542             endScrollAnimation();
543             if (mNumberViewHolder.isFocused()) {
544                 mBackgroundView.setVisibility(View.VISIBLE);
545                 updateText();
546             } else {
547                 mBackgroundView.setVisibility(View.GONE);
548                 if (!mScroller.isFinished()) {
549                     mCurrentValue = mNextValue;
550                     mScroller.abortAnimation();
551                 }
552                 clearText();
553                 mNumberViewHolder.setScrollY(mNumberViewHeight);
554             }
555         }
556 
557         private void clearText() {
558             for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
559                 if (i != CURRENT_NUMBER_VIEW_INDEX) {
560                     mNumberViews[i].setText("");
561                 } else if (mCurrentValue >= mMinValue && mCurrentValue <= mMaxValue) {
562                     mNumberViews[i].setText(String.valueOf(mCurrentValue));
563                 }
564             }
565         }
566 
567         private void updateText() {
568             if (mNumberViewHolder.isFocused()) {
569                 if (mCurrentValue < mMinValue || mCurrentValue > mMaxValue) {
570                     mNextValue = mCurrentValue = mMinValue;
571                 }
572                 int value = adjustValueInValidRange(mCurrentValue - CURRENT_NUMBER_VIEW_INDEX);
573                 for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
574                     mNumberViews[i].setText(String.valueOf(adjustValueInValidRange(value)));
575                     value = adjustValueInValidRange(value + 1);
576                 }
577             }
578         }
579 
580         private int adjustValueInValidRange(int value) {
581             int interval = mMaxValue - mMinValue + 1;
582             if (value < mMinValue - interval || value > mMaxValue + interval) {
583                 throw new IllegalArgumentException("The value( " + value
584                         + ") is too small or too big to adjust");
585             }
586             return (value < mMinValue) ? value + interval
587                     : (value > mMaxValue) ? value - interval : value;
588         }
589     }
590 }
591