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