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