RespondViaSmsManager.java revision fcfd76719875e86742b16f5cc901689bff5bf43a
1/*
2 * Copyright (C) 2011 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;
18
19import com.android.internal.telephony.Call;
20import com.android.internal.telephony.Connection;
21import com.android.internal.telephony.Phone;
22
23import android.app.ActionBar;
24import android.app.AlertDialog;
25import android.app.Dialog;
26import android.content.Context;
27import android.content.DialogInterface;
28import android.content.Intent;
29import android.content.SharedPreferences;
30import android.content.res.Resources;
31import android.net.Uri;
32import android.os.Bundle;
33import android.os.SystemProperties;
34import android.preference.EditTextPreference;
35import android.preference.Preference;
36import android.preference.PreferenceActivity;
37import android.telephony.PhoneNumberUtils;
38import android.text.TextUtils;
39import android.util.Log;
40import android.view.MenuItem;
41import android.view.View;
42import android.widget.AdapterView;
43import android.widget.ArrayAdapter;
44import android.widget.ListView;
45import android.widget.Toast;
46
47import java.util.Arrays;
48
49/**
50 * Helper class to manage the "Respond via SMS" feature for incoming calls.
51 * @see InCallScreen.internalRespondViaSms()
52 */
53public class RespondViaSmsManager {
54    private static final String TAG = "RespondViaSmsManager";
55    private static final boolean DBG =
56            (PhoneApp.DBG_LEVEL >= 1) && (SystemProperties.getInt("ro.debuggable", 0) == 1);
57    // Do not check in with VDBG = true, since that may write PII to the system log.
58    private static final boolean VDBG = false;
59
60    /**
61     * Reference to the InCallScreen activity that owns us.  This may be
62     * null if we haven't been initialized yet *or* after the InCallScreen
63     * activity has been destroyed.
64     */
65    private InCallScreen mInCallScreen;
66
67    /**
68     * The popup showing the list of canned responses.
69     *
70     * This is an AlertDialog containing a ListView showing the possible
71     * choices.  This may be null if the InCallScreen hasn't ever called
72     * showRespondViaSmsPopup() yet, or if the popup was visible once but
73     * then got dismissed.
74     */
75    private Dialog mPopup;
76
77    /** The array of "canned responses"; see loadCannedResponses(). */
78    private String[] mCannedResponses;
79
80    /** SharedPreferences file name for our persistent settings. */
81    private static final String SHARED_PREFERENCES_NAME = "respond_via_sms_prefs";
82
83    // Preference keys for the 4 "canned responses"; see RespondViaSmsManager$Settings.
84    // Since (for now at least) the number of messages is fixed at 4, and since
85    // SharedPreferences can't deal with arrays anyway, just store the messages
86    // as 4 separate strings.
87    private static final int NUM_CANNED_RESPONSES = 4;
88    private static final String KEY_CANNED_RESPONSE_PREF_1 = "canned_response_pref_1";
89    private static final String KEY_CANNED_RESPONSE_PREF_2 = "canned_response_pref_2";
90    private static final String KEY_CANNED_RESPONSE_PREF_3 = "canned_response_pref_3";
91    private static final String KEY_CANNED_RESPONSE_PREF_4 = "canned_response_pref_4";
92
93    private static final String ACTION_SENDTO_NO_CONFIRMATION =
94            "com.android.mms.intent.action.SENDTO_NO_CONFIRMATION";
95
96    /**
97     * RespondViaSmsManager constructor.
98     */
99    public RespondViaSmsManager() {
100    }
101
102    public void setInCallScreenInstance(InCallScreen inCallScreen) {
103        mInCallScreen = inCallScreen;
104
105        if (mInCallScreen != null) {
106            // Prefetch shared preferences to make the first canned response lookup faster
107            // (and to prevent StrictMode violation)
108            mInCallScreen.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
109        }
110    }
111
112    /**
113     * Brings up the "Respond via SMS" popup for an incoming call.
114     *
115     * @param ringingCall the current incoming call
116     */
117    public void showRespondViaSmsPopup(Call ringingCall) {
118        if (DBG) log("showRespondViaSmsPopup()...");
119
120        ListView lv = new ListView(mInCallScreen);
121
122        // Refresh the array of "canned responses".
123        mCannedResponses = loadCannedResponses();
124
125        // Build the list: start with the canned responses, but manually add
126        // "Custom message..." as the last choice.
127        int numPopupItems = mCannedResponses.length + 1;
128        String[] popupItems = Arrays.copyOf(mCannedResponses, numPopupItems);
129        popupItems[numPopupItems - 1] = mInCallScreen.getResources()
130                .getString(R.string.respond_via_sms_custom_message);
131
132        ArrayAdapter<String> adapter =
133                new ArrayAdapter<String>(mInCallScreen,
134                                         android.R.layout.simple_list_item_1,
135                                         android.R.id.text1,
136                                         popupItems);
137        lv.setAdapter(adapter);
138
139        // Create a RespondViaSmsItemClickListener instance to handle item
140        // clicks from the popup.
141        // (Note we create a fresh instance for each incoming call, and
142        // stash away the call's phone number, since we can't necessarily
143        // assume this call will still be ringing when the user finally
144        // chooses a response.)
145
146        Connection c = ringingCall.getLatestConnection();
147        if (VDBG) log("- connection: " + c);
148
149        if (c == null) {
150            // Uh oh -- the "ringingCall" doesn't have any connections any more.
151            // (In other words, it's no longer ringing.)  This is rare, but can
152            // happen if the caller hangs up right at the exact moment the user
153            // selects the "Respond via SMS" option.
154            // There's nothing to do here (since the incoming call is gone),
155            // so just bail out.
156            Log.i(TAG, "showRespondViaSmsPopup: null connection; bailing out...");
157            return;
158        }
159
160        // TODO: at this point we probably should re-check c.getAddress()
161        // and c.getNumberPresentation() for validity.  (i.e. recheck the
162        // same cases in InCallTouchUi.showIncomingCallWidget() where we
163        // should have disallowed the "respond via SMS" feature in the
164        // first place.)
165
166        String phoneNumber = c.getAddress();
167        if (VDBG) log("- phoneNumber: " + phoneNumber);
168        lv.setOnItemClickListener(new RespondViaSmsItemClickListener(phoneNumber));
169
170        AlertDialog.Builder builder = new AlertDialog.Builder(mInCallScreen)
171                .setCancelable(true)
172                .setOnCancelListener(new RespondViaSmsCancelListener())
173                .setView(lv);
174        mPopup = builder.create();
175        mPopup.show();
176    }
177
178    /**
179     * Dismiss the "Respond via SMS" popup if it's visible.
180     *
181     * This is safe to call even if the popup is already dismissed, and
182     * even if you never called showRespondViaSmsPopup() in the first
183     * place.
184     */
185    public void dismissPopup() {
186        if (mPopup != null) {
187            mPopup.dismiss();  // safe even if already dismissed
188            mPopup = null;
189        }
190    }
191
192    public boolean isShowingPopup() {
193        return mPopup != null && mPopup.isShowing();
194    }
195
196    /**
197     * OnItemClickListener for the "Respond via SMS" popup.
198     */
199    public class RespondViaSmsItemClickListener implements AdapterView.OnItemClickListener {
200        // Phone number to send the SMS to.
201        private String mPhoneNumber;
202
203        public RespondViaSmsItemClickListener(String phoneNumber) {
204            mPhoneNumber = phoneNumber;
205        }
206
207        /**
208         * Handles the user selecting an item from the popup.
209         */
210        @Override
211        public void onItemClick(AdapterView<?> parent,  // The ListView
212                                View view,  // The TextView that was clicked
213                                int position,
214                                long id) {
215            if (DBG) log("RespondViaSmsItemClickListener.onItemClick(" + position + ")...");
216            String message = (String) parent.getItemAtPosition(position);
217            if (VDBG) log("- message: '" + message + "'");
218
219            // The "Custom" choice is a special case.
220            // (For now, it's guaranteed to be the last item.)
221            if (position == (parent.getCount() - 1)) {
222                // Take the user to the standard SMS compose UI.
223                launchSmsCompose(mPhoneNumber);
224            } else {
225                // Send the selected message immediately with no user interaction.
226                sendText(mPhoneNumber, message);
227
228                // ...and show a brief confirmation to the user (since
229                // otherwise it's hard to be sure that anything actually
230                // happened.)
231                final Resources res = mInCallScreen.getResources();
232                String formatString = res.getString(R.string.respond_via_sms_confirmation_format);
233                String confirmationMsg = String.format(formatString, mPhoneNumber);
234                Toast.makeText(mInCallScreen,
235                               confirmationMsg,
236                               Toast.LENGTH_LONG).show();
237
238                // TODO: If the device is locked, this toast won't actually ever
239                // be visible!  (That's because we're about to dismiss the call
240                // screen, which means that the device will return to the
241                // keyguard.  But toasts aren't visible on top of the keyguard.)
242                // Possible fixes:
243                // (1) Is it possible to allow a specific Toast to be visible
244                //     on top of the keyguard?
245                // (2) Artifically delay the dismissCallScreen() call by 3
246                //     seconds to allow the toast to be seen?
247                // (3) Don't use a toast at all; instead use a transient state
248                //     of the InCallScreen (perhaps via the InCallUiState
249                //     progressIndication feature), and have that state be
250                //     visible for 3 seconds before calling dismissCallScreen().
251            }
252
253            // At this point the user is done dealing with the incoming call, so
254            // there's no reason to keep it around.  (It's also confusing for
255            // the "incoming call" icon in the status bar to still be visible.)
256            // So reject the call now.
257            mInCallScreen.hangupRingingCall();
258
259            dismissPopup();
260
261            final Phone.State state = PhoneApp.getInstance().mCM.getState();
262            if (state == Phone.State.IDLE) {
263                // There's no other phone call to interact. Exit the entire in-call screen.
264                PhoneApp.getInstance().dismissCallScreen();
265            } else {
266                // The user is still in the middle of other phone calls, so we should keep the
267                // in-call screen.
268                mInCallScreen.requestUpdateScreen();
269            }
270        }
271    }
272
273    /**
274     * OnCancelListener for the "Respond via SMS" popup.
275     */
276    public class RespondViaSmsCancelListener implements DialogInterface.OnCancelListener {
277        public RespondViaSmsCancelListener() {
278        }
279
280        /**
281         * Handles the user canceling the popup, either by touching
282         * outside the popup or by pressing Back.
283         */
284        @Override
285        public void onCancel(DialogInterface dialog) {
286            if (DBG) log("RespondViaSmsCancelListener.onCancel()...");
287
288            dismissPopup();
289
290            final Phone.State state = PhoneApp.getInstance().mCM.getState();
291            if (state == Phone.State.IDLE) {
292                // This means the incoming call is already hung up when the user chooses not to
293                // use "Respond via SMS" feature. Let's just exit the whole in-call screen.
294                PhoneApp.getInstance().dismissCallScreen();
295            } else {
296
297                // If the user cancels the popup, this presumably means that
298                // they didn't actually mean to bring up the "Respond via SMS"
299                // UI in the first place (and instead want to go back to the
300                // state where they can either answer or reject the call.)
301                // So restart the ringer and bring back the regular incoming
302                // call UI.
303
304                // This will have no effect if the incoming call isn't still ringing.
305                PhoneApp.getInstance().notifier.restartRinger();
306
307                // We hid the MultiWaveView widget way back in
308                // InCallTouchUi.onTrigger(), when the user first selected
309                // the "SMS" trigger.
310                //
311                // To bring it back, just force the entire InCallScreen to
312                // update itself based on the current telephony state.
313                // (Assuming the incoming call is still ringing, this will
314                // cause the incoming call widget to reappear.)
315                mInCallScreen.requestUpdateScreen();
316            }
317        }
318    }
319
320    /**
321     * Sends a text message without any interaction from the user.
322     */
323    private void sendText(String phoneNumber, String message) {
324        if (VDBG) log("sendText: number "
325                      + phoneNumber + ", message '" + message + "'");
326
327        mInCallScreen.startService(getInstantTextIntent(phoneNumber, message));
328    }
329
330    /**
331     * Brings up the standard SMS compose UI.
332     */
333    private void launchSmsCompose(String phoneNumber) {
334        if (VDBG) log("launchSmsCompose: number " + phoneNumber);
335
336        Intent intent = getInstantTextIntent(phoneNumber, null);
337
338        if (VDBG) log("- Launching SMS compose UI: " + intent);
339        mInCallScreen.startService(intent);
340    }
341
342    /**
343     * @param phoneNumber Must not be null.
344     * @param message Can be null. If message is null, the returned Intent will be configured to
345     * launch the SMS compose UI. If non-null, the returned Intent will cause the specified message
346     * to be sent with no interaction from the user.
347     * @return Service Intent for the instant response.
348     */
349    private static Intent getInstantTextIntent(String phoneNumber, String message) {
350        Uri uri = Uri.fromParts(Constants.SCHEME_SMSTO, phoneNumber, null);
351        Intent intent = new Intent(ACTION_SENDTO_NO_CONFIRMATION, uri);
352        if (message != null) {
353            intent.putExtra(Intent.EXTRA_TEXT, message);
354        } else {
355            intent.putExtra("exit_on_sent", true);
356            intent.putExtra("showUI", true);
357        }
358        return intent;
359    }
360
361    /**
362     * Settings activity under "Call settings" to let you manage the
363     * canned responses; see respond_via_sms_settings.xml
364     */
365    public static class Settings extends PreferenceActivity
366            implements Preference.OnPreferenceChangeListener {
367        @Override
368        protected void onCreate(Bundle icicle) {
369            super.onCreate(icicle);
370            if (DBG) log("Settings: onCreate()...");
371
372            getPreferenceManager().setSharedPreferencesName(SHARED_PREFERENCES_NAME);
373
374            // This preference screen is ultra-simple; it's just 4 plain
375            // <EditTextPreference>s, one for each of the 4 "canned responses".
376            //
377            // The only nontrivial thing we do here is copy the text value of
378            // each of those EditTextPreferences and use it as the preference's
379            // "title" as well, so that the user will immediately see all 4
380            // strings when they arrive here.
381            //
382            // Also, listen for change events (since we'll need to update the
383            // title any time the user edits one of the strings.)
384
385            addPreferencesFromResource(R.xml.respond_via_sms_settings);
386
387            EditTextPreference pref;
388            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_1);
389            pref.setTitle(pref.getText());
390            pref.setOnPreferenceChangeListener(this);
391
392            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_2);
393            pref.setTitle(pref.getText());
394            pref.setOnPreferenceChangeListener(this);
395
396            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_3);
397            pref.setTitle(pref.getText());
398            pref.setOnPreferenceChangeListener(this);
399
400            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_4);
401            pref.setTitle(pref.getText());
402            pref.setOnPreferenceChangeListener(this);
403
404            ActionBar actionBar = getActionBar();
405            if (actionBar != null) {
406                // android.R.id.home will be triggered in onOptionsItemSelected()
407                actionBar.setDisplayHomeAsUpEnabled(true);
408            }
409        }
410
411        // Preference.OnPreferenceChangeListener implementation
412        @Override
413        public boolean onPreferenceChange(Preference preference, Object newValue) {
414            if (DBG) log("onPreferenceChange: key = " + preference.getKey());
415            if (VDBG) log("  preference = '" + preference + "'");
416            if (VDBG) log("  newValue = '" + newValue + "'");
417
418            EditTextPreference pref = (EditTextPreference) preference;
419
420            // Copy the new text over to the title, just like in onCreate().
421            // (Watch out: onPreferenceChange() is called *before* the
422            // Preference itself gets updated, so we need to use newValue here
423            // rather than pref.getText().)
424            pref.setTitle((String) newValue);
425
426            return true;  // means it's OK to update the state of the Preference with the new value
427        }
428
429        @Override
430        public boolean onOptionsItemSelected(MenuItem item) {
431            final int itemId = item.getItemId();
432            if (itemId == android.R.id.home) {  // See ActionBar#setDisplayHomeAsUpEnabled()
433                CallFeaturesSetting.goUpToTopLevelSetting(this);
434                return true;
435            }
436            return super.onOptionsItemSelected(item);
437        }
438    }
439
440    /**
441     * Read the (customizable) canned responses from SharedPreferences,
442     * or from defaults if the user has never actually brought up
443     * the Settings UI.
444     *
445     * This method does disk I/O (reading the SharedPreferences file)
446     * so don't call it from the main thread.
447     *
448     * @see RespondViaSmsManager.Settings
449     */
450    private String[] loadCannedResponses() {
451        if (DBG) log("loadCannedResponses()...");
452
453        SharedPreferences prefs =
454                mInCallScreen.getSharedPreferences(SHARED_PREFERENCES_NAME,
455                                                   Context.MODE_PRIVATE);
456        final Resources res = mInCallScreen.getResources();
457
458        String[] responses = new String[NUM_CANNED_RESPONSES];
459
460        // Note the default values here must agree with the corresponding
461        // android:defaultValue attributes in respond_via_sms_settings.xml.
462
463        responses[0] = prefs.getString(KEY_CANNED_RESPONSE_PREF_1,
464                                       res.getString(R.string.respond_via_sms_canned_response_1));
465        responses[1] = prefs.getString(KEY_CANNED_RESPONSE_PREF_2,
466                                       res.getString(R.string.respond_via_sms_canned_response_2));
467        responses[2] = prefs.getString(KEY_CANNED_RESPONSE_PREF_3,
468                                       res.getString(R.string.respond_via_sms_canned_response_3));
469        responses[3] = prefs.getString(KEY_CANNED_RESPONSE_PREF_4,
470                                       res.getString(R.string.respond_via_sms_canned_response_4));
471        return responses;
472    }
473
474    /**
475     * @return true if the "Respond via SMS" feature should be enabled
476     * for the specified incoming call.
477     *
478     * The general rule is that we *do* allow "Respond via SMS" except for
479     * the few (relatively rare) cases where we know for sure it won't
480     * work, namely:
481     *   - a bogus or blank incoming number
482     *   - a call from a SIP address
483     *   - a "call presentation" that doesn't allow the number to be revealed
484     *
485     * In all other cases, we allow the user to respond via SMS.
486     *
487     * Note that this behavior isn't perfect; for example we have no way
488     * to detect whether the incoming call is from a landline (with most
489     * networks at least), so we still enable this feature even though
490     * SMSes to that number will silently fail.
491     */
492    public static boolean allowRespondViaSmsForCall(Context context, Call ringingCall) {
493        if (DBG) log("allowRespondViaSmsForCall(" + ringingCall + ")...");
494
495        // First some basic sanity checks:
496        if (ringingCall == null) {
497            Log.w(TAG, "allowRespondViaSmsForCall: null ringingCall!");
498            return false;
499        }
500        if (!ringingCall.isRinging()) {
501            // The call is in some state other than INCOMING or WAITING!
502            // (This should almost never happen, but it *could*
503            // conceivably happen if the ringing call got disconnected by
504            // the network just *after* we got it from the CallManager.)
505            Log.w(TAG, "allowRespondViaSmsForCall: ringingCall not ringing! state = "
506                  + ringingCall.getState());
507            return false;
508        }
509        Connection conn = ringingCall.getLatestConnection();
510        if (conn == null) {
511            // The call doesn't have any connections!  (Again, this can
512            // happen if the ringing call disconnects at the exact right
513            // moment, but should almost never happen in practice.)
514            Log.w(TAG, "allowRespondViaSmsForCall: null Connection!");
515            return false;
516        }
517
518        // Check the incoming number:
519        final String number = conn.getAddress();
520        if (DBG) log("- number: '" + number + "'");
521        if (TextUtils.isEmpty(number)) {
522            Log.w(TAG, "allowRespondViaSmsForCall: no incoming number!");
523            return false;
524        }
525        if (PhoneNumberUtils.isUriNumber(number)) {
526            // The incoming number is actually a URI (i.e. a SIP address),
527            // not a regular PSTN phone number, and we can't send SMSes to
528            // SIP addresses.
529            // (TODO: That might still be possible eventually, though.  Is
530            // there some SIP-specific equivalent to sending a text message?)
531            Log.i(TAG, "allowRespondViaSmsForCall: incoming 'number' is a SIP address.");
532            return false;
533        }
534
535        // Finally, check the "call presentation":
536        int presentation = conn.getNumberPresentation();
537        if (DBG) log("- presentation: " + presentation);
538        if (presentation == Connection.PRESENTATION_RESTRICTED) {
539            // PRESENTATION_RESTRICTED means "caller-id blocked".
540            // The user isn't allowed to see the number in the first
541            // place, so obviously we can't let you send an SMS to it.
542            Log.i(TAG, "allowRespondViaSmsForCall: PRESENTATION_RESTRICTED.");
543            return false;
544        }
545
546        // Allow the feature only when there's a destination for it.
547        if (context.getPackageManager().resolveService(getInstantTextIntent(number, null) , 0)
548                == null) {
549            return false;
550        }
551
552        // TODO: with some carriers (in certain countries) you *can* actually
553        // tell whether a given number is a mobile phone or not.  So in that
554        // case we could potentially return false here if the incoming call is
555        // from a land line.
556
557        // If none of the above special cases apply, it's OK to enable the
558        // "Respond via SMS" feature.
559        return true;
560    }
561
562
563    private static void log(String msg) {
564        Log.d(TAG, msg);
565    }
566}
567