1 /* 2 * Copyright (C) 2008 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.GsmAlphabet; 20 21 import android.bluetooth.AtCommandHandler; 22 import android.bluetooth.AtCommandResult; 23 import android.bluetooth.AtParser; 24 import android.bluetooth.BluetoothDevice; 25 import android.bluetooth.HeadsetBase; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.database.Cursor; 29 import android.net.Uri; 30 import android.provider.CallLog.Calls; 31 import android.provider.ContactsContract.CommonDataKinds.Phone; 32 import android.provider.ContactsContract.PhoneLookup; 33 import android.telephony.PhoneNumberUtils; 34 import android.util.Log; 35 36 import java.util.HashMap; 37 38 /** 39 * Helper for managing phonebook presentation over AT commands 40 * @hide 41 */ 42 public class BluetoothAtPhonebook { 43 private static final String TAG = "BluetoothAtPhonebook"; 44 private static final boolean DBG = false; 45 46 /** The projection to use when querying the call log database in response 47 * to AT+CPBR for the MC, RC, and DC phone books (missed, received, and 48 * dialed calls respectively) 49 */ 50 private static final String[] CALLS_PROJECTION = new String[] { 51 Calls._ID, Calls.NUMBER 52 }; 53 54 /** The projection to use when querying the contacts database in response 55 * to AT+CPBR for the ME phonebook (saved phone numbers). 56 */ 57 private static final String[] PHONES_PROJECTION = new String[] { 58 Phone._ID, Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE 59 }; 60 61 /** Android supports as many phonebook entries as the flash can hold, but 62 * BT periphals don't. Limit the number we'll report. */ 63 private static final int MAX_PHONEBOOK_SIZE = 16384; 64 65 private static final String OUTGOING_CALL_WHERE = Calls.TYPE + "=" + Calls.OUTGOING_TYPE; 66 private static final String INCOMING_CALL_WHERE = Calls.TYPE + "=" + Calls.INCOMING_TYPE; 67 private static final String MISSED_CALL_WHERE = Calls.TYPE + "=" + Calls.MISSED_TYPE; 68 private static final String VISIBLE_PHONEBOOK_WHERE = Phone.IN_VISIBLE_GROUP + "=1"; 69 70 private class PhonebookResult { 71 public Cursor cursor; // result set of last query 72 public int numberColumn; 73 public int typeColumn; 74 public int nameColumn; 75 }; 76 77 private final Context mContext; 78 private final BluetoothHandsfree mHandsfree; 79 80 private String mCurrentPhonebook; 81 private String mCharacterSet = "UTF-8"; 82 83 private int mCpbrIndex1, mCpbrIndex2; 84 private boolean mCheckingAccessPermission; 85 86 // package and class name to which we send intent to check phone book access permission 87 private static final String ACCESS_AUTHORITY_PACKAGE = "com.android.settings"; 88 private static final String ACCESS_AUTHORITY_CLASS = 89 "com.android.settings.bluetooth.BluetoothPermissionRequest"; 90 private static final String BLUETOOTH_ADMIN_PERM = android.Manifest.permission.BLUETOOTH_ADMIN; 91 92 private final HashMap<String, PhonebookResult> mPhonebooks = 93 new HashMap<String, PhonebookResult>(4); 94 95 public BluetoothAtPhonebook(Context context, BluetoothHandsfree handsfree) { 96 mContext = context; 97 mHandsfree = handsfree; 98 mPhonebooks.put("DC", new PhonebookResult()); // dialled calls 99 mPhonebooks.put("RC", new PhonebookResult()); // received calls 100 mPhonebooks.put("MC", new PhonebookResult()); // missed calls 101 mPhonebooks.put("ME", new PhonebookResult()); // mobile phonebook 102 103 mCurrentPhonebook = "ME"; // default to mobile phonebook 104 105 mCpbrIndex1 = mCpbrIndex2 = -1; 106 mCheckingAccessPermission = false; 107 } 108 109 /** Returns the last dialled number, or null if no numbers have been called */ 110 public String getLastDialledNumber() { 111 String[] projection = {Calls.NUMBER}; 112 Cursor cursor = mContext.getContentResolver().query(Calls.CONTENT_URI, projection, 113 Calls.TYPE + "=" + Calls.OUTGOING_TYPE, null, Calls.DEFAULT_SORT_ORDER + 114 " LIMIT 1"); 115 if (cursor == null) return null; 116 117 if (cursor.getCount() < 1) { 118 cursor.close(); 119 return null; 120 } 121 cursor.moveToNext(); 122 int column = cursor.getColumnIndexOrThrow(Calls.NUMBER); 123 String number = cursor.getString(column); 124 cursor.close(); 125 return number; 126 } 127 128 public void register(AtParser parser) { 129 // Select Character Set 130 parser.register("+CSCS", new AtCommandHandler() { 131 @Override 132 public AtCommandResult handleReadCommand() { 133 String result = "+CSCS: \"" + mCharacterSet + "\""; 134 return new AtCommandResult(result); 135 } 136 @Override 137 public AtCommandResult handleSetCommand(Object[] args) { 138 if (args.length < 1) { 139 return new AtCommandResult(AtCommandResult.ERROR); 140 } 141 String characterSet = (String)args[0]; 142 characterSet = characterSet.replace("\"", ""); 143 if (characterSet.equals("GSM") || characterSet.equals("IRA") || 144 characterSet.equals("UTF-8") || characterSet.equals("UTF8")) { 145 mCharacterSet = characterSet; 146 return new AtCommandResult(AtCommandResult.OK); 147 } else { 148 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_SUPPORTED); 149 } 150 } 151 @Override 152 public AtCommandResult handleTestCommand() { 153 return new AtCommandResult( "+CSCS: (\"UTF-8\",\"IRA\",\"GSM\")"); 154 } 155 }); 156 157 // Select PhoneBook memory Storage 158 parser.register("+CPBS", new AtCommandHandler() { 159 @Override 160 public AtCommandResult handleReadCommand() { 161 // Return current size and max size 162 if ("SM".equals(mCurrentPhonebook)) { 163 return new AtCommandResult("+CPBS: \"SM\",0," + getMaxPhoneBookSize(0)); 164 } 165 166 PhonebookResult pbr = getPhonebookResult(mCurrentPhonebook, true); 167 if (pbr == null) { 168 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_ALLOWED); 169 } 170 int size = pbr.cursor.getCount(); 171 return new AtCommandResult("+CPBS: \"" + mCurrentPhonebook + "\"," + 172 size + "," + getMaxPhoneBookSize(size)); 173 } 174 @Override 175 public AtCommandResult handleSetCommand(Object[] args) { 176 // Select phonebook memory 177 if (args.length < 1 || !(args[0] instanceof String)) { 178 return new AtCommandResult(AtCommandResult.ERROR); 179 } 180 String pb = ((String)args[0]).trim(); 181 while (pb.endsWith("\"")) pb = pb.substring(0, pb.length() - 1); 182 while (pb.startsWith("\"")) pb = pb.substring(1, pb.length()); 183 if (getPhonebookResult(pb, false) == null && !"SM".equals(pb)) { 184 if (DBG) log("Dont know phonebook: '" + pb + "'"); 185 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_SUPPORTED); 186 } 187 mCurrentPhonebook = pb; 188 return new AtCommandResult(AtCommandResult.OK); 189 } 190 @Override 191 public AtCommandResult handleTestCommand() { 192 return new AtCommandResult("+CPBS: (\"ME\",\"SM\",\"DC\",\"RC\",\"MC\")"); 193 } 194 }); 195 196 // Read PhoneBook Entries 197 parser.register("+CPBR", new AtCommandHandler() { 198 @Override 199 public AtCommandResult handleSetCommand(Object[] args) { 200 // Phone Book Read Request 201 // AT+CPBR=<index1>[,<index2>] 202 203 if (mCpbrIndex1 != -1) { 204 /* handling a CPBR at the moment, reject this CPBR command */ 205 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_ALLOWED); 206 } 207 208 // Parse indexes 209 int index1; 210 int index2; 211 if (args.length < 1 || !(args[0] instanceof Integer)) { 212 return new AtCommandResult(AtCommandResult.ERROR); 213 } else { 214 index1 = (Integer)args[0]; 215 } 216 217 if (args.length == 1) { 218 index2 = index1; 219 } else if (!(args[1] instanceof Integer)) { 220 return mHandsfree.reportCmeError(BluetoothCmeError.TEXT_HAS_INVALID_CHARS); 221 } else { 222 index2 = (Integer)args[1]; 223 } 224 225 mCpbrIndex1 = index1; 226 mCpbrIndex2 = index2; 227 mCheckingAccessPermission = true; 228 229 if (checkAccessPermission()) { 230 mCheckingAccessPermission = false; 231 AtCommandResult atResult = processCpbrCommand(); 232 mCpbrIndex1 = mCpbrIndex2 = -1; 233 return atResult; 234 } 235 236 // no reponse here, will continue the process in handleAccessPermissionResult 237 return new AtCommandResult(AtCommandResult.UNSOLICITED); 238 }; 239 240 @Override 241 public AtCommandResult handleTestCommand() { 242 /* Ideally we should return the maximum range of valid index's 243 * for the selected phone book, but this causes problems for the 244 * Parrot CK3300. So instead send just the range of currently 245 * valid index's. 246 */ 247 int size; 248 if ("SM".equals(mCurrentPhonebook)) { 249 size = 0; 250 } else { 251 PhonebookResult pbr = getPhonebookResult(mCurrentPhonebook, false); 252 if (pbr == null) { 253 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_ALLOWED); 254 } 255 size = pbr.cursor.getCount(); 256 } 257 258 if (size == 0) { 259 /* Sending "+CPBR: (1-0)" can confused some carkits, send "1-1" 260 * instead */ 261 size = 1; 262 } 263 return new AtCommandResult("+CPBR: (1-" + size + "),30,30"); 264 } 265 }); 266 } 267 268 /* package */ void handleAccessPermissionResult(Intent intent) { 269 if (!mCheckingAccessPermission) { 270 return; 271 } 272 273 HeadsetBase headset = mHandsfree.getHeadset(); 274 // ASSERT: (headset != null) && headSet.isConnected() 275 // REASON: mCheckingAccessPermission is true, otherwise resetAtState 276 // has set mCheckingAccessPermission to false 277 278 if (intent.getAction().equals(BluetoothDevice.ACTION_CONNECTION_ACCESS_REPLY)) { 279 280 if (intent.getIntExtra(BluetoothDevice.EXTRA_CONNECTION_ACCESS_RESULT, 281 BluetoothDevice.CONNECTION_ACCESS_NO) == 282 BluetoothDevice.CONNECTION_ACCESS_YES) { 283 BluetoothDevice remoteDevice = headset.getRemoteDevice(); 284 if (intent.getBooleanExtra(BluetoothDevice.EXTRA_ALWAYS_ALLOWED, false)) { 285 remoteDevice.setTrust(true); 286 } 287 288 AtCommandResult cpbrResult = processCpbrCommand(); 289 headset.sendURC(cpbrResult.toString()); 290 } else { 291 headset.sendURC("ERROR"); 292 } 293 } 294 mCpbrIndex1 = mCpbrIndex2 = -1; 295 mCheckingAccessPermission = false; 296 } 297 298 /** Get the most recent result for the given phone book, 299 * with the cursor ready to go. 300 * If force then re-query that phonebook 301 * Returns null if the cursor is not ready 302 */ 303 private synchronized PhonebookResult getPhonebookResult(String pb, boolean force) { 304 if (pb == null) { 305 return null; 306 } 307 PhonebookResult pbr = mPhonebooks.get(pb); 308 if (pbr == null) { 309 pbr = new PhonebookResult(); 310 } 311 if (force || pbr.cursor == null) { 312 if (!queryPhonebook(pb, pbr)) { 313 return null; 314 } 315 } 316 317 return pbr; 318 } 319 320 private synchronized boolean queryPhonebook(String pb, PhonebookResult pbr) { 321 String where; 322 boolean ancillaryPhonebook = true; 323 324 if (pb.equals("ME")) { 325 ancillaryPhonebook = false; 326 where = VISIBLE_PHONEBOOK_WHERE; 327 } else if (pb.equals("DC")) { 328 where = OUTGOING_CALL_WHERE; 329 } else if (pb.equals("RC")) { 330 where = INCOMING_CALL_WHERE; 331 } else if (pb.equals("MC")) { 332 where = MISSED_CALL_WHERE; 333 } else { 334 return false; 335 } 336 337 if (pbr.cursor != null) { 338 pbr.cursor.close(); 339 pbr.cursor = null; 340 } 341 342 if (ancillaryPhonebook) { 343 pbr.cursor = mContext.getContentResolver().query( 344 Calls.CONTENT_URI, CALLS_PROJECTION, where, null, 345 Calls.DEFAULT_SORT_ORDER + " LIMIT " + MAX_PHONEBOOK_SIZE); 346 if (pbr.cursor == null) return false; 347 348 pbr.numberColumn = pbr.cursor.getColumnIndexOrThrow(Calls.NUMBER); 349 pbr.typeColumn = -1; 350 pbr.nameColumn = -1; 351 } else { 352 pbr.cursor = mContext.getContentResolver().query(Phone.CONTENT_URI, PHONES_PROJECTION, 353 where, null, Phone.NUMBER + " LIMIT " + MAX_PHONEBOOK_SIZE); 354 if (pbr.cursor == null) return false; 355 356 pbr.numberColumn = pbr.cursor.getColumnIndex(Phone.NUMBER); 357 pbr.typeColumn = pbr.cursor.getColumnIndex(Phone.TYPE); 358 pbr.nameColumn = pbr.cursor.getColumnIndex(Phone.DISPLAY_NAME); 359 } 360 Log.i(TAG, "Refreshed phonebook " + pb + " with " + pbr.cursor.getCount() + " results"); 361 return true; 362 } 363 364 synchronized void resetAtState() { 365 mCharacterSet = "UTF-8"; 366 mCpbrIndex1 = mCpbrIndex2 = -1; 367 mCheckingAccessPermission = false; 368 } 369 370 private synchronized int getMaxPhoneBookSize(int currSize) { 371 // some car kits ignore the current size and request max phone book 372 // size entries. Thus, it takes a long time to transfer all the 373 // entries. Use a heuristic to calculate the max phone book size 374 // considering future expansion. 375 // maxSize = currSize + currSize / 2 rounded up to nearest power of 2 376 // If currSize < 100, use 100 as the currSize 377 378 int maxSize = (currSize < 100) ? 100 : currSize; 379 maxSize += maxSize / 2; 380 return roundUpToPowerOfTwo(maxSize); 381 } 382 383 private int roundUpToPowerOfTwo(int x) { 384 x |= x >> 1; 385 x |= x >> 2; 386 x |= x >> 4; 387 x |= x >> 8; 388 x |= x >> 16; 389 return x + 1; 390 } 391 392 // process CPBR command after permission check 393 private AtCommandResult processCpbrCommand() 394 { 395 // Shortcut SM phonebook 396 if ("SM".equals(mCurrentPhonebook)) { 397 return new AtCommandResult(AtCommandResult.OK); 398 } 399 400 // Check phonebook 401 PhonebookResult pbr = getPhonebookResult(mCurrentPhonebook, false); 402 if (pbr == null) { 403 return mHandsfree.reportCmeError(BluetoothCmeError.OPERATION_NOT_ALLOWED); 404 } 405 406 // More sanity checks 407 // Send OK instead of ERROR if these checks fail. 408 // When we send error, certain kits like BMW disconnect the 409 // Handsfree connection. 410 if (pbr.cursor.getCount() == 0 || mCpbrIndex1 <= 0 || mCpbrIndex2 < mCpbrIndex1 || 411 mCpbrIndex2 > pbr.cursor.getCount() || mCpbrIndex1 > pbr.cursor.getCount()) { 412 return new AtCommandResult(AtCommandResult.OK); 413 } 414 415 // Process 416 AtCommandResult result = new AtCommandResult(AtCommandResult.OK); 417 int errorDetected = -1; // no error 418 pbr.cursor.moveToPosition(mCpbrIndex1 - 1); 419 for (int index = mCpbrIndex1; index <= mCpbrIndex2; index++) { 420 String number = pbr.cursor.getString(pbr.numberColumn); 421 String name = null; 422 int type = -1; 423 if (pbr.nameColumn == -1) { 424 // try caller id lookup 425 // TODO: This code is horribly inefficient. I saw it 426 // take 7 seconds to process 100 missed calls. 427 Cursor c = mContext.getContentResolver(). 428 query(Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number), 429 new String[] {PhoneLookup.DISPLAY_NAME, PhoneLookup.TYPE}, 430 null, null, null); 431 if (c != null) { 432 if (c.moveToFirst()) { 433 name = c.getString(0); 434 type = c.getInt(1); 435 } 436 c.close(); 437 } 438 if (DBG && name == null) log("Caller ID lookup failed for " + number); 439 440 } else { 441 name = pbr.cursor.getString(pbr.nameColumn); 442 } 443 if (name == null) name = ""; 444 name = name.trim(); 445 if (name.length() > 28) name = name.substring(0, 28); 446 447 if (pbr.typeColumn != -1) { 448 type = pbr.cursor.getInt(pbr.typeColumn); 449 name = name + "/" + getPhoneType(type); 450 } 451 452 if (number == null) number = ""; 453 int regionType = PhoneNumberUtils.toaFromString(number); 454 455 number = number.trim(); 456 number = PhoneNumberUtils.stripSeparators(number); 457 if (number.length() > 30) number = number.substring(0, 30); 458 if (number.equals("-1")) { 459 // unknown numbers are stored as -1 in our database 460 number = ""; 461 name = mContext.getString(R.string.unknown); 462 } 463 464 // TODO(): Handle IRA commands. It's basically 465 // a 7 bit ASCII character set. 466 if (!name.equals("") && mCharacterSet.equals("GSM")) { 467 byte[] nameByte = GsmAlphabet.stringToGsm8BitPacked(name); 468 if (nameByte == null) { 469 name = mContext.getString(R.string.unknown); 470 } else { 471 name = new String(nameByte); 472 } 473 } 474 475 result.addResponse("+CPBR: " + index + ",\"" + number + "\"," + 476 regionType + ",\"" + name + "\""); 477 if (!pbr.cursor.moveToNext()) { 478 break; 479 } 480 } 481 return result; 482 } 483 484 // Check if the remote device has premission to read our phone book 485 // Return true if it has the permission 486 // false if not known and we have sent our Intent to check 487 private boolean checkAccessPermission() { 488 BluetoothDevice remoteDevice = mHandsfree.getHeadset().getRemoteDevice(); 489 490 boolean trust = remoteDevice.getTrustState(); 491 492 if (trust) { 493 return true; 494 } 495 496 Intent intent = new Intent(BluetoothDevice.ACTION_CONNECTION_ACCESS_REQUEST); 497 intent.setClassName(ACCESS_AUTHORITY_PACKAGE, ACCESS_AUTHORITY_CLASS); 498 intent.putExtra(BluetoothDevice.EXTRA_ACCESS_REQUEST_TYPE, 499 BluetoothDevice.REQUEST_TYPE_PHONEBOOK_ACCESS); 500 intent.putExtra(BluetoothDevice.EXTRA_DEVICE, remoteDevice); 501 // Leave EXTRA_PACKAGE_NAME and EXTRA_CLASS_NAME field empty 502 // BluetoothHandsfree's broadcast receiver is anonymous, cannot be targeted 503 mContext.sendBroadcast(intent, BLUETOOTH_ADMIN_PERM); 504 505 return false; 506 } 507 508 private static String getPhoneType(int type) { 509 switch (type) { 510 case Phone.TYPE_HOME: 511 return "H"; 512 case Phone.TYPE_MOBILE: 513 return "M"; 514 case Phone.TYPE_WORK: 515 return "W"; 516 case Phone.TYPE_FAX_HOME: 517 case Phone.TYPE_FAX_WORK: 518 return "F"; 519 case Phone.TYPE_OTHER: 520 case Phone.TYPE_CUSTOM: 521 default: 522 return "O"; 523 } 524 } 525 526 private static void log(String msg) { 527 Log.d(TAG, msg); 528 } 529 } 530