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
17package com.example.android.supportv4.media.model;
18
19import android.os.AsyncTask;
20import android.support.v4.media.MediaMetadataCompat;
21import android.util.Log;
22
23import org.json.JSONArray;
24import org.json.JSONException;
25import org.json.JSONObject;
26
27import java.io.BufferedInputStream;
28import java.io.BufferedReader;
29import java.io.IOException;
30import java.io.InputStream;
31import java.io.InputStreamReader;
32import java.net.URL;
33import java.net.URLConnection;
34import java.util.ArrayList;
35import java.util.Collections;
36import java.util.List;
37import java.util.Set;
38import java.util.concurrent.ConcurrentHashMap;
39import 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 */
45public 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