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