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