RespondViaSmsManager.java revision a29c610482c8db105c3c7f562cd72873d3ac4db6
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;
21
22import android.app.ActionBar;
23import android.app.AlertDialog;
24import android.app.Dialog;
25import android.content.Context;
26import android.content.DialogInterface;
27import android.content.Intent;
28import android.content.SharedPreferences;
29import android.content.res.Resources;
30import android.net.Uri;
31import android.os.Bundle;
32import android.preference.EditTextPreference;
33import android.preference.Preference;
34import android.preference.PreferenceActivity;
35import android.util.Log;
36import android.view.MenuItem;
37import android.view.View;
38import android.widget.AdapterView;
39import android.widget.ArrayAdapter;
40import android.widget.ListView;
41import android.widget.Toast;
42
43import java.util.Arrays;
44
45/**
46 * Helper class to manage the "Respond via SMS" feature for incoming calls.
47 * @see InCallScreen.internalRespondViaSms()
48 */
49public class RespondViaSmsManager {
50    private static final String TAG = "RespondViaSmsManager";
51    private static final boolean DBG = true;
52    // STOPSHIP: reduce DBG to
53    //       (PhoneApp.DBG_LEVEL >= 1) && (SystemProperties.getInt("ro.debuggable", 0) == 1);
54
55    /**
56     * Reference to the InCallScreen activity that owns us.  This may be
57     * null if we haven't been initialized yet *or* after the InCallScreen
58     * activity has been destroyed.
59     */
60    private InCallScreen mInCallScreen;
61
62    /**
63     * The popup showing the list of canned responses.
64     *
65     * This is an AlertDialog containing a ListView showing the possible
66     * choices.  This may be null if the InCallScreen hasn't ever called
67     * showRespondViaSmsPopup() yet, or if the popup was visible once but
68     * then got dismissed.
69     */
70    private Dialog mPopup;
71
72    /** The array of "canned responses"; see loadCannedResponses(). */
73    private String[] mCannedResponses;
74
75    /** SharedPreferences file name for our persistent settings. */
76    private static final String SHARED_PREFERENCES_NAME = "respond_via_sms_prefs";
77
78    // Preference keys for the 4 "canned responses"; see RespondViaSmsManager$Settings.
79    // Since (for now at least) the number of messages is fixed at 4, and since
80    // SharedPreferences can't deal with arrays anyway, just store the messages
81    // as 4 separate strings.
82    private static final int NUM_CANNED_RESPONSES = 4;
83    private static final String KEY_CANNED_RESPONSE_PREF_1 = "canned_response_pref_1";
84    private static final String KEY_CANNED_RESPONSE_PREF_2 = "canned_response_pref_2";
85    private static final String KEY_CANNED_RESPONSE_PREF_3 = "canned_response_pref_3";
86    private static final String KEY_CANNED_RESPONSE_PREF_4 = "canned_response_pref_4";
87
88
89    /**
90     * RespondViaSmsManager constructor.
91     */
92    public RespondViaSmsManager() {
93    }
94
95    public void setInCallScreenInstance(InCallScreen inCallScreen) {
96        mInCallScreen = inCallScreen;
97    }
98
99    /**
100     * Brings up the "Respond via SMS" popup for an incoming call.
101     *
102     * @param ringingCall the current incoming call
103     */
104    public void showRespondViaSmsPopup(Call ringingCall) {
105        if (DBG) log("showRespondViaSmsPopup()...");
106
107        ListView lv = new ListView(mInCallScreen);
108
109        // Refresh the array of "canned responses".
110        // TODO: don't do this here in the UI thread!  (This lookup is very
111        // cheap, but it's still a StrictMode violation.  See the TODO comment
112        // following loadCannedResponses() for more info.)
113        mCannedResponses = loadCannedResponses();
114
115        // Build the list: start with the canned responses, but manually add
116        // "Custom message..." as the last choice.
117        int numPopupItems = mCannedResponses.length + 1;
118        String[] popupItems = Arrays.copyOf(mCannedResponses, numPopupItems);
119        popupItems[numPopupItems - 1] = mInCallScreen.getResources()
120                .getString(R.string.respond_via_sms_custom_message);
121
122        ArrayAdapter<String> adapter =
123                new ArrayAdapter<String>(mInCallScreen,
124                                         android.R.layout.simple_list_item_1,
125                                         android.R.id.text1,
126                                         popupItems);
127        lv.setAdapter(adapter);
128
129        // Create a RespondViaSmsItemClickListener instance to handle item
130        // clicks from the popup.
131        // (Note we create a fresh instance for each incoming call, and
132        // stash away the call's phone number, since we can't necessarily
133        // assume this call will still be ringing when the user finally
134        // chooses a response.)
135
136        Connection c = ringingCall.getLatestConnection();
137        if (DBG) log("- connection: " + c);
138
139        if (c == null) {
140            // Uh oh -- the "ringingCall" doesn't have any connections any more.
141            // (In other words, it's no longer ringing.)  This is rare, but can
142            // happen if the caller hangs up right at the exact moment the user
143            // selects the "Respond via SMS" option.
144            // There's nothing to do here (since the incoming call is gone),
145            // so just bail out.
146            Log.i(TAG, "showRespondViaSmsPopup: null connection; bailing out...");
147            return;
148        }
149
150        // TODO: at this point we probably should re-check c.getAddress()
151        // and c.getNumberPresentation() for validity.  (i.e. recheck the
152        // same cases in InCallTouchUi.showIncomingCallWidget() where we
153        // should have disallowed the "respond via SMS" feature in the
154        // first place.)
155
156        String phoneNumber = c.getAddress();
157        if (DBG) log("- phoneNumber: " + phoneNumber);  // STOPSHIP: don't log PII
158        lv.setOnItemClickListener(new RespondViaSmsItemClickListener(phoneNumber));
159
160        AlertDialog.Builder builder = new AlertDialog.Builder(mInCallScreen)
161                .setCancelable(true)
162                .setOnCancelListener(new RespondViaSmsCancelListener())
163                .setView(lv);
164        mPopup = builder.create();
165        mPopup.show();
166    }
167
168    /**
169     * Dismiss the "Respond via SMS" popup if it's visible.
170     *
171     * This is safe to call even if the popup is already dismissed, and
172     * even if you never called showRespondViaSmsPopup() in the first
173     * place.
174     */
175    public void dismissPopup() {
176        if (mPopup != null) {
177            mPopup.dismiss();  // safe even if already dismissed
178            mPopup = null;
179        }
180    }
181
182    /**
183     * OnItemClickListener for the "Respond via SMS" popup.
184     */
185    public class RespondViaSmsItemClickListener implements AdapterView.OnItemClickListener {
186        // Phone number to send the SMS to.
187        private String mPhoneNumber;
188
189        public RespondViaSmsItemClickListener(String phoneNumber) {
190            mPhoneNumber = phoneNumber;
191        }
192
193        /**
194         * Handles the user selecting an item from the popup.
195         */
196        public void onItemClick(AdapterView<?> parent,  // The ListView
197                                View view,  // The TextView that was clicked
198                                int position,
199                                long id) {
200            if (DBG) log("RespondViaSmsItemClickListener.onItemClick(" + position + ")...");
201            String message = (String) parent.getItemAtPosition(position);
202            if (DBG) log("- message: '" + message + "'");
203
204            // The "Custom" choice is a special case.
205            // (For now, it's guaranteed to be the last item.)
206            if (position == (parent.getCount() - 1)) {
207                // Take the user to the standard SMS compose UI.
208                launchSmsCompose(mPhoneNumber);
209            } else {
210                // Send the selected message immediately with no user interaction.
211                sendText(mPhoneNumber, message);
212
213                // ...and show a brief confirmation to the user (since
214                // otherwise it's hard to be sure that anything actually
215                // happened.)
216                final Resources res = mInCallScreen.getResources();
217                String formatString = res.getString(R.string.respond_via_sms_confirmation_format);
218                String confirmationMsg = String.format(formatString, mPhoneNumber);
219                Toast.makeText(mInCallScreen,
220                               confirmationMsg,
221                               Toast.LENGTH_LONG).show();
222
223                // TODO: If the device is locked, this toast won't actually ever
224                // be visible!  (That's because we're about to dismiss the call
225                // screen, which means that the device will return to the
226                // keyguard.  But toasts aren't visible on top of the keyguard.)
227                // Possible fixes:
228                // (1) Is it possible to allow a specific Toast to be visible
229                //     on top of the keyguard?
230                // (2) Artifically delay the dismissCallScreen() call by 3
231                //     seconds to allow the toast to be seen?
232                // (3) Don't use a toast at all; instead use a transient state
233                //     of the InCallScreen (perhaps via the InCallUiState
234                //     progressIndication feature), and have that state be
235                //     visible for 3 seconds before calling dismissCallScreen().
236            }
237
238            // At this point the user is done dealing with the incoming call, so
239            // there's no reason to keep it around.  (It's also confusing for
240            // the "incoming call" icon in the status bar to still be visible.)
241            // So reject the call now.
242            mInCallScreen.hangupRingingCall();
243
244            PhoneApp.getInstance().dismissCallScreen();
245        }
246    }
247
248    /**
249     * OnCancelListener for the "Respond via SMS" popup.
250     */
251    public class RespondViaSmsCancelListener implements DialogInterface.OnCancelListener {
252        public RespondViaSmsCancelListener() {
253        }
254
255        /**
256         * Handles the user canceling the popup, either by touching
257         * outside the popup or by pressing Back.
258         */
259        public void onCancel(DialogInterface dialog) {
260            if (DBG) log("RespondViaSmsCancelListener.onCancel()...");
261
262            // If the user cancels the popup, this presumably means that
263            // they didn't actually mean to bring up the "Respond via SMS"
264            // UI in the first place (and instead want to go back to the
265            // state where they can either answer or reject the call.)
266            // So restart the ringer and bring back the regular incoming
267            // call UI.
268
269            // This will have no effect if the incoming call isn't still ringing.
270            PhoneApp.getInstance().notifier.restartRinger();
271
272            // We hid the MultiWaveView widget way back in
273            // InCallTouchUi.onTrigger(), when the user first selected
274            // the "SMS" trigger.
275            //
276            // To bring it back, just force the entire InCallScreen to
277            // update itself based on the current telephony state.
278            // (Assuming the incoming call is still ringing, this will
279            // cause the incoming call widget to reappear.)
280            mInCallScreen.requestUpdateScreen();
281        }
282    }
283
284    /**
285     * Sends a text message without any interaction from the user.
286     */
287    private void sendText(String phoneNumber, String message) {
288        // STOPSHIP: disable all logging of PII (everywhere in this file)
289        if (DBG) log("sendText: number "
290                     + phoneNumber + ", message '" + message + "'");
291
292        Uri uri = Uri.fromParts(Constants.SCHEME_SMSTO, phoneNumber, null);
293        Intent intent = new Intent("com.android.mms.intent.action.SENDTO_NO_CONFIRMATION", uri);
294        intent.putExtra(Intent.EXTRA_TEXT, message);
295        mInCallScreen.startService(intent);
296    }
297
298    /**
299     * Brings up the standard SMS compose UI.
300     */
301    private void launchSmsCompose(String phoneNumber) {
302        if (DBG) log("launchSmsCompose: number " + phoneNumber);
303
304        Uri uri = Uri.fromParts(Constants.SCHEME_SMS, phoneNumber, null);
305        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
306
307        if (DBG) log("- Launching SMS compose UI: " + intent);  // STOPSHIP: disable logging of PII
308        mInCallScreen.startActivity(intent);
309
310        // TODO: One open issue here: if the user selects "Custom message"
311        // for an incoming call while the device was locked, and the user
312        // does *not* have a secure keyguard set, we bring up the
313        // non-secure keyguard at this point :-(
314        // Instead, we should immediately go to the SMS compose UI.
315        //
316        // I *believe* the fix is for the SMS compose activity to set the
317        // FLAG_DISMISS_KEYGUARD window flag (which will cause the
318        // keyguard to be dismissed *only* if it is not a secure lock
319        // keyguard.)
320        //
321        // But it there an equivalent way for me to accomplish that here,
322        // without needing to change the SMS app?
323        //
324        // In any case, I'm pretty sure the SMS UI should *not* to set
325        // FLAG_SHOW_WHEN_LOCKED, since we do want the force the user to
326        // enter their lock pattern or PIN at this point if they have a
327        // secure keyguard set.
328    }
329
330
331    /**
332     * Settings activity under "Call settings" to let you manage the
333     * canned responses; see respond_via_sms_settings.xml
334     */
335    public static class Settings extends PreferenceActivity
336            implements Preference.OnPreferenceChangeListener {
337        @Override
338        protected void onCreate(Bundle icicle) {
339            super.onCreate(icicle);
340            if (DBG) log("Settings: onCreate()...");
341
342            getPreferenceManager().setSharedPreferencesName(SHARED_PREFERENCES_NAME);
343
344            // This preference screen is ultra-simple; it's just 4 plain
345            // <EditTextPreference>s, one for each of the 4 "canned responses".
346            //
347            // The only nontrivial thing we do here is copy the text value of
348            // each of those EditTextPreferences and use it as the preference's
349            // "title" as well, so that the user will immediately see all 4
350            // strings when they arrive here.
351            //
352            // Also, listen for change events (since we'll need to update the
353            // title any time the user edits one of the strings.)
354
355            addPreferencesFromResource(R.xml.respond_via_sms_settings);
356
357            EditTextPreference pref;
358            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_1);
359            pref.setTitle(pref.getText());
360            pref.setOnPreferenceChangeListener(this);
361
362            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_2);
363            pref.setTitle(pref.getText());
364            pref.setOnPreferenceChangeListener(this);
365
366            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_3);
367            pref.setTitle(pref.getText());
368            pref.setOnPreferenceChangeListener(this);
369
370            pref = (EditTextPreference) findPreference(KEY_CANNED_RESPONSE_PREF_4);
371            pref.setTitle(pref.getText());
372            pref.setOnPreferenceChangeListener(this);
373
374            ActionBar actionBar = getActionBar();
375            if (actionBar != null) {
376                // android.R.id.home will be triggered in onOptionsItemSelected()
377                actionBar.setDisplayHomeAsUpEnabled(true);
378            }
379        }
380
381        // Preference.OnPreferenceChangeListener implementation
382        public boolean onPreferenceChange(Preference preference, Object newValue) {
383            if (DBG) log("onPreferenceChange: key = " + preference.getKey());
384            if (DBG) log("  preference = '" + preference + "'");
385            if (DBG) log("  newValue = '" + newValue + "'");
386
387            EditTextPreference pref = (EditTextPreference) preference;
388
389            // Copy the new text over to the title, just like in onCreate().
390            // (Watch out: onPreferenceChange() is called *before* the
391            // Preference itself gets updated, so we need to use newValue here
392            // rather than pref.getText().)
393            pref.setTitle((String) newValue);
394
395            return true;  // means it's OK to update the state of the Preference with the new value
396        }
397
398        @Override
399        public boolean onOptionsItemSelected(MenuItem item) {
400            final int itemId = item.getItemId();
401            if (itemId == android.R.id.home) {  // See ActionBar#setDisplayHomeAsUpEnabled()
402                CallFeaturesSetting.goUpToTopLevelSetting(this);
403                return true;
404            }
405            return super.onOptionsItemSelected(item);
406        }
407    }
408
409    /**
410     * Read the (customizable) canned responses from SharedPreferences,
411     * or from defaults if the user has never actually brought up
412     * the Settings UI.
413     *
414     * This method does disk I/O (reading the SharedPreferences file)
415     * so don't call it from the main thread.
416     *
417     * @see RespondViaSmsManager$Settings
418     */
419    private String[] loadCannedResponses() {
420        if (DBG) log("loadCannedResponses()...");
421
422        SharedPreferences prefs =
423                mInCallScreen.getSharedPreferences(SHARED_PREFERENCES_NAME,
424                                                   Context.MODE_PRIVATE);
425        final Resources res = mInCallScreen.getResources();
426
427        String[] responses = new String[NUM_CANNED_RESPONSES];
428
429        // Note the default values here must agree with the corresponding
430        // android:defaultValue attributes in respond_via_sms_settings.xml.
431
432        responses[0] = prefs.getString(KEY_CANNED_RESPONSE_PREF_1,
433                                       res.getString(R.string.respond_via_sms_canned_response_1));
434        responses[1] = prefs.getString(KEY_CANNED_RESPONSE_PREF_2,
435                                       res.getString(R.string.respond_via_sms_canned_response_2));
436        responses[2] = prefs.getString(KEY_CANNED_RESPONSE_PREF_3,
437                                       res.getString(R.string.respond_via_sms_canned_response_3));
438        responses[3] = prefs.getString(KEY_CANNED_RESPONSE_PREF_4,
439                                       res.getString(R.string.respond_via_sms_canned_response_4));
440        return responses;
441    }
442
443    // TODO: Don't call loadCannedResponses() from the UI thread.
444    //
445    // We should either (1) kick off a background task when the call first
446    // starts ringing (probably triggered from the InCallScreen
447    // onNewRingingConnection() method) which would run loadCannedResponses()
448    // and stash the result away in mCannedResponses, or (2) use an
449    // OnSharedPreferenceChangeListener to listen for changes to this
450    // SharedPreferences instance, and use that to kick off the background task.
451    //
452    // In either case:
453    //
454    // - Make sure we recover sanely if mCannedResponses is still null when it's
455    //   actually time to show the popup (i.e. if the background task was too
456    //   slow, or if the background task never got started for some reason)
457    //
458    // - Make sure that all setting and getting of mCannedResponses happens
459    //   inside a synchronized block
460    //
461    // - If we kick off the background task when the call first starts ringing,
462    //   consider delaying that until the incoming-call UI actually comes to the
463    //   foreground; this way we won't steal any CPU away from the caller-id
464    //   query.  Maybe do it from InCallScreen.onResume()?
465    //   Or InCallTouchUi.showIncomingCallWidget()?
466
467
468    private static void log(String msg) {
469        Log.d(TAG, msg);
470    }
471}
472