1 /* 2 * Copyright (C) 2016 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.voicemail.impl.settings; 18 19 import android.annotation.TargetApi; 20 import android.app.Activity; 21 import android.app.AlertDialog; 22 import android.app.ProgressDialog; 23 import android.content.Context; 24 import android.content.DialogInterface; 25 import android.content.DialogInterface.OnDismissListener; 26 import android.net.Network; 27 import android.os.Build.VERSION_CODES; 28 import android.os.Bundle; 29 import android.os.Handler; 30 import android.os.Message; 31 import android.support.annotation.Nullable; 32 import android.telecom.PhoneAccountHandle; 33 import android.text.Editable; 34 import android.text.InputFilter; 35 import android.text.InputFilter.LengthFilter; 36 import android.text.TextWatcher; 37 import android.view.KeyEvent; 38 import android.view.MenuItem; 39 import android.view.View; 40 import android.view.View.OnClickListener; 41 import android.view.WindowManager; 42 import android.view.inputmethod.EditorInfo; 43 import android.widget.Button; 44 import android.widget.EditText; 45 import android.widget.TextView; 46 import android.widget.TextView.OnEditorActionListener; 47 import android.widget.Toast; 48 import com.android.dialer.logging.DialerImpression; 49 import com.android.voicemail.impl.OmtpConstants; 50 import com.android.voicemail.impl.OmtpConstants.ChangePinResult; 51 import com.android.voicemail.impl.OmtpEvents; 52 import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper; 53 import com.android.voicemail.impl.R; 54 import com.android.voicemail.impl.VisualVoicemailPreferences; 55 import com.android.voicemail.impl.VoicemailStatus; 56 import com.android.voicemail.impl.VvmLog; 57 import com.android.voicemail.impl.imap.ImapHelper; 58 import com.android.voicemail.impl.imap.ImapHelper.InitializingException; 59 import com.android.voicemail.impl.mail.MessagingException; 60 import com.android.voicemail.impl.sync.VvmNetworkRequestCallback; 61 import com.android.voicemail.impl.utils.LoggerUtils; 62 63 /** 64 * Dialog to change the voicemail PIN. The TUI (Telephony User Interface) PIN is used when accessing 65 * traditional voicemail through phone call. The intent to launch this activity must contain {@link 66 * #EXTRA_PHONE_ACCOUNT_HANDLE} 67 */ 68 @TargetApi(VERSION_CODES.O) 69 public class VoicemailChangePinActivity extends Activity 70 implements OnClickListener, OnEditorActionListener, TextWatcher { 71 72 private static final String TAG = "VmChangePinActivity"; 73 74 public static final String EXTRA_PHONE_ACCOUNT_HANDLE = "extra_phone_account_handle"; 75 76 private static final String KEY_DEFAULT_OLD_PIN = "default_old_pin"; 77 78 private static final int MESSAGE_HANDLE_RESULT = 1; 79 80 private PhoneAccountHandle mPhoneAccountHandle; 81 private OmtpVvmCarrierConfigHelper mConfig; 82 83 private int mPinMinLength; 84 private int mPinMaxLength; 85 86 private State mUiState = State.Initial; 87 private String mOldPin; 88 private String mFirstPin; 89 90 private ProgressDialog mProgressDialog; 91 92 private TextView mHeaderText; 93 private TextView mHintText; 94 private TextView mErrorText; 95 private EditText mPinEntry; 96 private Button mCancelButton; 97 private Button mNextButton; 98 99 private Handler mHandler = 100 new Handler() { 101 @Override 102 public void handleMessage(Message message) { 103 if (message.what == MESSAGE_HANDLE_RESULT) { 104 mUiState.handleResult(VoicemailChangePinActivity.this, message.arg1); 105 } 106 } 107 }; 108 109 private enum State { 110 /** 111 * Empty state to handle initial state transition. Will immediately switch into {@link 112 * #VerifyOldPin} if a default PIN has been set by the OMTP client, or {@link #EnterOldPin} if 113 * not. 114 */ 115 Initial, 116 /** 117 * Prompt the user to enter old PIN. The PIN will be verified with the server before proceeding 118 * to {@link #EnterNewPin}. 119 */ 120 EnterOldPin { 121 @Override 122 public void onEnter(VoicemailChangePinActivity activity) { 123 activity.setHeader(R.string.change_pin_enter_old_pin_header); 124 activity.mHintText.setText(R.string.change_pin_enter_old_pin_hint); 125 activity.mNextButton.setText(R.string.change_pin_continue_label); 126 activity.mErrorText.setText(null); 127 } 128 129 @Override 130 public void onInputChanged(VoicemailChangePinActivity activity) { 131 activity.setNextEnabled(activity.getCurrentPasswordInput().length() > 0); 132 } 133 134 @Override 135 public void handleNext(VoicemailChangePinActivity activity) { 136 activity.mOldPin = activity.getCurrentPasswordInput(); 137 activity.verifyOldPin(); 138 } 139 140 @Override 141 public void handleResult(VoicemailChangePinActivity activity, @ChangePinResult int result) { 142 if (result == OmtpConstants.CHANGE_PIN_SUCCESS) { 143 activity.updateState(State.EnterNewPin); 144 } else { 145 CharSequence message = activity.getChangePinResultMessage(result); 146 activity.showError(message); 147 activity.mPinEntry.setText(""); 148 } 149 } 150 }, 151 /** 152 * The default old PIN is found. Show a blank screen while verifying with the server to make 153 * sure the PIN is still valid. If the PIN is still valid, proceed to {@link #EnterNewPin}. If 154 * not, the user probably changed the PIN through other means, proceed to {@link #EnterOldPin}. 155 * If any other issue caused the verifying to fail, show an error and exit. 156 */ 157 VerifyOldPin { 158 @Override 159 public void onEnter(VoicemailChangePinActivity activity) { 160 activity.findViewById(android.R.id.content).setVisibility(View.INVISIBLE); 161 activity.verifyOldPin(); 162 } 163 164 @Override 165 public void handleResult( 166 final VoicemailChangePinActivity activity, @ChangePinResult int result) { 167 if (result == OmtpConstants.CHANGE_PIN_SUCCESS) { 168 activity.updateState(State.EnterNewPin); 169 } else if (result == OmtpConstants.CHANGE_PIN_SYSTEM_ERROR) { 170 activity 171 .getWindow() 172 .setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); 173 activity.showError( 174 activity.getString(R.string.change_pin_system_error), 175 new OnDismissListener() { 176 @Override 177 public void onDismiss(DialogInterface dialog) { 178 activity.finish(); 179 } 180 }); 181 } else { 182 VvmLog.e(TAG, "invalid default old PIN: " + activity.getChangePinResultMessage(result)); 183 // If the default old PIN is rejected by the server, the PIN is probably changed 184 // through other means, or the generated pin is invalid 185 // Wipe the default old PIN so the old PIN input box will be shown to the user 186 // on the next time. 187 setDefaultOldPIN(activity, activity.mPhoneAccountHandle, null); 188 activity.handleOmtpEvent(OmtpEvents.CONFIG_PIN_SET); 189 activity.updateState(State.EnterOldPin); 190 } 191 } 192 193 @Override 194 public void onLeave(VoicemailChangePinActivity activity) { 195 activity.findViewById(android.R.id.content).setVisibility(View.VISIBLE); 196 } 197 }, 198 /** 199 * Let the user enter the new PIN and validate the format. Only length is enforced, PIN strength 200 * check relies on the server. After a valid PIN is entered, proceed to {@link #ConfirmNewPin} 201 */ 202 EnterNewPin { 203 @Override 204 public void onEnter(VoicemailChangePinActivity activity) { 205 activity.mHeaderText.setText(R.string.change_pin_enter_new_pin_header); 206 activity.mNextButton.setText(R.string.change_pin_continue_label); 207 activity.mHintText.setText( 208 activity.getString( 209 R.string.change_pin_enter_new_pin_hint, 210 activity.mPinMinLength, 211 activity.mPinMaxLength)); 212 } 213 214 @Override 215 public void onInputChanged(VoicemailChangePinActivity activity) { 216 String password = activity.getCurrentPasswordInput(); 217 if (password.length() == 0) { 218 activity.setNextEnabled(false); 219 return; 220 } 221 CharSequence error = activity.validatePassword(password); 222 if (error != null) { 223 activity.mErrorText.setText(error); 224 activity.setNextEnabled(false); 225 } else { 226 activity.mErrorText.setText(null); 227 activity.setNextEnabled(true); 228 } 229 } 230 231 @Override 232 public void handleNext(VoicemailChangePinActivity activity) { 233 CharSequence errorMsg; 234 errorMsg = activity.validatePassword(activity.getCurrentPasswordInput()); 235 if (errorMsg != null) { 236 activity.showError(errorMsg); 237 return; 238 } 239 activity.mFirstPin = activity.getCurrentPasswordInput(); 240 activity.updateState(State.ConfirmNewPin); 241 } 242 }, 243 /** 244 * Let the user type in the same PIN again to avoid typos. If the PIN matches then perform a PIN 245 * change to the server. Finish the activity if succeeded. Return to {@link #EnterOldPin} if the 246 * old PIN is rejected, {@link #EnterNewPin} for other failure. 247 */ 248 ConfirmNewPin { 249 @Override 250 public void onEnter(VoicemailChangePinActivity activity) { 251 activity.mHeaderText.setText(R.string.change_pin_confirm_pin_header); 252 activity.mHintText.setText(null); 253 activity.mNextButton.setText(R.string.change_pin_ok_label); 254 } 255 256 @Override 257 public void onInputChanged(VoicemailChangePinActivity activity) { 258 if (activity.getCurrentPasswordInput().length() == 0) { 259 activity.setNextEnabled(false); 260 return; 261 } 262 if (activity.getCurrentPasswordInput().equals(activity.mFirstPin)) { 263 activity.setNextEnabled(true); 264 activity.mErrorText.setText(null); 265 } else { 266 activity.setNextEnabled(false); 267 activity.mErrorText.setText(R.string.change_pin_confirm_pins_dont_match); 268 } 269 } 270 271 @Override 272 public void handleResult(VoicemailChangePinActivity activity, @ChangePinResult int result) { 273 if (result == OmtpConstants.CHANGE_PIN_SUCCESS) { 274 // If the PIN change succeeded we no longer know what the old (current) PIN is. 275 // Wipe the default old PIN so the old PIN input box will be shown to the user 276 // on the next time. 277 setDefaultOldPIN(activity, activity.mPhoneAccountHandle, null); 278 activity.handleOmtpEvent(OmtpEvents.CONFIG_PIN_SET); 279 280 activity.finish(); 281 LoggerUtils.logImpressionOnMainThread( 282 activity, DialerImpression.Type.VVM_CHANGE_PIN_COMPLETED); 283 Toast.makeText( 284 activity, activity.getString(R.string.change_pin_succeeded), Toast.LENGTH_SHORT) 285 .show(); 286 } else { 287 CharSequence message = activity.getChangePinResultMessage(result); 288 VvmLog.i(TAG, "Change PIN failed: " + message); 289 activity.showError(message); 290 if (result == OmtpConstants.CHANGE_PIN_MISMATCH) { 291 // Somehow the PIN has changed, prompt to enter the old PIN again. 292 activity.updateState(State.EnterOldPin); 293 } else { 294 // The new PIN failed to fulfil other restrictions imposed by the server. 295 activity.updateState(State.EnterNewPin); 296 } 297 } 298 } 299 300 @Override 301 public void handleNext(VoicemailChangePinActivity activity) { 302 activity.processPinChange(activity.mOldPin, activity.mFirstPin); 303 } 304 }; 305 306 /** The activity has switched from another state to this one. */ 307 public void onEnter(VoicemailChangePinActivity activity) { 308 // Do nothing 309 } 310 311 /** 312 * The user has typed something into the PIN input field. Also called after {@link 313 * #onEnter(VoicemailChangePinActivity)} 314 */ 315 public void onInputChanged(VoicemailChangePinActivity activity) { 316 // Do nothing 317 } 318 319 /** The asynchronous call to change the PIN on the server has returned. */ 320 public void handleResult(VoicemailChangePinActivity activity, @ChangePinResult int result) { 321 // Do nothing 322 } 323 324 /** The user has pressed the "next" button. */ 325 public void handleNext(VoicemailChangePinActivity activity) { 326 // Do nothing 327 } 328 329 /** The activity has switched from this state to another one. */ 330 public void onLeave(VoicemailChangePinActivity activity) { 331 // Do nothing 332 } 333 } 334 335 @Override 336 public void onCreate(Bundle savedInstanceState) { 337 super.onCreate(savedInstanceState); 338 339 mPhoneAccountHandle = getIntent().getParcelableExtra(EXTRA_PHONE_ACCOUNT_HANDLE); 340 mConfig = new OmtpVvmCarrierConfigHelper(this, mPhoneAccountHandle); 341 setContentView(R.layout.voicemail_change_pin); 342 setTitle(R.string.change_pin_title); 343 344 readPinLength(); 345 346 View view = findViewById(android.R.id.content); 347 348 mCancelButton = (Button) view.findViewById(R.id.cancel_button); 349 mCancelButton.setOnClickListener(this); 350 mNextButton = (Button) view.findViewById(R.id.next_button); 351 mNextButton.setOnClickListener(this); 352 353 mPinEntry = (EditText) view.findViewById(R.id.pin_entry); 354 mPinEntry.setOnEditorActionListener(this); 355 mPinEntry.addTextChangedListener(this); 356 if (mPinMaxLength != 0) { 357 mPinEntry.setFilters(new InputFilter[] {new LengthFilter(mPinMaxLength)}); 358 } 359 360 mHeaderText = (TextView) view.findViewById(R.id.headerText); 361 mHintText = (TextView) view.findViewById(R.id.hintText); 362 mErrorText = (TextView) view.findViewById(R.id.errorText); 363 364 if (isDefaultOldPinSet(this, mPhoneAccountHandle)) { 365 mOldPin = getDefaultOldPin(this, mPhoneAccountHandle); 366 updateState(State.VerifyOldPin); 367 } else { 368 updateState(State.EnterOldPin); 369 } 370 } 371 372 private void handleOmtpEvent(OmtpEvents event) { 373 mConfig.handleEvent(getVoicemailStatusEditor(), event); 374 } 375 376 private VoicemailStatus.Editor getVoicemailStatusEditor() { 377 // This activity does not have any automatic retry mechanism, errors should be written right 378 // away. 379 return VoicemailStatus.edit(this, mPhoneAccountHandle); 380 } 381 382 /** Extracts the pin length requirement sent by the server with a STATUS SMS. */ 383 private void readPinLength() { 384 VisualVoicemailPreferences preferences = 385 new VisualVoicemailPreferences(this, mPhoneAccountHandle); 386 // The OMTP pin length format is {min}-{max} 387 String[] lengths = preferences.getString(OmtpConstants.TUI_PASSWORD_LENGTH, "").split("-"); 388 if (lengths.length == 2) { 389 try { 390 mPinMinLength = Integer.parseInt(lengths[0]); 391 mPinMaxLength = Integer.parseInt(lengths[1]); 392 } catch (NumberFormatException e) { 393 mPinMinLength = 0; 394 mPinMaxLength = 0; 395 } 396 } else { 397 mPinMinLength = 0; 398 mPinMaxLength = 0; 399 } 400 } 401 402 @Override 403 public void onResume() { 404 super.onResume(); 405 updateState(mUiState); 406 } 407 408 public void handleNext() { 409 if (mPinEntry.length() == 0) { 410 return; 411 } 412 mUiState.handleNext(this); 413 } 414 415 @Override 416 public void onClick(View v) { 417 if (v.getId() == R.id.next_button) { 418 handleNext(); 419 } else if (v.getId() == R.id.cancel_button) { 420 finish(); 421 } 422 } 423 424 @Override 425 public boolean onOptionsItemSelected(MenuItem item) { 426 if (item.getItemId() == android.R.id.home) { 427 onBackPressed(); 428 return true; 429 } 430 return super.onOptionsItemSelected(item); 431 } 432 433 @Override 434 public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { 435 if (!mNextButton.isEnabled()) { 436 return true; 437 } 438 // Check if this was the result of hitting the enter or "done" key 439 if (actionId == EditorInfo.IME_NULL 440 || actionId == EditorInfo.IME_ACTION_DONE 441 || actionId == EditorInfo.IME_ACTION_NEXT) { 442 handleNext(); 443 return true; 444 } 445 return false; 446 } 447 448 @Override 449 public void afterTextChanged(Editable s) { 450 mUiState.onInputChanged(this); 451 } 452 453 @Override 454 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 455 // Do nothing 456 } 457 458 @Override 459 public void onTextChanged(CharSequence s, int start, int before, int count) { 460 // Do nothing 461 } 462 463 /** 464 * After replacing the default PIN with a random PIN, call this to store the random PIN. The 465 * stored PIN will be automatically entered when the user attempts to change the PIN. 466 */ 467 public static void setDefaultOldPIN( 468 Context context, PhoneAccountHandle phoneAccountHandle, String pin) { 469 new VisualVoicemailPreferences(context, phoneAccountHandle) 470 .edit() 471 .putString(KEY_DEFAULT_OLD_PIN, pin) 472 .apply(); 473 } 474 475 public static boolean isDefaultOldPinSet(Context context, PhoneAccountHandle phoneAccountHandle) { 476 return getDefaultOldPin(context, phoneAccountHandle) != null; 477 } 478 479 private static String getDefaultOldPin(Context context, PhoneAccountHandle phoneAccountHandle) { 480 return new VisualVoicemailPreferences(context, phoneAccountHandle) 481 .getString(KEY_DEFAULT_OLD_PIN); 482 } 483 484 private String getCurrentPasswordInput() { 485 return mPinEntry.getText().toString(); 486 } 487 488 private void updateState(State state) { 489 State previousState = mUiState; 490 mUiState = state; 491 if (previousState != state) { 492 previousState.onLeave(this); 493 mPinEntry.setText(""); 494 mUiState.onEnter(this); 495 } 496 mUiState.onInputChanged(this); 497 } 498 499 /** 500 * Validates PIN and returns a message to display if PIN fails test. 501 * 502 * @param password the raw password the user typed in 503 * @return error message to show to user or null if password is OK 504 */ 505 private CharSequence validatePassword(String password) { 506 if (mPinMinLength == 0 && mPinMaxLength == 0) { 507 // Invalid length requirement is sent by the server, just accept anything and let the 508 // server decide. 509 return null; 510 } 511 512 if (password.length() < mPinMinLength) { 513 return getString(R.string.vm_change_pin_error_too_short); 514 } 515 return null; 516 } 517 518 private void setHeader(int text) { 519 mHeaderText.setText(text); 520 mPinEntry.setContentDescription(mHeaderText.getText()); 521 } 522 523 /** 524 * Get the corresponding message for the {@link ChangePinResult}.<code>result</code> must not 525 * {@link OmtpConstants#CHANGE_PIN_SUCCESS} 526 */ 527 private CharSequence getChangePinResultMessage(@ChangePinResult int result) { 528 switch (result) { 529 case OmtpConstants.CHANGE_PIN_TOO_SHORT: 530 return getString(R.string.vm_change_pin_error_too_short); 531 case OmtpConstants.CHANGE_PIN_TOO_LONG: 532 return getString(R.string.vm_change_pin_error_too_long); 533 case OmtpConstants.CHANGE_PIN_TOO_WEAK: 534 return getString(R.string.vm_change_pin_error_too_weak); 535 case OmtpConstants.CHANGE_PIN_INVALID_CHARACTER: 536 return getString(R.string.vm_change_pin_error_invalid); 537 case OmtpConstants.CHANGE_PIN_MISMATCH: 538 return getString(R.string.vm_change_pin_error_mismatch); 539 case OmtpConstants.CHANGE_PIN_SYSTEM_ERROR: 540 return getString(R.string.vm_change_pin_error_system_error); 541 default: 542 VvmLog.wtf(TAG, "Unexpected ChangePinResult " + result); 543 return null; 544 } 545 } 546 547 private void verifyOldPin() { 548 processPinChange(mOldPin, mOldPin); 549 } 550 551 private void setNextEnabled(boolean enabled) { 552 mNextButton.setEnabled(enabled); 553 } 554 555 private void showError(CharSequence message) { 556 showError(message, null); 557 } 558 559 private void showError(CharSequence message, @Nullable OnDismissListener callback) { 560 new AlertDialog.Builder(this) 561 .setMessage(message) 562 .setPositiveButton(android.R.string.ok, null) 563 .setOnDismissListener(callback) 564 .show(); 565 } 566 567 /** Asynchronous call to change the PIN on the server. */ 568 private void processPinChange(String oldPin, String newPin) { 569 mProgressDialog = new ProgressDialog(this); 570 mProgressDialog.setCancelable(false); 571 mProgressDialog.setMessage(getString(R.string.vm_change_pin_progress_message)); 572 mProgressDialog.show(); 573 574 ChangePinNetworkRequestCallback callback = new ChangePinNetworkRequestCallback(oldPin, newPin); 575 callback.requestNetwork(); 576 } 577 578 private class ChangePinNetworkRequestCallback extends VvmNetworkRequestCallback { 579 580 private final String mOldPin; 581 private final String mNewPin; 582 583 public ChangePinNetworkRequestCallback(String oldPin, String newPin) { 584 super( 585 mConfig, mPhoneAccountHandle, VoicemailChangePinActivity.this.getVoicemailStatusEditor()); 586 mOldPin = oldPin; 587 mNewPin = newPin; 588 } 589 590 @Override 591 public void onAvailable(Network network) { 592 super.onAvailable(network); 593 try (ImapHelper helper = 594 new ImapHelper( 595 VoicemailChangePinActivity.this, 596 mPhoneAccountHandle, 597 network, 598 getVoicemailStatusEditor())) { 599 600 @ChangePinResult int result = helper.changePin(mOldPin, mNewPin); 601 sendResult(result); 602 } catch (InitializingException | MessagingException e) { 603 VvmLog.e(TAG, "ChangePinNetworkRequestCallback: onAvailable: ", e); 604 sendResult(OmtpConstants.CHANGE_PIN_SYSTEM_ERROR); 605 } 606 } 607 608 @Override 609 public void onFailed(String reason) { 610 super.onFailed(reason); 611 sendResult(OmtpConstants.CHANGE_PIN_SYSTEM_ERROR); 612 } 613 614 private void sendResult(@ChangePinResult int result) { 615 VvmLog.i(TAG, "Change PIN result: " + result); 616 if (mProgressDialog.isShowing() 617 && !VoicemailChangePinActivity.this.isDestroyed() 618 && !VoicemailChangePinActivity.this.isFinishing()) { 619 mProgressDialog.dismiss(); 620 } else { 621 VvmLog.i(TAG, "Dialog not visible, not dismissing"); 622 } 623 mHandler.obtainMessage(MESSAGE_HANDLE_RESULT, result, 0).sendToTarget(); 624 releaseNetwork(); 625 } 626 } 627 } 628