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