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