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