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
17package com.android.tv.dialog;
18
19import android.animation.Animator;
20import android.animation.AnimatorInflater;
21import android.app.Dialog;
22import android.content.Context;
23import android.content.DialogInterface;
24import android.content.SharedPreferences;
25import android.content.res.Resources;
26import android.os.Bundle;
27import android.os.Handler;
28import android.preference.PreferenceManager;
29import android.text.TextUtils;
30import android.text.format.DateUtils;
31import android.util.AttributeSet;
32import android.util.Log;
33import android.util.TypedValue;
34import android.view.KeyEvent;
35import android.view.LayoutInflater;
36import android.view.View;
37import android.view.ViewGroup;
38import android.widget.FrameLayout;
39import android.widget.OverScroller;
40import android.widget.TextView;
41import android.widget.Toast;
42
43import com.android.tv.settings.R;
44
45public 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 {
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
101    public abstract long getPinDisabledUntil();
102    public abstract void setPinDisabledUntil(long retryDisableTimeout);
103    public abstract void setPin(String pin);
104    public abstract boolean isPinCorrect(String pin);
105    public abstract boolean isPinSet();
106
107    public PinDialogFragment(int type, ResultListener listener) {
108        mType = type;
109        mListener = listener;
110        mRetCode = PIN_DIALOG_RESULT_FAIL;
111    }
112
113    @Override
114    public void onCreate(Bundle savedInstanceState) {
115        super.onCreate(savedInstanceState);
116        setStyle(STYLE_NO_TITLE, 0);
117        mDisablePinUntil = getPinDisabledUntil();
118    }
119
120    @Override
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
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
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