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