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