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