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