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