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