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.dialog; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorInflater; 21 import android.app.Dialog; 22 import android.content.Context; 23 import android.content.DialogInterface; 24 import android.content.SharedPreferences; 25 import android.content.res.Resources; 26 import android.os.Bundle; 27 import android.os.Handler; 28 import android.preference.PreferenceManager; 29 import android.text.TextUtils; 30 import android.text.format.DateUtils; 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 public 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 { done(boolean success)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 getPinDisabledUntil()101 public abstract long getPinDisabledUntil(); setPinDisabledUntil(long retryDisableTimeout)102 public abstract void setPinDisabledUntil(long retryDisableTimeout); setPin(String pin)103 public abstract void setPin(String pin); isPinCorrect(String pin)104 public abstract boolean isPinCorrect(String pin); isPinSet()105 public abstract boolean isPinSet(); 106 PinDialogFragment(int type, ResultListener listener)107 public PinDialogFragment(int type, ResultListener listener) { 108 mType = type; 109 mListener = listener; 110 mRetCode = PIN_DIALOG_RESULT_FAIL; 111 } 112 113 @Override onCreate(Bundle savedInstanceState)114 public void onCreate(Bundle savedInstanceState) { 115 super.onCreate(savedInstanceState); 116 setStyle(STYLE_NO_TITLE, 0); 117 mDisablePinUntil = getPinDisabledUntil(); 118 } 119 120 @Override onCreateDialog(Bundle savedInstanceState)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 onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)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 updateWrongPin()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