1 /* 2 * Copyright (C) 2015 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.supportv4.media.model; 18 19 import android.os.AsyncTask; 20 import android.support.v4.media.MediaMetadataCompat; 21 import android.util.Log; 22 23 import org.json.JSONArray; 24 import org.json.JSONException; 25 import org.json.JSONObject; 26 27 import java.io.BufferedInputStream; 28 import java.io.BufferedReader; 29 import java.io.IOException; 30 import java.io.InputStream; 31 import java.io.InputStreamReader; 32 import java.net.URL; 33 import java.net.URLConnection; 34 import java.util.ArrayList; 35 import java.util.Collections; 36 import java.util.List; 37 import java.util.Set; 38 import java.util.concurrent.ConcurrentHashMap; 39 import java.util.concurrent.ConcurrentMap; 40 41 /** 42 * Utility class to get a list of MusicTrack's based on a server-side JSON 43 * configuration. 44 */ 45 public class MusicProvider { 46 47 private static final String TAG = "MusicProvider"; 48 49 private static final String CATALOG_URL = 50 "http://storage.googleapis.com/automotive-media/music.json"; 51 52 public static final String CUSTOM_METADATA_TRACK_SOURCE = "__SOURCE__"; 53 54 private static final String JSON_MUSIC = "music"; 55 private static final String JSON_TITLE = "title"; 56 private static final String JSON_ALBUM = "album"; 57 private static final String JSON_ARTIST = "artist"; 58 private static final String JSON_GENRE = "genre"; 59 private static final String JSON_SOURCE = "source"; 60 private static final String JSON_IMAGE = "image"; 61 private static final String JSON_TRACK_NUMBER = "trackNumber"; 62 private static final String JSON_TOTAL_TRACK_COUNT = "totalTrackCount"; 63 private static final String JSON_DURATION = "duration"; 64 65 // Categorized caches for music track data: 66 private ConcurrentMap<String, List<MediaMetadataCompat>> mMusicListByGenre; 67 private List<String> mMusicGenres; 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 { 79 void onMusicCatalogReady(boolean success); 80 } 81 82 public MusicProvider() { 83 mMusicListByGenre = new ConcurrentHashMap<>(); 84 mMusicListById = new ConcurrentHashMap<>(); 85 mFavoriteTracks = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>()); 86 mMusicGenres = new ArrayList<>(); 87 } 88 89 /** 90 * Get the list of genres 91 * 92 * @return genres 93 */ 94 public List<String> getGenres() { 95 if (mCurrentState != State.INITIALIZED) { 96 return Collections.emptyList(); 97 } 98 return mMusicGenres; 99 } 100 101 /** 102 * Get music tracks of the given genre 103 */ 104 public List<MediaMetadataCompat> 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 */ 116 public Iterable<MediaMetadataCompat> searchMusic(String titleQuery) { 117 if (mCurrentState != State.INITIALIZED) { 118 return Collections.emptyList(); 119 } 120 ArrayList<MediaMetadataCompat> result = new ArrayList<>(); 121 titleQuery = titleQuery.toLowerCase(); 122 for (MutableMediaMetadata track : mMusicListById.values()) { 123 if (track.metadata.getString(MediaMetadataCompat.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 */ 136 public MediaMetadataCompat getMusic(String musicId) { 137 return mMusicListById.containsKey(musicId) ? mMusicListById.get(musicId).metadata : null; 138 } 139 140 public synchronized void updateMusic(String musicId, MediaMetadataCompat metadata) { 141 MutableMediaMetadata track = mMusicListById.get(musicId); 142 if (track == null) { 143 return; 144 } 145 146 String oldGenre = track.metadata.getString(MediaMetadataCompat.METADATA_KEY_GENRE); 147 String newGenre = metadata.getString(MediaMetadataCompat.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 157 public void setFavorite(String musicId, boolean favorite) { 158 if (favorite) { 159 mFavoriteTracks.add(musicId); 160 } else { 161 mFavoriteTracks.remove(musicId); 162 } 163 } 164 165 public boolean isFavorite(String musicId) { 166 return mFavoriteTracks.contains(musicId); 167 } 168 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 */ 177 public void retrieveMediaAsync(final Callback callback) { 178 Log.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 202 private synchronized void buildListsByGenre() { 203 ConcurrentMap<String, List<MediaMetadataCompat>> newMusicListByGenre 204 = new ConcurrentHashMap<>(); 205 206 for (MutableMediaMetadata m : mMusicListById.values()) { 207 String genre = m.metadata.getString(MediaMetadataCompat.METADATA_KEY_GENRE); 208 List<MediaMetadataCompat> list = newMusicListByGenre.get(genre); 209 if (list == null) { 210 list = new ArrayList<>(); 211 newMusicListByGenre.put(genre, list); 212 } 213 list.add(m.metadata); 214 } 215 mMusicListByGenre = newMusicListByGenre; 216 mMusicGenres = new ArrayList<>(mMusicListByGenre.keySet()); 217 } 218 219 private synchronized void retrieveMedia() { 220 try { 221 if (mCurrentState == State.NON_INITIALIZED) { 222 mCurrentState = State.INITIALIZING; 223 224 int slashPos = CATALOG_URL.lastIndexOf('/'); 225 String path = CATALOG_URL.substring(0, slashPos + 1); 226 JSONObject jsonObj = fetchJSONFromUrl(CATALOG_URL); 227 if (jsonObj == null) { 228 return; 229 } 230 JSONArray tracks = jsonObj.getJSONArray(JSON_MUSIC); 231 if (tracks != null) { 232 for (int j = 0; j < tracks.length(); j++) { 233 MediaMetadataCompat item = buildFromJSON(tracks.getJSONObject(j), path); 234 String musicId = item.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID); 235 mMusicListById.put(musicId, new MutableMediaMetadata(musicId, item)); 236 } 237 buildListsByGenre(); 238 } 239 mCurrentState = State.INITIALIZED; 240 } 241 } catch (JSONException e) { 242 Log.e(TAG, "Could not retrieve music list", e); 243 } finally { 244 if (mCurrentState != State.INITIALIZED) { 245 // Something bad happened, so we reset state to NON_INITIALIZED to allow 246 // retries (eg if the network connection is temporary unavailable) 247 mCurrentState = State.NON_INITIALIZED; 248 } 249 } 250 } 251 252 private MediaMetadataCompat buildFromJSON(JSONObject json, String basePath) throws JSONException { 253 String title = json.getString(JSON_TITLE); 254 String album = json.getString(JSON_ALBUM); 255 String artist = json.getString(JSON_ARTIST); 256 String genre = json.getString(JSON_GENRE); 257 String source = json.getString(JSON_SOURCE); 258 String iconUrl = json.getString(JSON_IMAGE); 259 int trackNumber = json.getInt(JSON_TRACK_NUMBER); 260 int totalTrackCount = json.getInt(JSON_TOTAL_TRACK_COUNT); 261 int duration = json.getInt(JSON_DURATION) * 1000; // ms 262 263 Log.d(TAG, "Found music track: " + json); 264 265 // Media is stored relative to JSON file 266 if (!source.startsWith("http")) { 267 source = basePath + source; 268 } 269 if (!iconUrl.startsWith("http")) { 270 iconUrl = basePath + iconUrl; 271 } 272 // Since we don't have a unique ID in the server, we fake one using the hashcode of 273 // the music source. In a real world app, this could come from the server. 274 String id = String.valueOf(source.hashCode()); 275 276 // Adding the music source to the MediaMetadata (and consequently using it in the 277 // mediaSession.setMetadata) is not a good idea for a real world music app, because 278 // the session metadata can be accessed by notification listeners. This is done in this 279 // sample for convenience only. 280 return new MediaMetadataCompat.Builder() 281 .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) 282 .putString(CUSTOM_METADATA_TRACK_SOURCE, source) 283 .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) 284 .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) 285 .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) 286 .putString(MediaMetadataCompat.METADATA_KEY_GENRE, genre) 287 .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUrl) 288 .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) 289 .putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, trackNumber) 290 .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, totalTrackCount) 291 .build(); 292 } 293 294 /** 295 * Download a JSON file from a server, parse the content and return the JSON 296 * object. 297 * 298 * @return result JSONObject containing the parsed representation. 299 */ 300 private JSONObject fetchJSONFromUrl(String urlString) { 301 InputStream is = null; 302 try { 303 URL url = new URL(urlString); 304 URLConnection urlConnection = url.openConnection(); 305 is = new BufferedInputStream(urlConnection.getInputStream()); 306 BufferedReader reader = new BufferedReader(new InputStreamReader( 307 urlConnection.getInputStream(), "iso-8859-1")); 308 StringBuilder sb = new StringBuilder(); 309 String line; 310 while ((line = reader.readLine()) != null) { 311 sb.append(line); 312 } 313 return new JSONObject(sb.toString()); 314 } catch (Exception e) { 315 Log.e(TAG, "Failed to parse the json for media list", e); 316 return null; 317 } finally { 318 if (is != null) { 319 try { 320 is.close(); 321 } catch (IOException e) { 322 // ignore 323 } 324 } 325 } 326 } 327 } 328