1/* 2 * Copyright (C) 2013 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.bluetooth.mapapi; 18 19import android.content.ContentProvider; 20import android.content.ContentResolver; 21import android.content.ContentValues; 22import android.content.Context; 23import android.content.UriMatcher; 24import android.content.pm.ProviderInfo; 25import android.database.Cursor; 26import android.net.Uri; 27import android.os.AsyncTask; 28import android.os.Binder; 29import android.os.Bundle; 30import android.os.ParcelFileDescriptor; 31import android.util.Log; 32 33import java.io.FileInputStream; 34import java.io.FileNotFoundException; 35import java.io.FileOutputStream; 36import java.io.IOException; 37import java.util.List; 38import java.util.Map; 39import java.util.Map.Entry; 40import java.util.Set; 41 42/** 43 * A base implementation of the BluetoothMapEmailContract. 44 * A base class for a ContentProvider that allows access to Email messages from a Bluetooth 45 * device through the Message Access Profile. 46 */ 47public abstract class BluetoothMapEmailProvider extends ContentProvider { 48 49 private static final String TAG = "BluetoothMapEmailProvider"; 50 private static final boolean D = true; 51 52 private static final int MATCH_ACCOUNT = 1; 53 private static final int MATCH_MESSAGE = 2; 54 private static final int MATCH_FOLDER = 3; 55 56 protected ContentResolver mResolver; 57 58 private Uri CONTENT_URI = null; 59 private String mAuthority; 60 private UriMatcher mMatcher; 61 62 63 private PipeReader mPipeReader = new PipeReader(); 64 private PipeWriter mPipeWriter = new PipeWriter(); 65 66 /** 67 * Write the content of a message to a stream as MIME encoded RFC-2822 data. 68 * @param accountId the ID of the account to which the message belong 69 * @param messageId the ID of the message to write to the stream 70 * @param includeAttachment true if attachments should be included 71 * @param download true if any missing part of the message shall be downloaded 72 * before written to the stream. The download flag will determine 73 * whether or not attachments shall be downloaded or only the message content. 74 * @param out the FileOurputStream to write to. 75 * @throws IOException 76 */ 77 abstract protected void WriteMessageToStream(long accountId, long messageId, 78 boolean includeAttachment, boolean download, FileOutputStream out) 79 throws IOException; 80 81 /** 82 * @return the CONTENT_URI exposed. This will be used to send out notifications. 83 */ 84 abstract protected Uri getContentUri(); 85 86 /** 87 * Implementation is provided by the parent class. 88 */ 89 @Override 90 public void attachInfo(Context context, ProviderInfo info) { 91 mAuthority = info.authority; 92 93 mMatcher = new UriMatcher(UriMatcher.NO_MATCH); 94 mMatcher.addURI(mAuthority, BluetoothMapContract.TABLE_ACCOUNT, MATCH_ACCOUNT); 95 mMatcher.addURI(mAuthority, "#/"+BluetoothMapContract.TABLE_FOLDER, MATCH_FOLDER); 96 mMatcher.addURI(mAuthority, "#/"+BluetoothMapContract.TABLE_MESSAGE, MATCH_MESSAGE); 97 98 // Sanity check our setup 99 if (!info.exported) { 100 throw new SecurityException("Provider must be exported"); 101 } 102 // Enforce correct permissions are used 103 if (!android.Manifest.permission.BLUETOOTH_MAP.equals(info.writePermission)){ 104 throw new SecurityException("Provider must be protected by " + 105 android.Manifest.permission.BLUETOOTH_MAP); 106 } 107 mResolver = context.getContentResolver(); 108 super.attachInfo(context, info); 109 } 110 111 112 /** 113 * Interface to write a stream of data to a pipe. Use with 114 * {@link ContentProvider#openPipeHelper}. 115 */ 116 public interface PipeDataReader<T> { 117 /** 118 * Called from a background thread to stream data from a pipe. 119 * Note that the pipe is blocking, so this thread can block on 120 * reads for an arbitrary amount of time if the client is slow 121 * at writing. 122 * 123 * @param input The pipe where data should be read. This will be 124 * closed for you upon returning from this function. 125 * @param uri The URI whose data is to be written. 126 * @param mimeType The desired type of data to be written. 127 * @param opts Options supplied by caller. 128 * @param args Your own custom arguments. 129 */ 130 public void readDataFromPipe(ParcelFileDescriptor input, Uri uri, String mimeType, 131 Bundle opts, T args); 132 } 133 134 public class PipeReader implements PipeDataReader<Cursor> { 135 /** 136 * Read the data from the pipe and generate a message. 137 * Use the message to do an update of the message specified by the URI. 138 */ 139 @Override 140 public void readDataFromPipe(ParcelFileDescriptor input, Uri uri, 141 String mimeType, Bundle opts, Cursor args) { 142 Log.v(TAG, "readDataFromPipe(): uri=" + uri.toString()); 143 FileInputStream fIn = null; 144 try { 145 fIn = new FileInputStream(input.getFileDescriptor()); 146 long messageId = Long.valueOf(uri.getLastPathSegment()); 147 long accountId = Long.valueOf(getAccountId(uri)); 148 UpdateMimeMessageFromStream(fIn, accountId, messageId); 149 } catch (IOException e) { 150 Log.w(TAG,"IOException: ", e); 151 /* TODO: How to signal the error to the calling entity? Had expected readDataFromPipe 152 * to throw IOException? 153 */ 154 } finally { 155 try { 156 if(fIn != null) 157 fIn.close(); 158 } catch (IOException e) { 159 Log.w(TAG,e); 160 } 161 } 162 } 163 } 164 165 /** 166 * Read a MIME encoded RFC-2822 fileStream and update the message content. 167 * The Date and/or From headers may not be present in the MIME encoded 168 * message, and this function shall add appropriate values if the headers 169 * are missing. From should be set to the owner of the account. 170 * 171 * @param input the file stream to read data from 172 * @param accountId the accountId 173 * @param messageId ID of the message to update 174 */ 175 abstract protected void UpdateMimeMessageFromStream(FileInputStream input, long accountId, 176 long messageId) throws IOException; 177 178 public class PipeWriter implements PipeDataWriter<Cursor> { 179 /** 180 * Generate a message based on the cursor, and write the encoded data to the stream. 181 */ 182 183 public void writeDataToPipe(ParcelFileDescriptor output, Uri uri, String mimeType, 184 Bundle opts, Cursor c) { 185 if (D) Log.d(TAG, "writeDataToPipe(): uri=" + uri.toString() + 186 " - getLastPathSegment() = " + uri.getLastPathSegment()); 187 188 FileOutputStream fout = null; 189 190 try { 191 fout = new FileOutputStream(output.getFileDescriptor()); 192 193 boolean includeAttachments = true; 194 boolean download = false; 195 List<String> segments = uri.getPathSegments(); 196 long messageId = Long.parseLong(segments.get(2)); 197 long accountId = Long.parseLong(getAccountId(uri)); 198 if(segments.size() >= 4) { 199 String format = segments.get(3); 200 if(format.equalsIgnoreCase(BluetoothMapContract.FILE_MSG_NO_ATTACHMENTS)) { 201 includeAttachments = false; 202 } else if(format.equalsIgnoreCase(BluetoothMapContract.FILE_MSG_DOWNLOAD_NO_ATTACHMENTS)) { 203 includeAttachments = false; 204 download = true; 205 } else if(format.equalsIgnoreCase(BluetoothMapContract.FILE_MSG_DOWNLOAD)) { 206 download = true; 207 } 208 } 209 210 WriteMessageToStream(accountId, messageId, includeAttachments, download, fout); 211 } catch (IOException e) { 212 Log.w(TAG, e); 213 /* TODO: How to signal the error to the calling entity? Had expected writeDataToPipe 214 * to throw IOException? 215 */ 216 } finally { 217 try { 218 fout.flush(); 219 } catch (IOException e) { 220 Log.w(TAG, "IOException: ", e); 221 } 222 try { 223 fout.close(); 224 } catch (IOException e) { 225 Log.w(TAG, "IOException: ", e); 226 } 227 } 228 } 229 } 230 231 /** 232 * This function shall be called when any Account database content have changed 233 * to Notify any attached observers. 234 * @param accountId the ID of the account that changed. Null is a valid value, 235 * if accountId is unknown or multiple accounts changed. 236 */ 237 protected void onAccountChanged(String accountId) { 238 Uri newUri = null; 239 240 if(mAuthority == null){ 241 return; 242 } 243 if(accountId == null){ 244 newUri = BluetoothMapContract.buildAccountUri(mAuthority); 245 } else { 246 newUri = BluetoothMapContract.buildAccountUriwithId(mAuthority, accountId); 247 } 248 if(D) Log.d(TAG,"onAccountChanged() accountId = " + accountId + " URI: " + newUri); 249 mResolver.notifyChange(newUri, null); 250 } 251 252 /** 253 * This function shall be called when any Message database content have changed 254 * to notify any attached observers. 255 * @param accountId Null is a valid value, if accountId is unknown, but 256 * recommended for increased performance. 257 * @param messageId Null is a valid value, if multiple messages changed or the 258 * messageId is unknown, but recommended for increased performance. 259 */ 260 protected void onMessageChanged(String accountId, String messageId) { 261 Uri newUri = null; 262 263 if(mAuthority == null){ 264 return; 265 } 266 267 if(accountId == null){ 268 newUri = BluetoothMapContract.buildMessageUri(mAuthority); 269 } else { 270 if(messageId == null) 271 { 272 newUri = BluetoothMapContract.buildMessageUri(mAuthority,accountId); 273 } else { 274 newUri = BluetoothMapContract.buildMessageUriWithId(mAuthority,accountId, messageId); 275 } 276 } 277 if(D) Log.d(TAG,"onMessageChanged() accountId = " + accountId + " messageId = " + messageId + " URI: " + newUri); 278 mResolver.notifyChange(newUri, null); 279 } 280 281 /** 282 * Not used, this is just a dummy implementation. 283 */ 284 @Override 285 public String getType(Uri uri) { 286 return "Email"; 287 } 288 289 /** 290 * Open a file descriptor to a message. 291 * Two modes supported for read: With and without attachments. 292 * One mode exist for write and the actual content will be with or without 293 * attachments. 294 * 295 * Mode will be "r" or "w". 296 * 297 * URI format: 298 * The URI scheme is as follows. 299 * For messages with attachments: 300 * content://com.android.mail.bluetoothprovider/Messages/msgId# 301 * 302 * For messages without attachments: 303 * content://com.android.mail.bluetoothprovider/Messages/msgId#/NO_ATTACHMENTS 304 * 305 * UPDATE: For write. 306 * First create a message in the DB using insert into the message DB 307 * Then open a file handle to the #id 308 * write the data to a stream created from the fileHandle. 309 * 310 * @param uri the URI to open. ../Messages/#id 311 * @param mode the mode to use. The following modes exist: - UPDATE do not work - use URI 312 * - "read_with_attachments" - to read an e-mail including any attachments 313 * - "read_no_attachments" - to read an e-mail excluding any attachments 314 * - "write" - to add a mime encoded message to the database. This write 315 * should not trigger the message to be send. 316 * @return the ParcelFileDescriptor 317 * @throws FileNotFoundException 318 */ 319 @Override 320 public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { 321 long callingId = Binder.clearCallingIdentity(); 322 if(D)Log.d(TAG, "openFile(): uri=" + uri.toString() + " - getLastPathSegment() = " + 323 uri.getLastPathSegment()); 324 try { 325 /* To be able to do abstraction of the file IO, we simply ignore the URI at this 326 * point and let the read/write function implementations parse the URI. */ 327 if(mode.equals("w")) { 328 return openInversePipeHelper(uri, null, null, null, mPipeReader); 329 } else { 330 return openPipeHelper (uri, null, null, null, mPipeWriter); 331 } 332 } catch (IOException e) { 333 Log.w(TAG,e); 334 } finally { 335 Binder.restoreCallingIdentity(callingId); 336 } 337 return null; 338 } 339 340 /** 341 * A helper function for implementing {@link #openFile}, for 342 * creating a data pipe and background thread allowing you to stream 343 * data back from the client. This function returns a new 344 * ParcelFileDescriptor that should be returned to the caller (the caller 345 * is responsible for closing it). 346 * 347 * @param uri The URI whose data is to be written. 348 * @param mimeType The desired type of data to be written. 349 * @param opts Options supplied by caller. 350 * @param args Your own custom arguments. 351 * @param func Interface implementing the function that will actually 352 * stream the data. 353 * @return Returns a new ParcelFileDescriptor holding the read side of 354 * the pipe. This should be returned to the caller for reading; the caller 355 * is responsible for closing it when done. 356 */ 357 private <T> ParcelFileDescriptor openInversePipeHelper(final Uri uri, final String mimeType, 358 final Bundle opts, final T args, final PipeDataReader<T> func) 359 throws FileNotFoundException { 360 try { 361 final ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe(); 362 363 AsyncTask<Object, Object, Object> task = new AsyncTask<Object, Object, Object>() { 364 @Override 365 protected Object doInBackground(Object... params) { 366 func.readDataFromPipe(fds[0], uri, mimeType, opts, args); 367 try { 368 fds[0].close(); 369 } catch (IOException e) { 370 Log.w(TAG, "Failure closing pipe", e); 371 } 372 return null; 373 } 374 }; 375 task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Object[])null); 376 377 return fds[1]; 378 } catch (IOException e) { 379 throw new FileNotFoundException("failure making pipe"); 380 } 381 } 382 383 /** 384 * The MAP specification states that a delete request from MAP client is a folder shift to the 385 * 'deleted' folder. 386 * Only use case of delete() is when transparency is requested for push messages, then 387 * message should not remain in sent folder and therefore must be deleted 388 */ 389 @Override 390 public int delete(Uri uri, String where, String[] selectionArgs) { 391 if (D) Log.d(TAG, "delete(): uri=" + uri.toString() ); 392 int result = 0; 393 394 String table = uri.getPathSegments().get(1); 395 if(table == null) 396 throw new IllegalArgumentException("Table missing in URI"); 397 // the id of the entry to be deleted from the database 398 String messageId = uri.getLastPathSegment(); 399 if (messageId == null) 400 throw new IllegalArgumentException("Message ID missing in update values!"); 401 402 403 String accountId = getAccountId(uri); 404 if (accountId == null) 405 throw new IllegalArgumentException("Account ID missing in update values!"); 406 407 long callingId = Binder.clearCallingIdentity(); 408 try { 409 if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { 410 return deleteMessage(accountId, messageId); 411 } else { 412 if (D) Log.w(TAG, "Unknown table name: " + table); 413 return result; 414 } 415 } finally { 416 Binder.restoreCallingIdentity(callingId); 417 } 418 } 419 420 /** 421 * This function deletes a message. 422 * @param accountId the ID of the Account 423 * @param messageId the ID of the message to delete. 424 * @return the number of messages deleted - 0 if the message was not found. 425 */ 426 abstract protected int deleteMessage(String accountId, String messageId); 427 428 /** 429 * Insert is used to add new messages to the data base. 430 * Insert message approach: 431 * - Insert an empty message to get an _id with only a folder_id 432 * - Open the _id for write 433 * - Write the message content 434 * (When the writer completes, this provider should do an update of the message) 435 */ 436 @Override 437 public Uri insert(Uri uri, ContentValues values) { 438 String table = uri.getLastPathSegment(); 439 if(table == null){ 440 throw new IllegalArgumentException("Table missing in URI"); 441 } 442 String accountId = getAccountId(uri); 443 Long folderId = values.getAsLong(BluetoothMapContract.MessageColumns.FOLDER_ID); 444 if(folderId == null) { 445 throw new IllegalArgumentException("FolderId missing in ContentValues"); 446 } 447 448 String id; // the id of the entry inserted into the database 449 long callingId = Binder.clearCallingIdentity(); 450 Log.d(TAG, "insert(): uri=" + uri.toString() + " - getLastPathSegment() = " + 451 uri.getLastPathSegment()); 452 try { 453 if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { 454 id = insertMessage(accountId, folderId.toString()); 455 if(D) Log.i(TAG, "insert() ID: " + id); 456 return Uri.parse(uri.toString() + "/" + id); 457 } else { 458 Log.w(TAG, "Unknown table name: " + table); 459 return null; 460 } 461 } finally { 462 Binder.restoreCallingIdentity(callingId); 463 } 464 } 465 466 467 /** 468 * Inserts an empty message into the Message data base in the specified folder. 469 * This is done before the actual message content is written by fileIO. 470 * @param accountId the ID of the account 471 * @param folderId the ID of the folder to create a new message in. 472 * @return the message id as a string 473 */ 474 abstract protected String insertMessage(String accountId, String folderId); 475 476 /** 477 * Utility function to build a projection based on a projectionMap. 478 * 479 * "btColumnName" -> "emailColumnName as btColumnName" for each entry. 480 * 481 * This supports SQL statements in the emailColumnName entry. 482 * @param projection 483 * @param projectionMap <btColumnName, emailColumnName> 484 * @return the converted projection 485 */ 486 protected String[] convertProjection(String[] projection, Map<String,String> projectionMap) { 487 String[] newProjection = new String[projection.length]; 488 for(int i = 0; i < projection.length; i++) { 489 newProjection[i] = projectionMap.get(projection[i]) + " as " + projection[i]; 490 } 491 return newProjection; 492 } 493 494 /** 495 * This query needs to map from the data used in the e-mail client to BluetoothMapContract type of data. 496 */ 497 @Override 498 public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, 499 String sortOrder) { 500 long callingId = Binder.clearCallingIdentity(); 501 try { 502 String accountId = null; 503 switch (mMatcher.match(uri)) { 504 case MATCH_ACCOUNT: 505 return queryAccount(projection, selection, selectionArgs, sortOrder); 506 case MATCH_FOLDER: 507 accountId = getAccountId(uri); 508 return queryFolder(accountId, projection, selection, selectionArgs, sortOrder); 509 case MATCH_MESSAGE: 510 accountId = getAccountId(uri); 511 return queryMessage(accountId, projection, selection, selectionArgs, sortOrder); 512 default: 513 throw new UnsupportedOperationException("Unsupported Uri " + uri); 514 } 515 } finally { 516 Binder.restoreCallingIdentity(callingId); 517 } 518 } 519 520 /** 521 * Query account information. 522 * This function shall return only exposable e-mail accounts. Hence shall not 523 * return accounts that has policies suggesting not to be shared them. 524 * @param projection 525 * @param selection 526 * @param selectionArgs 527 * @param sortOrder 528 * @return a cursor to the accounts that are subject to exposure over BT. 529 */ 530 abstract protected Cursor queryAccount(String[] projection, String selection, String[] selectionArgs, 531 String sortOrder); 532 533 /** 534 * Filter out the non usable folders and ensure to name the mandatory folders 535 * inbox, outbox, sent, deleted and draft. 536 * @param accountId 537 * @param projection 538 * @param selection 539 * @param selectionArgs 540 * @param sortOrder 541 * @return 542 */ 543 abstract protected Cursor queryFolder(String accountId, String[] projection, String selection, String[] selectionArgs, 544 String sortOrder); 545 /** 546 * For the message table the selection (where clause) can only include the following columns: 547 * date: less than, greater than and equals 548 * flagRead: = 1 or = 0 549 * flagPriority: = 1 or = 0 550 * folder_id: the ID of the folder only equals 551 * toList: partial name/address search 552 * ccList: partial name/address search 553 * bccList: partial name/address search 554 * fromList: partial name/address search 555 * Additionally the COUNT and OFFSET shall be supported. 556 * @param accountId the ID of the account 557 * @param projection 558 * @param selection 559 * @param selectionArgs 560 * @param sortOrder 561 * @return a cursor to query result 562 */ 563 abstract protected Cursor queryMessage(String accountId, String[] projection, String selection, String[] selectionArgs, 564 String sortOrder); 565 566 /** 567 * update() 568 * Messages can be modified in the following cases: 569 * - the folder_key of a message - hence the message can be moved to a new folder, 570 * but the content cannot be modified. 571 * - the FLAG_READ state can be changed. 572 * The selection statement will always be selection of a message ID, when updating a message, 573 * hence this function will be called multiple times if multiple messages must be updated 574 * due to the nature of the Bluetooth Message Access profile. 575 */ 576 @Override 577 public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { 578 579 String table = uri.getLastPathSegment(); 580 if(table == null){ 581 throw new IllegalArgumentException("Table missing in URI"); 582 } 583 if(selection != null) { 584 throw new IllegalArgumentException("selection shall not be used, ContentValues shall contain the data"); 585 } 586 587 long callingId = Binder.clearCallingIdentity(); 588 if(D)Log.w(TAG, "update(): uri=" + uri.toString() + " - getLastPathSegment() = " + 589 uri.getLastPathSegment()); 590 try { 591 if(table.equals(BluetoothMapContract.TABLE_ACCOUNT)) { 592 String accountId = values.getAsString(BluetoothMapContract.AccountColumns._ID); 593 if(accountId == null) { 594 throw new IllegalArgumentException("Account ID missing in update values!"); 595 } 596 Integer exposeFlag = values.getAsInteger(BluetoothMapContract.AccountColumns.FLAG_EXPOSE); 597 if(exposeFlag == null){ 598 throw new IllegalArgumentException("Expose flag missing in update values!"); 599 } 600 return updateAccount(accountId, exposeFlag); 601 } else if(table.equals(BluetoothMapContract.TABLE_FOLDER)) { 602 return 0; // We do not support changing folders 603 } else if(table.equals(BluetoothMapContract.TABLE_MESSAGE)) { 604 String accountId = getAccountId(uri); 605 Long messageId = values.getAsLong(BluetoothMapContract.MessageColumns._ID); 606 if(messageId == null) { 607 throw new IllegalArgumentException("Message ID missing in update values!"); 608 } 609 Long folderId = values.getAsLong(BluetoothMapContract.MessageColumns.FOLDER_ID); 610 Boolean flagRead = values.getAsBoolean(BluetoothMapContract.MessageColumns.FLAG_READ); 611 return updateMessage(accountId, messageId, folderId, flagRead); 612 } else { 613 if(D)Log.w(TAG, "Unknown table name: " + table); 614 return 0; 615 } 616 } finally { 617 Binder.restoreCallingIdentity(callingId); 618 } 619 } 620 621 /** 622 * Update an entry in the account table. Only the expose flag will be 623 * changed through this interface. 624 * @param accountId the ID of the account to change. 625 * @param flagExpose the updated value. 626 * @return the number of entries changed - 0 if account not found or value cannot be changed. 627 */ 628 abstract protected int updateAccount(String accountId, int flagExpose); 629 630 /** 631 * Update an entry in the message table. 632 * @param accountId ID of the account to which the messageId relates 633 * @param messageId the ID of the message to update 634 * @param folderId the new folder ID value to set - ignore if null. 635 * @param flagRead the new flagRead value to set - ignore if null. 636 * @return 637 */ 638 abstract protected int updateMessage(String accountId, Long messageId, Long folderId, Boolean flagRead); 639 640 641 @Override 642 public Bundle call(String method, String arg, Bundle extras) { 643 long callingId = Binder.clearCallingIdentity(); 644 if(D)Log.d(TAG, "call(): method=" + method + " arg=" + arg + "ThreadId: " + Thread.currentThread().getId()); 645 646 try { 647 if(method.equals(BluetoothMapContract.METHOD_UPDATE_FOLDER)) { 648 long accountId = extras.getLong(BluetoothMapContract.EXTRA_UPDATE_ACCOUNT_ID, -1); 649 if(accountId == -1) { 650 Log.w(TAG, "No account ID in CALL"); 651 return null; 652 } 653 long folderId = extras.getLong(BluetoothMapContract.EXTRA_UPDATE_FOLDER_ID, -1); 654 if(folderId == -1) { 655 Log.w(TAG, "No folder ID in CALL"); 656 return null; 657 } 658 int ret = syncFolder(accountId, folderId); 659 if(ret == 0) { 660 return new Bundle(); 661 } 662 return null; 663 } 664 } finally { 665 Binder.restoreCallingIdentity(callingId); 666 } 667 return null; 668 } 669 670 /** 671 * Trigger a sync of the specified folder. 672 * @param accountId the ID of the account that owns the folder 673 * @param folderId the ID of the folder. 674 * @return 0 at success 675 */ 676 abstract protected int syncFolder(long accountId, long folderId); 677 678 /** 679 * Need this to suppress warning in unit tests. 680 */ 681 @Override 682 public void shutdown() { 683 // Don't call super.shutdown(), which emits a warning... 684 } 685 686 /** 687 * Extract the BluetoothMapContract.AccountColumns._ID from the given URI. 688 */ 689 public static String getAccountId(Uri uri) { 690 final List<String> segments = uri.getPathSegments(); 691 if (segments.size() < 1) { 692 throw new IllegalArgumentException("No AccountId pressent in URI: " + uri); 693 } 694 return segments.get(0); 695 } 696} 697