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