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