blob: f4611b5cf32b6d801a1df35868da08d2b1a41bd5 [file] [log] [blame]
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tv.settings.dialog;
import android.animation.Animator;
import android.animation.AnimatorInflater;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.OverScroller;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.IntDef;
import androidx.fragment.app.Fragment;
import com.android.tv.settings.R;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.Consumer;
public abstract class PinDialogFragment extends SafeDismissDialogFragment
implements DialogInterface.OnDismissListener {
private static final String TAG = PinDialogFragment.class.getSimpleName();
private static final boolean DEBUG = false;
protected static final String ARG_TYPE = "type";
@Retention(RetentionPolicy.SOURCE)
@IntDef({PIN_DIALOG_TYPE_UNLOCK_CHANNEL,
PIN_DIALOG_TYPE_UNLOCK_PROGRAM,
PIN_DIALOG_TYPE_ENTER_PIN,
PIN_DIALOG_TYPE_NEW_PIN,
PIN_DIALOG_TYPE_OLD_PIN,
PIN_DIALOG_TYPE_DELETE_PIN})
public @interface PinDialogType {}
/**
* PIN code dialog for unlock channel
*/
public static final int PIN_DIALOG_TYPE_UNLOCK_CHANNEL = 0;
/**
* PIN code dialog for unlock content.
* Only difference between {@code PIN_DIALOG_TYPE_UNLOCK_CHANNEL} is it's title.
*/
public static final int PIN_DIALOG_TYPE_UNLOCK_PROGRAM = 1;
/**
* PIN code dialog for change parental control settings
*/
public static final int PIN_DIALOG_TYPE_ENTER_PIN = 2;
/**
* PIN code dialog for set new PIN
*/
public static final int PIN_DIALOG_TYPE_NEW_PIN = 3;
// PIN code dialog for checking old PIN. This is intenal only.
private static final int PIN_DIALOG_TYPE_OLD_PIN = 4;
/**
* PIN code dialog for deleting the PIN
*/
public static final int PIN_DIALOG_TYPE_DELETE_PIN = 5;
private static final int PIN_DIALOG_RESULT_SUCCESS = 0;
private static final int PIN_DIALOG_RESULT_FAIL = 1;
private static final int MAX_WRONG_PIN_COUNT = 5;
private static final int WRONG_PIN_REFRESH_DELAY = 1000;
private static final int DISABLE_PIN_DURATION_MILLIS = 60 * 1000; // 1 minute
public interface ResultListener {
void pinFragmentDone(int requestCode, boolean success);
}
public static final String DIALOG_TAG = PinDialogFragment.class.getName();
private static final int NUMBER_PICKERS_RES_ID[] = {
R.id.first, R.id.second, R.id.third, R.id.fourth };
private int mType;
private int mRetCode;
private TextView mWrongPinView;
private View mEnterPinView;
private TextView mTitleView;
private PinNumberPicker[] mPickers;
private String mOriginalPin;
private String mPrevPin;
private int mWrongPinCount;
private long mDisablePinUntil;
private boolean mIsPinSet;
private boolean mIsDispatched;
private final Handler mHandler = new Handler();
/**
* Get the bad PIN retry time
* @return Retry time
*/
public abstract long getPinDisabledUntil();
/**
* Set the bad PIN retry time
* @param retryDisableTimeout Retry time
*/
public abstract void setPinDisabledUntil(long retryDisableTimeout);
/**
* Set PIN password for the profile
* @param pin New PIN password
* @param consumer Will be called with the success result from setting the pin
*/
public abstract void setPin(String pin, String originalPin, Consumer<Boolean> consumer);
/**
* Delete PIN password for the profile
* @param oldPin Old PIN password (required)
* @param consumer Will be called with the success result from deleting the pin
*/
public abstract void deletePin(String oldPin, Consumer<Boolean> consumer);
/**
* Validate PIN password for the profile
* @param pin Password to check
* @param consumer Will be called with the result of the check
*/
public abstract void isPinCorrect(String pin, Consumer<Boolean> consumer);
/**
* Check if there is a PIN password set on the profile
* @param consumer Will be called with the result of the check
*/
public abstract void isPinSet(Consumer<Boolean> consumer);
public PinDialogFragment() {
mRetCode = PIN_DIALOG_RESULT_FAIL;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_TITLE, 0);
mDisablePinUntil = getPinDisabledUntil();
final Bundle args = getArguments();
if (!args.containsKey(ARG_TYPE)) {
throw new IllegalStateException("Fragment arguments must specify type");
}
mType = getArguments().getInt(ARG_TYPE);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Dialog dlg = super.onCreateDialog(savedInstanceState);
dlg.getWindow().getAttributes().windowAnimations = R.style.pin_dialog_animation;
PinNumberPicker.loadResources(dlg.getContext());
return dlg;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
final View v = inflater.inflate(R.layout.pin_dialog, container, false);
mWrongPinView = v.findViewById(R.id.wrong_pin);
mEnterPinView = v.findViewById(R.id.enter_pin);
if (mEnterPinView == null) {
throw new IllegalStateException("R.id.enter_pin missing!");
}
mTitleView = mEnterPinView.findViewById(R.id.title);
isPinSet(result -> dispatchOnIsPinSet(result, savedInstanceState, v));
return v;
}
@Override
public void onDismiss(DialogInterface dialog) {
super.onDismiss(dialog);
if (DEBUG) Log.d(TAG, "onDismiss: mRetCode=" + mRetCode);
boolean result = mRetCode == PIN_DIALOG_RESULT_SUCCESS;
Fragment f = getTargetFragment();
if (f instanceof ResultListener) {
((ResultListener) f).pinFragmentDone(getTargetRequestCode(), result);
} else if (getActivity() instanceof ResultListener) {
final ResultListener listener = (ResultListener) getActivity();
listener.pinFragmentDone(getTargetRequestCode(), result);
}
}
private void updateWrongPin() {
if (getActivity() == null) {
// The activity is already detached. No need to update.
mHandler.removeCallbacks(null);
return;
}
final long secondsLeft = (mDisablePinUntil - System.currentTimeMillis()) / 1000;
final boolean enabled = secondsLeft < 1;
if (enabled) {
mWrongPinView.setVisibility(View.GONE);
mEnterPinView.setVisibility(View.VISIBLE);
mWrongPinCount = 0;
} else {
mEnterPinView.setVisibility(View.GONE);
mWrongPinView.setVisibility(View.VISIBLE);
mWrongPinView.setText(getResources().getString(R.string.pin_enter_wrong_seconds,
secondsLeft));
mHandler.postDelayed(this::updateWrongPin, WRONG_PIN_REFRESH_DELAY);
}
}
private void exit(int retCode) {
mRetCode = retCode;
mIsDispatched = false;
dismiss();
}
private void handleWrongPin() {
if (++mWrongPinCount >= MAX_WRONG_PIN_COUNT) {
mDisablePinUntil = System.currentTimeMillis() + DISABLE_PIN_DURATION_MILLIS;
setPinDisabledUntil(mDisablePinUntil);
updateWrongPin();
} else {
showToast(R.string.pin_toast_wrong);
}
}
private void showToast(int resId) {
Toast.makeText(getActivity(), resId, Toast.LENGTH_SHORT).show();
}
private void done(String pin) {
if (DEBUG) Log.d(TAG, "done: mType=" + mType + " pin=" + pin);
if (mIsDispatched) {
// avoid re-triggering any of the dispatch methods if the user
// double clicks in the pin dialog
return;
}
switch (mType) {
case PIN_DIALOG_TYPE_UNLOCK_CHANNEL:
case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
case PIN_DIALOG_TYPE_ENTER_PIN:
dispatchOnPinEntered(pin);
break;
case PIN_DIALOG_TYPE_DELETE_PIN:
dispatchOnDeletePin(pin);
break;
case PIN_DIALOG_TYPE_NEW_PIN:
dispatchOnNewPinTyped(pin);
break;
case PIN_DIALOG_TYPE_OLD_PIN:
dispatchOnOldPinTyped(pin);
break;
}
}
private void dispatchOnPinEntered(String pin) {
isPinCorrect(pin, pinCorrect -> {
if (!mIsPinSet || pinCorrect) {
exit(PIN_DIALOG_RESULT_SUCCESS);
} else {
resetPinInput();
handleWrongPin();
}
});
}
private void dispatchOnDeletePin(String pin) {
isPinCorrect(pin, pinIsCorrect -> {
if (pinIsCorrect) {
mIsDispatched = true;
deletePin(pin, success -> {
exit(success ? PIN_DIALOG_RESULT_SUCCESS : PIN_DIALOG_RESULT_FAIL);
});
} else {
resetPinInput();
handleWrongPin();
}
});
}
private void dispatchOnNewPinTyped(String pin) {
if (mPrevPin == null) {
resetPinInput();
mPrevPin = pin;
mTitleView.setText(R.string.pin_enter_again);
} else {
if (pin.equals(mPrevPin)) {
mIsDispatched = true;
setPin(pin, mOriginalPin, success -> {
exit(PIN_DIALOG_RESULT_SUCCESS);
});
} else {
resetPinInput();
mTitleView.setText(R.string.pin_enter_new_pin);
mPrevPin = null;
showToast(R.string.pin_toast_not_match);
}
}
}
private void dispatchOnOldPinTyped(String pin) {
resetPinInput();
isPinCorrect(pin, pinIsCorrect -> {
if (isAdded()) {
if (pinIsCorrect) {
mOriginalPin = pin;
mType = PIN_DIALOG_TYPE_NEW_PIN;
mTitleView.setText(R.string.pin_enter_new_pin);
} else {
handleWrongPin();
}
}
});
}
public int getType() {
return mType;
}
private void dispatchOnIsPinSet(Boolean result, Bundle savedInstanceState, View v) {
mIsPinSet = result;
if (!mIsPinSet) {
// If PIN isn't set, user should set a PIN.
// Successfully setting a new set is considered as entering correct PIN.
mType = PIN_DIALOG_TYPE_NEW_PIN;
}
mEnterPinView.setVisibility(View.VISIBLE);
setDialogTitle();
setUpPinNumberPicker(savedInstanceState, v);
}
private void setDialogTitle() {
switch (mType) {
case PIN_DIALOG_TYPE_UNLOCK_CHANNEL:
mTitleView.setText(R.string.pin_enter_unlock_channel);
break;
case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
mTitleView.setText(R.string.pin_enter_unlock_program);
break;
case PIN_DIALOG_TYPE_ENTER_PIN:
case PIN_DIALOG_TYPE_DELETE_PIN:
mTitleView.setText(R.string.pin_enter_pin);
break;
case PIN_DIALOG_TYPE_NEW_PIN:
if (!mIsPinSet) {
mTitleView.setText(R.string.pin_enter_new_pin);
} else {
mTitleView.setText(R.string.pin_enter_old_pin);
mType = PIN_DIALOG_TYPE_OLD_PIN;
}
}
}
private void setUpPinNumberPicker(Bundle savedInstanceState, View v) {
if (mType != PIN_DIALOG_TYPE_NEW_PIN) {
updateWrongPin();
}
mPickers = new PinNumberPicker[NUMBER_PICKERS_RES_ID.length];
for (int i = 0; i < NUMBER_PICKERS_RES_ID.length; i++) {
mPickers[i] = v.findViewById(NUMBER_PICKERS_RES_ID[i]);
mPickers[i].setValueRange(0, 9);
mPickers[i].setPinDialogFragment(this);
mPickers[i].updateFocus();
}
for (int i = 0; i < NUMBER_PICKERS_RES_ID.length - 1; i++) {
mPickers[i].setNextNumberPicker(mPickers[i + 1]);
}
if (savedInstanceState == null) {
mPickers[0].requestFocus();
}
}
private String getPinInput() {
String result = "";
try {
for (PinNumberPicker pnp : mPickers) {
result += pnp.getValue();
}
} catch (IllegalStateException e) {
result = "";
}
return result;
}
private void resetPinInput() {
for (PinNumberPicker pnp : mPickers) {
pnp.setValueRange(0, 9);
}
mPickers[0].requestFocus();
}
public static final class PinNumberPicker extends FrameLayout {
private static final int NUMBER_VIEWS_RES_ID[] = {
R.id.previous2_number,
R.id.previous_number,
R.id.current_number,
R.id.next_number,
R.id.next2_number };
private static final int CURRENT_NUMBER_VIEW_INDEX = 2;
private static Animator sFocusedNumberEnterAnimator;
private static Animator sFocusedNumberExitAnimator;
private static Animator sAdjacentNumberEnterAnimator;
private static Animator sAdjacentNumberExitAnimator;
private static float sAlphaForFocusedNumber;
private static float sAlphaForAdjacentNumber;
private int mMinValue;
private int mMaxValue;
private int mCurrentValue;
private int mNextValue;
private final int mNumberViewHeight;
private PinDialogFragment mDialog;
private PinNumberPicker mNextNumberPicker;
private boolean mCancelAnimation;
private final View mNumberViewHolder;
private final View mBackgroundView;
private final TextView[] mNumberViews;
private final OverScroller mScroller;
public PinNumberPicker(Context context) {
this(context, null);
}
public PinNumberPicker(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PinNumberPicker(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public PinNumberPicker(Context context, AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
View view = inflate(context, R.layout.pin_number_picker, this);
mNumberViewHolder = view.findViewById(R.id.number_view_holder);
if (mNumberViewHolder == null) {
throw new IllegalStateException("R.id.number_view_holder missing!");
}
mBackgroundView = view.findViewById(R.id.focused_background);
mNumberViews = new TextView[NUMBER_VIEWS_RES_ID.length];
for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
mNumberViews[i] = view.findViewById(NUMBER_VIEWS_RES_ID[i]);
}
Resources resources = context.getResources();
mNumberViewHeight = resources.getDimensionPixelOffset(
R.dimen.pin_number_picker_text_view_height);
mScroller = new OverScroller(context);
mNumberViewHolder.setOnFocusChangeListener((v, hasFocus) -> updateFocus());
mNumberViewHolder.setOnKeyListener((v, keyCode, event) -> {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN: {
if (!mScroller.isFinished() || mCancelAnimation) {
endScrollAnimation();
}
if (mScroller.isFinished() || mCancelAnimation) {
mCancelAnimation = false;
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
mNextValue = adjustValueInValidRange(mCurrentValue + 1);
startScrollAnimation(true);
mScroller.startScroll(0, 0, 0, mNumberViewHeight,
getResources().getInteger(
R.integer.pin_number_scroll_duration));
} else {
mNextValue = adjustValueInValidRange(mCurrentValue - 1);
startScrollAnimation(false);
mScroller.startScroll(0, 0, 0, -mNumberViewHeight,
getResources().getInteger(
R.integer.pin_number_scroll_duration));
}
updateText();
invalidate();
}
return true;
}
}
} else if (event.getAction() == KeyEvent.ACTION_UP) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN: {
mCancelAnimation = true;
return true;
}
}
}
return false;
});
mNumberViewHolder.setScrollY(mNumberViewHeight);
}
static void loadResources(Context context) {
if (sFocusedNumberEnterAnimator == null) {
TypedValue outValue = new TypedValue();
context.getResources().getValue(
R.dimen.pin_alpha_for_focused_number, outValue, true);
sAlphaForFocusedNumber = outValue.getFloat();
context.getResources().getValue(
R.dimen.pin_alpha_for_adjacent_number, outValue, true);
sAlphaForAdjacentNumber = outValue.getFloat();
sFocusedNumberEnterAnimator = AnimatorInflater.loadAnimator(context,
R.animator.pin_focused_number_enter);
sFocusedNumberExitAnimator = AnimatorInflater.loadAnimator(context,
R.animator.pin_focused_number_exit);
sAdjacentNumberEnterAnimator = AnimatorInflater.loadAnimator(context,
R.animator.pin_adjacent_number_enter);
sAdjacentNumberExitAnimator = AnimatorInflater.loadAnimator(context,
R.animator.pin_adjacent_number_exit);
}
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
mNumberViewHolder.setScrollY(mScroller.getCurrY() + mNumberViewHeight);
updateText();
invalidate();
} else if (mCurrentValue != mNextValue) {
mCurrentValue = mNextValue;
}
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_UP) {
int keyCode = event.getKeyCode();
if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) {
jumpNextValue(keyCode - KeyEvent.KEYCODE_0);
} else if (keyCode != KeyEvent.KEYCODE_DPAD_CENTER
&& keyCode != KeyEvent.KEYCODE_ENTER) {
return super.dispatchKeyEvent(event);
}
if (mNextNumberPicker == null) {
String pin = mDialog.getPinInput();
if (!TextUtils.isEmpty(pin)) {
mDialog.done(pin);
}
} else {
mNextNumberPicker.requestFocus();
}
return true;
}
return super.dispatchKeyEvent(event);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
mNumberViewHolder.setFocusable(enabled);
for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
mNumberViews[i].setEnabled(enabled);
}
}
void startScrollAnimation(boolean scrollUp) {
if (scrollUp) {
sAdjacentNumberExitAnimator.setTarget(mNumberViews[1]);
sFocusedNumberExitAnimator.setTarget(mNumberViews[2]);
sFocusedNumberEnterAnimator.setTarget(mNumberViews[3]);
sAdjacentNumberEnterAnimator.setTarget(mNumberViews[4]);
} else {
sAdjacentNumberEnterAnimator.setTarget(mNumberViews[0]);
sFocusedNumberEnterAnimator.setTarget(mNumberViews[1]);
sFocusedNumberExitAnimator.setTarget(mNumberViews[2]);
sAdjacentNumberExitAnimator.setTarget(mNumberViews[3]);
}
sAdjacentNumberExitAnimator.start();
sFocusedNumberExitAnimator.start();
sFocusedNumberEnterAnimator.start();
sAdjacentNumberEnterAnimator.start();
}
void endScrollAnimation() {
sAdjacentNumberExitAnimator.end();
sFocusedNumberExitAnimator.end();
sFocusedNumberEnterAnimator.end();
sAdjacentNumberEnterAnimator.end();
mCurrentValue = mNextValue;
mNumberViews[1].setAlpha(sAlphaForAdjacentNumber);
mNumberViews[2].setAlpha(sAlphaForFocusedNumber);
mNumberViews[3].setAlpha(sAlphaForAdjacentNumber);
}
void setValueRange(int min, int max) {
if (min > max) {
throw new IllegalArgumentException(
"The min value should be greater than or equal to the max value");
}
mMinValue = min;
mMaxValue = max;
mNextValue = mCurrentValue = mMinValue - 1;
clearText();
mNumberViews[CURRENT_NUMBER_VIEW_INDEX].setText("—");
}
void setPinDialogFragment(PinDialogFragment dlg) {
mDialog = dlg;
}
void setNextNumberPicker(PinNumberPicker picker) {
mNextNumberPicker = picker;
}
int getValue() {
if (mCurrentValue < mMinValue || mCurrentValue > mMaxValue) {
throw new IllegalStateException("Value is not set");
}
return mCurrentValue;
}
void jumpNextValue(int value) {
if (value < mMinValue || value > mMaxValue) {
throw new IllegalStateException("Value is not set");
}
mNextValue = mCurrentValue = adjustValueInValidRange(value);
updateText();
}
void updateFocus() {
endScrollAnimation();
if (mNumberViewHolder.isFocused()) {
mBackgroundView.setVisibility(View.VISIBLE);
updateText();
} else {
mBackgroundView.setVisibility(View.GONE);
if (!mScroller.isFinished()) {
mCurrentValue = mNextValue;
mScroller.abortAnimation();
}
clearText();
mNumberViewHolder.setScrollY(mNumberViewHeight);
}
}
private void clearText() {
for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
if (i != CURRENT_NUMBER_VIEW_INDEX) {
mNumberViews[i].setText("");
} else if (mCurrentValue >= mMinValue && mCurrentValue <= mMaxValue) {
// Bullet
mNumberViews[i].setText("\u2022");
}
}
}
private void updateText() {
if (mNumberViewHolder.isFocused()) {
if (mCurrentValue < mMinValue || mCurrentValue > mMaxValue) {
mNextValue = mCurrentValue = mMinValue;
}
int value = adjustValueInValidRange(mCurrentValue - CURRENT_NUMBER_VIEW_INDEX);
for (int i = 0; i < NUMBER_VIEWS_RES_ID.length; ++i) {
mNumberViews[i].setText(String.valueOf(adjustValueInValidRange(value)));
value = adjustValueInValidRange(value + 1);
}
}
}
private int adjustValueInValidRange(int value) {
int interval = mMaxValue - mMinValue + 1;
if (value < mMinValue - interval || value > mMaxValue + interval) {
throw new IllegalArgumentException("The value( " + value
+ ") is too small or too big to adjust");
}
return (value < mMinValue) ? value + interval
: (value > mMaxValue) ? value - interval : value;
}
}
}