1 /*
2  * Copyright (C) 2019 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.bluetooth.avrcpcontroller;
18 
19 import android.bluetooth.BluetoothDevice;
20 import android.bluetooth.BluetoothProfile;
21 import android.graphics.Bitmap;
22 import android.net.Uri;
23 import android.os.SystemProperties;
24 import android.util.Log;
25 
26 import com.android.obex.ResponseCodes;
27 
28 import java.util.Map;
29 import java.util.Set;
30 import java.util.UUID;
31 import java.util.concurrent.ConcurrentHashMap;
32 
33 /**
34  * Manager of all AVRCP Controller connections to remote devices' BIP servers for retrieving cover
35  * art.
36  *
37  * <p>When given an image handle and device, this manager will negotiate the downloaded image
38  * properties, download the image, and place it into a Content Provider for others to retrieve from
39  */
40 public class AvrcpCoverArtManager {
41     private static final String TAG = AvrcpCoverArtManager.class.getSimpleName();
42 
43     // Image Download Schemes for cover art
44     public static final String AVRCP_CONTROLLER_COVER_ART_SCHEME =
45             "persist.bluetooth.avrcpcontroller.BIP_DOWNLOAD_SCHEME";
46     public static final String SCHEME_NATIVE = "native";
47     public static final String SCHEME_THUMBNAIL = "thumbnail";
48 
49     private final AvrcpControllerService mService;
50     protected final Map<BluetoothDevice, AvrcpBipClient> mClients = new ConcurrentHashMap<>(1);
51     private Map<BluetoothDevice, AvrcpBipSession> mBipSessions = new ConcurrentHashMap<>(1);
52     private final AvrcpCoverArtStorage mCoverArtStorage;
53     private final Callback mCallback;
54     private final String mDownloadScheme;
55 
56     /**
57      * An object representing an image download event. Contains the information necessary to
58      * retrieve the image from storage.
59      */
60     public static class DownloadEvent {
61         final String mImageUuid;
62         final Uri mUri;
63 
DownloadEvent(String uuid, Uri uri)64         public DownloadEvent(String uuid, Uri uri) {
65             mImageUuid = uuid;
66             mUri = uri;
67         }
68 
getUuid()69         public String getUuid() {
70             return mImageUuid;
71         }
72 
getUri()73         public Uri getUri() {
74             return mUri;
75         }
76     }
77 
78     interface Callback {
79         /**
80          * Notify of a get image download completing
81          *
82          * @param device The device the image handle belongs to
83          * @param event The download event, containing the downloaded image's information
84          */
onImageDownloadComplete(BluetoothDevice device, DownloadEvent event)85         void onImageDownloadComplete(BluetoothDevice device, DownloadEvent event);
86     }
87 
88     /**
89      * A thread-safe collection of BIP connection specific imformation meant to be cleared each time
90      * a client disconnects from the Target's BIP OBEX server.
91      *
92      * <p>Currently contains the mapping of image handles seen to assigned UUIDs.
93      */
94     private static class AvrcpBipSession {
95         private Map<String, String> mUuids = new ConcurrentHashMap<>(1); /* handle -> UUID */
96         private Map<String, String> mHandles = new ConcurrentHashMap<>(1); /* UUID -> handle */
97 
getHandleUuid(String handle)98         public String getHandleUuid(String handle) {
99             if (!isValidImageHandle(handle)) return null;
100             String newUuid = UUID.randomUUID().toString();
101             String existingUuid = mUuids.putIfAbsent(handle, newUuid);
102             if (existingUuid != null) return existingUuid;
103             mHandles.put(newUuid, handle);
104             return newUuid;
105         }
106 
getUuidHandle(String uuid)107         public String getUuidHandle(String uuid) {
108             return mHandles.get(uuid);
109         }
110 
clearHandleUuids()111         public void clearHandleUuids() {
112             mUuids.clear();
113             mHandles.clear();
114         }
115 
getSessionHandles()116         public Set<String> getSessionHandles() {
117             return mUuids.keySet();
118         }
119     }
120 
121     /**
122      * Validate an image handle meets the AVRCP and BIP specifications
123      *
124      * <p>By the BIP specification that AVRCP uses, "Image handles are 7 character long strings
125      * containing only the digits 0 to 9."
126      *
127      * @return True if the input string is a valid image handle
128      */
isValidImageHandle(String handle)129     public static boolean isValidImageHandle(String handle) {
130         if (handle == null || handle.length() != 7) return false;
131         for (char c : handle.toCharArray()) {
132             if (!Character.isDigit(c)) {
133                 return false;
134             }
135         }
136         return true;
137     }
138 
AvrcpCoverArtManager(AvrcpControllerService service, Callback callback)139     public AvrcpCoverArtManager(AvrcpControllerService service, Callback callback) {
140         mService = service;
141         mCoverArtStorage = new AvrcpCoverArtStorage(mService);
142         mCallback = callback;
143         mDownloadScheme = SystemProperties.get(AVRCP_CONTROLLER_COVER_ART_SCHEME, SCHEME_THUMBNAIL);
144         mCoverArtStorage.clear();
145     }
146 
147     /**
148      * Create a client and connect to a remote device's BIP Image Pull Server
149      *
150      * @param device The remote Bluetooth device you wish to connect to
151      * @param psm The Protocol Service Multiplexer that the remote device is hosting the server on
152      * @return True if the connection is successfully queued, False otherwise.
153      */
connect(BluetoothDevice device, int psm)154     public synchronized boolean connect(BluetoothDevice device, int psm) {
155         debug("Connect " + device + ", psm: " + psm);
156         if (mClients.containsKey(device)) return false;
157         AvrcpBipClient client = new AvrcpBipClient(device, psm, new BipClientCallback(device));
158         client.connectAsync();
159         mClients.put(device, client);
160         mBipSessions.put(device, new AvrcpBipSession());
161         return true;
162     }
163 
164     /**
165      * Refresh the OBEX session of a connected client
166      *
167      * @param device The remote Bluetooth device you wish to refresh
168      * @return True if the refresh is successfully queued, False otherwise.
169      */
refreshSession(BluetoothDevice device)170     public synchronized boolean refreshSession(BluetoothDevice device) {
171         debug("Refresh OBEX session for " + device);
172         AvrcpBipClient client = getClient(device);
173         if (client == null) {
174             warn("No client for " + device);
175             return false;
176         }
177         client.refreshSession();
178         return true;
179     }
180 
181     /**
182      * Disconnect from a remote device's BIP Image Pull Server
183      *
184      * @param device The remote Bluetooth device you wish to disconnect from
185      * @return True if the disconnection is successfully queued, False otherwise.
186      */
disconnect(BluetoothDevice device)187     public synchronized boolean disconnect(BluetoothDevice device) {
188         debug("Disconnect " + device);
189         AvrcpBipClient client = getClient(device);
190         if (client == null) {
191             warn("No client for " + device);
192             return false;
193         }
194         client.shutdown();
195         mClients.remove(device);
196         mBipSessions.remove(device);
197         mCoverArtStorage.removeImagesForDevice(device);
198         return true;
199     }
200 
201     /**
202      * Cleanup all cover art related resources
203      *
204      * <p>Please call when you've committed to shutting down the service.
205      */
cleanup()206     public synchronized void cleanup() {
207         debug("Clean up and shutdown");
208         for (BluetoothDevice device : mClients.keySet()) {
209             disconnect(device);
210         }
211     }
212 
213     /**
214      * Get the client connection state for a particular device's BIP Client
215      *
216      * @param device The Bluetooth device you want connection status for
217      * @return Connection status, based on BluetoothProfile.STATE_* constants
218      */
getState(BluetoothDevice device)219     public int getState(BluetoothDevice device) {
220         AvrcpBipClient client = getClient(device);
221         if (client == null) return BluetoothProfile.STATE_DISCONNECTED;
222         return client.getState();
223     }
224 
225     /**
226      * Get the UUID for an image handle coming from a particular device.
227      *
228      * <p>This UUID is used to request and track downloads.
229      *
230      * <p>Image handles are only good for the life of the BIP client. Since this connection is torn
231      * down frequently by specification, we have a layer of indirection to the images in the form of
232      * an UUID. This UUID will allow images to be identified outside the connection lifecycle. It
233      * also allows handles to be reused by the target in ways that won't impact image consumer's
234      * cache schemes.
235      *
236      * @param device The Bluetooth device you want a handle from
237      * @param handle The image handle you want a UUID for
238      * @return A string UUID by which the handle can be identified during the life of the BIP
239      *     connection.
240      */
getUuidForHandle(BluetoothDevice device, String handle)241     public String getUuidForHandle(BluetoothDevice device, String handle) {
242         AvrcpBipSession session = getSession(device);
243         if (session == null || !isValidImageHandle(handle)) return null;
244         return session.getHandleUuid(handle);
245     }
246 
247     /**
248      * Get the handle thats associated with a particular UUID.
249      *
250      * <p>The handle must have been seen during this connection.
251      *
252      * @param device The Bluetooth device you want a handle from
253      * @param uuid The UUID you want the associated handle for
254      * @return The image handle associated with this UUID if it exists, null otherwise.
255      */
getHandleForUuid(BluetoothDevice device, String uuid)256     public String getHandleForUuid(BluetoothDevice device, String uuid) {
257         AvrcpBipSession session = getSession(device);
258         if (session == null || uuid == null) return null;
259         return session.getUuidHandle(uuid);
260     }
261 
clearHandleUuids(BluetoothDevice device)262     private void clearHandleUuids(BluetoothDevice device) {
263         AvrcpBipSession session = getSession(device);
264         if (session == null) return;
265         session.clearHandleUuids();
266     }
267 
268     /**
269      * Get the Uri of an image if it has already been downloaded.
270      *
271      * @param device The remote Bluetooth device you wish to get an image for
272      * @param imageUuid The UUID associated with the image you want
273      * @return A Uri the image can be found at, null if it does not exist
274      */
getImageUri(BluetoothDevice device, String imageUuid)275     public Uri getImageUri(BluetoothDevice device, String imageUuid) {
276         if (mCoverArtStorage.doesImageExist(device, imageUuid)) {
277             return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
278         }
279         return null;
280     }
281 
282     /**
283      * Download an image from a remote device and make it findable via the given uri
284      *
285      * <p>Downloading happens in three steps: 1) Get the available image formats by requesting the
286      * Image Properties 2) Determine the specific format we want the image in and turn it into an
287      * image descriptor 3) Get the image using the chosen descriptor
288      *
289      * <p>Getting image properties and the image are both asynchronous in nature.
290      *
291      * @param device The remote Bluetooth device you wish to download from
292      * @param imageUuid The UUID associated with the image you wish to download. This will be
293      *     translated into an image handle.
294      * @return A Uri that will be assign to the image once the download is complete
295      */
downloadImage(BluetoothDevice device, String imageUuid)296     public Uri downloadImage(BluetoothDevice device, String imageUuid) {
297         debug("Download Image - device: " + device + ", Handle: " + imageUuid);
298         AvrcpBipClient client = getClient(device);
299         if (client == null) {
300             error("Cannot download an image. No client is available.");
301             return null;
302         }
303 
304         // Check to see if we have the image already. No need to download it if we do have it.
305         if (mCoverArtStorage.doesImageExist(device, imageUuid)) {
306             debug("Image is already downloaded");
307             return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
308         }
309 
310         // Getting image properties will return via the callback created when connecting, which
311         // invokes the download image function after we're returned the properties. If we already
312         // have the image, GetImageProperties returns true but does not start a download.
313         String imageHandle = getHandleForUuid(device, imageUuid);
314         if (imageHandle == null) {
315             warn("No handle for UUID");
316             return null;
317         }
318         boolean status = client.getImageProperties(imageHandle);
319         if (!status) return null;
320 
321         // Return the Uri that the caller should use to retrieve the image
322         return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
323     }
324 
325     /**
326      * Get a specific downloaded image if it exists
327      *
328      * @param device The remote Bluetooth device associated with the image
329      * @param imageUuid The UUID associated with the image you wish to retrieve
330      */
getImage(BluetoothDevice device, String imageUuid)331     public Bitmap getImage(BluetoothDevice device, String imageUuid) {
332         return mCoverArtStorage.getImage(device, imageUuid);
333     }
334 
335     /**
336      * Remove a specific downloaded image if it exists
337      *
338      * @param device The remote Bluetooth device associated with the image
339      * @param imageUuid The UUID associated with the image you wish to remove
340      */
removeImage(BluetoothDevice device, String imageUuid)341     public void removeImage(BluetoothDevice device, String imageUuid) {
342         mCoverArtStorage.removeImage(device, imageUuid);
343     }
344 
345     /**
346      * Get a device's BIP client if it exists
347      *
348      * @param device The device you want the client for
349      * @return The AvrcpBipClient object associated with the device, or null if it doesn't exist
350      */
getClient(BluetoothDevice device)351     private AvrcpBipClient getClient(BluetoothDevice device) {
352         return mClients.get(device);
353     }
354 
355     /**
356      * Get a device's BIP session information, if it exists
357      *
358      * @param device The device you want the client for
359      * @return The AvrcpBipSession object associated with the device, or null if it doesn't exist
360      */
getSession(BluetoothDevice device)361     private AvrcpBipSession getSession(BluetoothDevice device) {
362         return mBipSessions.get(device);
363     }
364 
365     /**
366      * Determines our preferred download descriptor from the list of available image download
367      * formats presented in the image properties object.
368      *
369      * <p>Our goal is ensure the image arrives in a format Android can consume and to minimize
370      * transfer size if possible.
371      *
372      * @param properties The set of available formats and image is downloadable in
373      * @return A descriptor containing the desirable download format
374      */
determineImageDescriptor(BipImageProperties properties)375     private BipImageDescriptor determineImageDescriptor(BipImageProperties properties) {
376         if (properties == null || !properties.isValid()) {
377             warn("Provided properties don't meet the spec. Requesting thumbnail format anyway.");
378         }
379         BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
380         switch (mDownloadScheme) {
381                 // BIP Specification says a blank/null descriptor signals to pull the native format
382             case SCHEME_NATIVE:
383                 return null;
384                 // AVRCP 1.6.2 defined "thumbnail" size is guaranteed so we'll do that for now
385             case SCHEME_THUMBNAIL:
386             default:
387                 builder.setEncoding(BipEncoding.JPEG);
388                 builder.setFixedDimensions(200, 200);
389                 break;
390         }
391         return builder.build();
392     }
393 
394     /** Callback for facilitating image download */
395     class BipClientCallback implements AvrcpBipClient.Callback {
396         final BluetoothDevice mDevice;
397 
BipClientCallback(BluetoothDevice device)398         BipClientCallback(BluetoothDevice device) {
399             mDevice = device;
400         }
401 
402         @Override
onConnectionStateChanged(int oldState, int newState)403         public void onConnectionStateChanged(int oldState, int newState) {
404             debug(mDevice + ": " + oldState + " -> " + newState);
405             if (newState == BluetoothProfile.STATE_CONNECTED) {
406                 // Ensure the handle map is cleared since old ones are invalid on a new connection
407                 clearHandleUuids(mDevice);
408 
409                 // Once we're connected fetch the current metadata again in case the target has an
410                 // image handle they can now give us. Only do this if we don't already have one.
411                 mService.getCurrentMetadataIfNoCoverArt(mDevice);
412             } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
413                 AvrcpBipClient client = getClient(mDevice);
414                 boolean shouldReconnect = (client != null);
415                 disconnect(mDevice);
416                 if (shouldReconnect) {
417                     debug("Disconnect was not expected by us. Attempt to reconnect.");
418                     connect(mDevice, client.getL2capPsm());
419                 }
420             }
421         }
422 
423         @Override
onGetImagePropertiesComplete( int status, String imageHandle, BipImageProperties properties)424         public void onGetImagePropertiesComplete(
425                 int status, String imageHandle, BipImageProperties properties) {
426             if (status != ResponseCodes.OBEX_HTTP_OK || properties == null) {
427                 warn(
428                         mDevice
429                                 + ": GetImageProperties() failed - Handle: "
430                                 + imageHandle
431                                 + ", Code: "
432                                 + status);
433                 return;
434             }
435             BipImageDescriptor descriptor = determineImageDescriptor(properties);
436             debug(mDevice + ": Download image - handle='" + imageHandle + "'");
437 
438             AvrcpBipClient client = getClient(mDevice);
439             if (client == null) {
440                 warn(
441                         mDevice
442                                 + ": Could not getImage() for "
443                                 + imageHandle
444                                 + " because client has disconnected.");
445                 return;
446             }
447             client.getImage(imageHandle, descriptor);
448         }
449 
450         @Override
onGetImageComplete(int status, String imageHandle, BipImage image)451         public void onGetImageComplete(int status, String imageHandle, BipImage image) {
452             if (status != ResponseCodes.OBEX_HTTP_OK) {
453                 warn(
454                         mDevice
455                                 + ": GetImage() failed - Handle: "
456                                 + imageHandle
457                                 + ", Code: "
458                                 + status);
459                 return;
460             }
461             String imageUuid = getUuidForHandle(mDevice, imageHandle);
462             debug(
463                     mDevice
464                             + ": Received image data for handle: "
465                             + imageHandle
466                             + ", uuid: "
467                             + imageUuid
468                             + ", image: "
469                             + image);
470             Uri uri = mCoverArtStorage.addImage(mDevice, imageUuid, image.getImage());
471             if (uri == null) {
472                 error("Could not store downloaded image");
473                 return;
474             }
475             DownloadEvent event = new DownloadEvent(imageUuid, uri);
476             if (mCallback != null) mCallback.onImageDownloadComplete(mDevice, event);
477         }
478     }
479 
480     @Override
toString()481     public String toString() {
482         String s = "CoverArtManager:\n";
483         s += "    Download Scheme: " + mDownloadScheme + "\n";
484         for (BluetoothDevice device : mClients.keySet()) {
485             AvrcpBipClient client = getClient(device);
486             AvrcpBipSession session = getSession(device);
487             s += "    " + device + ":" + "\n";
488             s += "      Client: " + client.toString() + "\n";
489             s += "      Handles: " + "\n";
490             for (String handle : session.getSessionHandles()) {
491                 s += "        " + handle + " -> " + session.getHandleUuid(handle) + "\n";
492             }
493         }
494         s += "  " + mCoverArtStorage.toString();
495         return s;
496     }
497 
498     /** Print to debug if debug is enabled for this class */
debug(String msg)499     private void debug(String msg) {
500         Log.d(TAG, msg);
501     }
502 
503     /** Print to warn */
warn(String msg)504     private void warn(String msg) {
505         Log.w(TAG, msg);
506     }
507 
508     /** Print to error */
error(String msg)509     private void error(String msg) {
510         Log.e(TAG, msg);
511     }
512 }
513