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 {
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         mMusicGenres = new ArrayList<>();
87     }
88 
89     /**
90      * Get the list of genres
91      *
92      * @return genres
93      */
getGenres()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      */
getMusicsByGenre(String genre)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      */
searchMusic(String titleQuery)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      */
getMusic(String musicId)136     public MediaMetadataCompat getMusic(String musicId) {
137         return mMusicListById.containsKey(musicId) ? mMusicListById.get(musicId).metadata : null;
138     }
139 
updateMusic(String musicId, MediaMetadataCompat metadata)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 
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         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 
buildListsByGenre()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 
retrieveMedia()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 
buildFromJSON(JSONObject json, String basePath)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      */
fetchJSONFromUrl(String urlString)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