1 /* 2 * Copyright (C) 2014 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.example.android.mediabrowserservice.model; 18 19 import android.media.MediaMetadata; 20 import android.os.AsyncTask; 21 22 import com.example.android.mediabrowserservice.utils.LogHelper; 23 24 import org.json.JSONArray; 25 import org.json.JSONException; 26 import org.json.JSONObject; 27 28 import java.io.BufferedInputStream; 29 import java.io.BufferedReader; 30 import java.io.IOException; 31 import java.io.InputStream; 32 import java.io.InputStreamReader; 33 import java.net.URL; 34 import java.net.URLConnection; 35 import java.util.ArrayList; 36 import java.util.Collections; 37 import java.util.List; 38 import java.util.Set; 39 import java.util.concurrent.ConcurrentHashMap; 40 import java.util.concurrent.ConcurrentMap; 41 42 /** 43 * Utility class to get a list of MusicTrack's based on a server-side JSON 44 * configuration. 45 */ 46 public class MusicProvider { 47 48 private static final String TAG = LogHelper.makeLogTag(MusicProvider.class); 49 50 private static final String CATALOG_URL = 51 "http://storage.googleapis.com/automotive-media/music.json"; 52 53 public static final String CUSTOM_METADATA_TRACK_SOURCE = "__SOURCE__"; 54 55 private static final String JSON_MUSIC = "music"; 56 private static final String JSON_TITLE = "title"; 57 private static final String JSON_ALBUM = "album"; 58 private static final String JSON_ARTIST = "artist"; 59 private static final String JSON_GENRE = "genre"; 60 private static final String JSON_SOURCE = "source"; 61 private static final String JSON_IMAGE = "image"; 62 private static final String JSON_TRACK_NUMBER = "trackNumber"; 63 private static final String JSON_TOTAL_TRACK_COUNT = "totalTrackCount"; 64 private static final String JSON_DURATION = "duration"; 65 66 // Categorized caches for music track data: 67 private ConcurrentMap<String, List<MediaMetadata>> mMusicListByGenre; 68 private final ConcurrentMap<String, MutableMediaMetadata> mMusicListById; 69 70 private final Set<String> mFavoriteTracks; 71 72 enum State { 73 NON_INITIALIZED, INITIALIZING, INITIALIZED 74 } 75 76 private volatile State mCurrentState = State.NON_INITIALIZED; 77 78 public interface Callback { onMusicCatalogReady(boolean success)79 void onMusicCatalogReady(boolean success); 80 } 81 MusicProvider()82 public MusicProvider() { 83 mMusicListByGenre = new ConcurrentHashMap<>(); 84 mMusicListById = new ConcurrentHashMap<>(); 85 mFavoriteTracks = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>()); 86 } 87 88 /** 89 * Get an iterator over the list of genres 90 * 91 * @return genres 92 */ getGenres()93 public Iterable<String> getGenres() { 94 if (mCurrentState != State.INITIALIZED) { 95 return Collections.emptyList(); 96 } 97 return mMusicListByGenre.keySet(); 98 } 99 100 /** 101 * Get music tracks of the given genre 102 * 103 */ getMusicsByGenre(String genre)104 public Iterable<MediaMetadata> getMusicsByGenre(String genre) { 105 if (mCurrentState != State.INITIALIZED || !mMusicListByGenre.containsKey(genre)) { 106 return Collections.emptyList(); 107 } 108 return mMusicListByGenre.get(genre); 109 } 110 111 /** 112 * Very basic implementation of a search that filter music tracks which title containing 113 * the given query. 114 * 115 */ searchMusic(String titleQuery)116 public Iterable<MediaMetadata> searchMusic(String titleQuery) { 117 if (mCurrentState != State.INITIALIZED) { 118 return Collections.emptyList(); 119 } 120 ArrayList<MediaMetadata> result = new ArrayList<>(); 121 titleQuery = titleQuery.toLowerCase(); 122 for (MutableMediaMetadata track : mMusicListById.values()) { 123 if (track.metadata.getString(MediaMetadata.METADATA_KEY_TITLE).toLowerCase() 124 .contains(titleQuery)) { 125 result.add(track.metadata); 126 } 127 } 128 return result; 129 } 130 131 /** 132 * Return the MediaMetadata for the given musicID. 133 * 134 * @param musicId The unique, non-hierarchical music ID. 135 */ getMusic(String musicId)136 public MediaMetadata getMusic(String musicId) { 137 return mMusicListById.containsKey(musicId) ? mMusicListById.get(musicId).metadata : null; 138 } 139 updateMusic(String musicId, MediaMetadata metadata)140 public synchronized void updateMusic(String musicId, MediaMetadata metadata) { 141 MutableMediaMetadata track = mMusicListById.get(musicId); 142 if (track == null) { 143 return; 144 } 145 146 String oldGenre = track.metadata.getString(MediaMetadata.METADATA_KEY_GENRE); 147 String newGenre = metadata.getString(MediaMetadata.METADATA_KEY_GENRE); 148 149 track.metadata = metadata; 150 151 // if genre has changed, we need to rebuild the list by genre 152 if (!oldGenre.equals(newGenre)) { 153 buildListsByGenre(); 154 } 155 } 156 setFavorite(String musicId, boolean favorite)157 public void setFavorite(String musicId, boolean favorite) { 158 if (favorite) { 159 mFavoriteTracks.add(musicId); 160 } else { 161 mFavoriteTracks.remove(musicId); 162 } 163 } 164 isFavorite(String musicId)165 public boolean isFavorite(String musicId) { 166 return mFavoriteTracks.contains(musicId); 167 } 168 isInitialized()169 public boolean isInitialized() { 170 return mCurrentState == State.INITIALIZED; 171 } 172 173 /** 174 * Get the list of music tracks from a server and caches the track information 175 * for future reference, keying tracks by musicId and grouping by genre. 176 */ retrieveMediaAsync(final Callback callback)177 public void retrieveMediaAsync(final Callback callback) { 178 LogHelper.d(TAG, "retrieveMediaAsync called"); 179 if (mCurrentState == State.INITIALIZED) { 180 // Nothing to do, execute callback immediately 181 callback.onMusicCatalogReady(true); 182 return; 183 } 184 185 // Asynchronously load the music catalog in a separate thread 186 new AsyncTask<Void, Void, State>() { 187 @Override 188 protected State doInBackground(Void... params) { 189 retrieveMedia(); 190 return mCurrentState; 191 } 192 193 @Override 194 protected void onPostExecute(State current) { 195 if (callback != null) { 196 callback.onMusicCatalogReady(current == State.INITIALIZED); 197 } 198 } 199 }.execute(); 200 } 201 buildListsByGenre()202 private synchronized void buildListsByGenre() { 203 ConcurrentMap<String, List<MediaMetadata>> newMusicListByGenre = new ConcurrentHashMap<>(); 204 205 for (MutableMediaMetadata m : mMusicListById.values()) { 206 String genre = m.metadata.getString(MediaMetadata.METADATA_KEY_GENRE); 207 List<MediaMetadata> list = newMusicListByGenre.get(genre); 208 if (list == null) { 209 list = new ArrayList<>(); 210 newMusicListByGenre.put(genre, list); 211 } 212 list.add(m.metadata); 213 } 214 mMusicListByGenre = newMusicListByGenre; 215 } 216 retrieveMedia()217 private synchronized void retrieveMedia() { 218 try { 219 if (mCurrentState == State.NON_INITIALIZED) { 220 mCurrentState = State.INITIALIZING; 221 222 int slashPos = CATALOG_URL.lastIndexOf('/'); 223 String path = CATALOG_URL.substring(0, slashPos + 1); 224 JSONObject jsonObj = fetchJSONFromUrl(CATALOG_URL); 225 if (jsonObj == null) { 226 return; 227 } 228 JSONArray tracks = jsonObj.getJSONArray(JSON_MUSIC); 229 if (tracks != null) { 230 for (int j = 0; j < tracks.length(); j++) { 231 MediaMetadata item = buildFromJSON(tracks.getJSONObject(j), path); 232 String musicId = item.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); 233 mMusicListById.put(musicId, new MutableMediaMetadata(musicId, item)); 234 } 235 buildListsByGenre(); 236 } 237 mCurrentState = State.INITIALIZED; 238 } 239 } catch (JSONException e) { 240 LogHelper.e(TAG, e, "Could not retrieve music list"); 241 } finally { 242 if (mCurrentState != State.INITIALIZED) { 243 // Something bad happened, so we reset state to NON_INITIALIZED to allow 244 // retries (eg if the network connection is temporary unavailable) 245 mCurrentState = State.NON_INITIALIZED; 246 } 247 } 248 } 249 buildFromJSON(JSONObject json, String basePath)250 private MediaMetadata buildFromJSON(JSONObject json, String basePath) throws JSONException { 251 String title = json.getString(JSON_TITLE); 252 String album = json.getString(JSON_ALBUM); 253 String artist = json.getString(JSON_ARTIST); 254 String genre = json.getString(JSON_GENRE); 255 String source = json.getString(JSON_SOURCE); 256 String iconUrl = json.getString(JSON_IMAGE); 257 int trackNumber = json.getInt(JSON_TRACK_NUMBER); 258 int totalTrackCount = json.getInt(JSON_TOTAL_TRACK_COUNT); 259 int duration = json.getInt(JSON_DURATION) * 1000; // ms 260 261 LogHelper.d(TAG, "Found music track: ", json); 262 263 // Media is stored relative to JSON file 264 if (!source.startsWith("http")) { 265 source = basePath + source; 266 } 267 if (!iconUrl.startsWith("http")) { 268 iconUrl = basePath + iconUrl; 269 } 270 // Since we don't have a unique ID in the server, we fake one using the hashcode of 271 // the music source. In a real world app, this could come from the server. 272 String id = String.valueOf(source.hashCode()); 273 274 // Adding the music source to the MediaMetadata (and consequently using it in the 275 // mediaSession.setMetadata) is not a good idea for a real world music app, because 276 // the session metadata can be accessed by notification listeners. This is done in this 277 // sample for convenience only. 278 return new MediaMetadata.Builder() 279 .putString(MediaMetadata.METADATA_KEY_MEDIA_ID, id) 280 .putString(CUSTOM_METADATA_TRACK_SOURCE, source) 281 .putString(MediaMetadata.METADATA_KEY_ALBUM, album) 282 .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) 283 .putLong(MediaMetadata.METADATA_KEY_DURATION, duration) 284 .putString(MediaMetadata.METADATA_KEY_GENRE, genre) 285 .putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, iconUrl) 286 .putString(MediaMetadata.METADATA_KEY_TITLE, title) 287 .putLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER, trackNumber) 288 .putLong(MediaMetadata.METADATA_KEY_NUM_TRACKS, totalTrackCount) 289 .build(); 290 } 291 292 /** 293 * Download a JSON file from a server, parse the content and return the JSON 294 * object. 295 * 296 * @return result JSONObject containing the parsed representation. 297 */ fetchJSONFromUrl(String urlString)298 private JSONObject fetchJSONFromUrl(String urlString) { 299 InputStream is = null; 300 try { 301 URL url = new URL(urlString); 302 URLConnection urlConnection = url.openConnection(); 303 is = new BufferedInputStream(urlConnection.getInputStream()); 304 BufferedReader reader = new BufferedReader(new InputStreamReader( 305 urlConnection.getInputStream(), "iso-8859-1")); 306 StringBuilder sb = new StringBuilder(); 307 String line; 308 while ((line = reader.readLine()) != null) { 309 sb.append(line); 310 } 311 return new JSONObject(sb.toString()); 312 } catch (Exception e) { 313 LogHelper.e(TAG, "Failed to parse the json for media list", e); 314 return null; 315 } finally { 316 if (is != null) { 317 try { 318 is.close(); 319 } catch (IOException e) { 320 // ignore 321 } 322 } 323 } 324 } 325 } 326