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