• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.phone;
18 
19 import com.android.internal.telephony.Call;
20 import com.android.internal.telephony.Connection;
21 
22 import android.app.ActionBar;
23 import android.app.AlertDialog;
24 import android.app.Dialog;
25 import android.content.Context;
26 import android.content.DialogInterface;
27 import android.content.Intent;
28 import android.content.SharedPreferences;
29 import android.content.res.Resources;
30 import android.net.Uri;
31 import android.os.Bundle;
32 import android.os.SystemProperties;
33 import android.preference.EditTextPreference;
34 import android.preference.Preference;
35 import android.preference.PreferenceActivity;
36 import android.telephony.PhoneNumberUtils;
37 import android.text.TextUtils;
38 import android.util.Log;
39 import android.view.MenuItem;
40 import android.view.View;
41 import android.widget.AdapterView;
42 import android.widget.ArrayAdapter;
43 import android.widget.ListView;
44 import android.widget.Toast;
45 
46 import java.util.Arrays;
47 
48 /**
49  * Helper class to manage the "Respond via SMS" feature for incoming calls.
50  * @see InCallScreen.internalRespondViaSms()
51  */
52 public 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      */
RespondViaSmsManager()96     public RespondViaSmsManager() {
97     }
98 
setInCallScreenInstance(InCallScreen inCallScreen)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      */
showRespondViaSmsPopup(Call ringingCall)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      */
dismissPopup()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 
RespondViaSmsItemClickListener(String phoneNumber)193         public RespondViaSmsItemClickListener(String phoneNumber) {
194             mPhoneNumber = phoneNumber;
195         }
196 
197         /**
198          * Handles the user selecting an item from the popup.
199          */
onItemClick(AdapterView<?> parent, View view, int position, long id)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 {
RespondViaSmsCancelListener()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          */
onCancel(DialogInterface dialog)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      */
sendText(String phoneNumber, String message)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      */
launchSmsCompose(String phoneNumber)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
onCreate(Bundle icicle)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
onPreferenceChange(Preference preference, Object newValue)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
onOptionsItemSelected(MenuItem item)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      */
loadCannedResponses()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      */
allowRespondViaSmsForCall(Call ringingCall)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 
log(String msg)552     private static void log(String msg) {
553         Log.d(TAG, msg);
554     }
555 }
556