1 /*
2  * Copyright (C) 2020 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.android.car.media.common.browse;
18 
19 import static com.android.car.apps.common.util.LiveDataFunctions.dataOf;
20 
21 import static java.util.stream.Collectors.toList;
22 
23 import android.app.Application;
24 import android.os.Bundle;
25 import android.support.v4.media.MediaBrowserCompat;
26 import android.support.v4.media.MediaBrowserCompat.SearchCallback;
27 import android.support.v4.media.MediaBrowserCompat.SubscriptionCallback;
28 import android.text.TextUtils;
29 import android.util.Log;
30 
31 import androidx.annotation.NonNull;
32 import androidx.annotation.Nullable;
33 import androidx.annotation.VisibleForTesting;
34 import androidx.lifecycle.LiveData;
35 import androidx.lifecycle.MutableLiveData;
36 
37 import com.android.car.apps.common.util.FutureData;
38 import com.android.car.media.common.MediaItemMetadata;
39 import com.android.car.media.common.source.MediaBrowserConnector.BrowsingState;
40 import com.android.car.media.common.source.MediaSource;
41 import com.android.car.media.common.source.MediaSourceViewModel;
42 
43 import java.util.Collections;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.Objects;
48 import java.util.stream.Collectors;
49 
50 
51 /**
52  * Fulfills media items search and children queries. The latter also provides the last list of
53  * results alongside the new list so that differences can be calculated and acted upon.
54  */
55 public class MediaItemsRepository {
56     private static final String TAG = "MediaItemsRepository";
57 
58     /** One instance per MEDIA_SOURCE_MODE. */
59     private static MediaItemsRepository[] sInstances = new MediaItemsRepository[2];
60 
61     /** Returns the MediaItemsRepository "singleton" tied to the application for the given mode. */
get(@onNull Application application, int mode)62     public static MediaItemsRepository get(@NonNull Application application, int mode) {
63         if (sInstances[mode] == null) {
64             sInstances[mode] = new MediaItemsRepository(
65                     MediaSourceViewModel.get(application, mode).getBrowsingState());
66         }
67         return sInstances[mode];
68     }
69 
70     /** The live data providing the updates for a query. */
71     public static class MediaItemsLiveData
72             extends LiveData<FutureData<List<MediaItemMetadata>>> {
73 
MediaItemsLiveData()74         private MediaItemsLiveData() {
75             this(true);
76         }
77 
MediaItemsLiveData(boolean initAsLoading)78         private MediaItemsLiveData(boolean initAsLoading) {
79             if (initAsLoading) {
80                 setLoading();
81             } else {
82                 clear();
83             }
84         }
85 
onDataLoaded(List<MediaItemMetadata> old, List<MediaItemMetadata> list)86         private void onDataLoaded(List<MediaItemMetadata> old, List<MediaItemMetadata> list) {
87             setValue(FutureData.newLoadedData(old, list));
88         }
89 
setLoading()90         private void setLoading() {
91             setValue(FutureData.newLoadingData());
92         }
93 
clear()94         private void clear() {
95             setValue(null);
96         }
97     }
98 
99     private static class MediaChildren {
100         final String mNodeId;
101         final MediaItemsLiveData mLiveData = new MediaItemsLiveData();
102         List<MediaItemMetadata> mPreviousValue = Collections.emptyList();
103 
MediaChildren(String nodeId)104         MediaChildren(String nodeId) {
105             mNodeId = nodeId;
106         }
107     }
108 
109     private static class PerMediaSourceCache {
110         String mRootId;
111         Map<String, MediaChildren> mChildrenByNodeId = new HashMap<>();
112     }
113 
114     private BrowsingState mBrowsingState;
115     private final Map<MediaSource, PerMediaSourceCache> mCaches = new HashMap<>();
116     private final MutableLiveData<BrowsingState> mBrowsingStateLiveData = dataOf(null);
117     private final MediaItemsLiveData mRootMediaItems = new MediaItemsLiveData();
118     private final MediaItemsLiveData mSearchMediaItems = new MediaItemsLiveData(/*loading*/ false);
119 
120     private String mSearchQuery;
121 
122     @VisibleForTesting
MediaItemsRepository(LiveData<BrowsingState> browsingState)123     public MediaItemsRepository(LiveData<BrowsingState> browsingState) {
124         browsingState.observeForever(this::onMediaBrowsingStateChanged);
125     }
126 
127     /**
128      * Rebroadcasts browsing state changes before the repository takes any action on them.
129      */
getBrowsingState()130     public LiveData<BrowsingState> getBrowsingState() {
131         return mBrowsingStateLiveData;
132     }
133 
134     /**
135      * Convenience wrapper for root media items. The live data is the same instance for all
136      * media sources.
137      */
getRootMediaItems()138     public MediaItemsLiveData getRootMediaItems() {
139         return mRootMediaItems;
140     }
141 
142     /**
143      * Returns the results from the current search query. The live data is the same instance
144      * for all media sources.
145      */
getSearchMediaItems()146     public MediaItemsLiveData getSearchMediaItems() {
147         return mSearchMediaItems;
148     }
149 
150     /** Returns the children of the given node. */
getMediaChildren(String nodeId)151     public MediaItemsLiveData getMediaChildren(String nodeId) {
152         PerMediaSourceCache cache = getCache();
153         MediaChildren items = cache.mChildrenByNodeId.get(nodeId);
154         if (items == null) {
155             items = new MediaChildren(nodeId);
156             cache.mChildrenByNodeId.put(nodeId, items);
157         }
158 
159         // Always refresh the subscription (to work around bugs in media apps).
160         mBrowsingState.mBrowser.unsubscribe(nodeId);
161         mBrowsingState.mBrowser.subscribe(nodeId, mBrowseCallback);
162 
163         return items.mLiveData;
164     }
165 
166     /** Sets the search query. Results will be given through {@link #getSearchMediaItems}. */
setSearchQuery(String query)167     public void setSearchQuery(String query) {
168         mSearchQuery = query;
169         if (TextUtils.isEmpty(mSearchQuery)) {
170             clearSearchResults();
171         } else {
172             mSearchMediaItems.setLoading();
173             mBrowsingState.mBrowser.search(mSearchQuery, null, mSearchCallback);
174         }
175     }
176 
clearSearchResults()177     private void clearSearchResults() {
178         mSearchMediaItems.clear();
179     }
180 
getMediaSource()181     private MediaSource getMediaSource() {
182         return (mBrowsingState != null) ? mBrowsingState.mMediaSource : null;
183     }
184 
onMediaBrowsingStateChanged(BrowsingState newBrowsingState)185     private void onMediaBrowsingStateChanged(BrowsingState newBrowsingState) {
186         mBrowsingState = newBrowsingState;
187         if (mBrowsingState == null) {
188             Log.e(TAG, "Null browsing state (no media source!)");
189             return;
190         }
191         mBrowsingStateLiveData.setValue(mBrowsingState);
192         switch (mBrowsingState.mConnectionStatus) {
193             case CONNECTING:
194                 mRootMediaItems.setLoading();
195                 break;
196             case CONNECTED:
197                 String rootId = mBrowsingState.mBrowser.getRoot();
198                 getCache().mRootId = rootId;
199                 getMediaChildren(rootId);
200                 break;
201             case DISCONNECTING:
202                 unsubscribeNodes();
203                 clearSearchResults();
204                 clearNodes();
205                 break;
206             case REJECTED:
207             case SUSPENDED:
208                 onBrowseData(getCache().mRootId, null);
209                 clearSearchResults();
210                 clearNodes();
211         }
212     }
213 
getCache()214     private PerMediaSourceCache getCache() {
215         PerMediaSourceCache cache = mCaches.get(getMediaSource());
216         if (cache == null) {
217             cache = new PerMediaSourceCache();
218             mCaches.put(getMediaSource(), cache);
219         }
220         return cache;
221     }
222 
223     /** Does NOT clear the cache. */
unsubscribeNodes()224     private void unsubscribeNodes() {
225         PerMediaSourceCache cache = getCache();
226         for (String nodeId : cache.mChildrenByNodeId.keySet()) {
227             mBrowsingState.mBrowser.unsubscribe(nodeId);
228         }
229     }
230 
231     /** Does NOT unsubscribe nodes. */
clearNodes()232     private void clearNodes() {
233         PerMediaSourceCache cache = getCache();
234         cache.mChildrenByNodeId.clear();
235     }
236 
onBrowseData(@onNull String parentId, @Nullable List<MediaItemMetadata> list)237     private void onBrowseData(@NonNull String parentId, @Nullable List<MediaItemMetadata> list) {
238         PerMediaSourceCache cache = getCache();
239         MediaChildren children = cache.mChildrenByNodeId.get(parentId);
240         if (children == null) {
241             if (Log.isLoggable(TAG, Log.WARN)) {
242                 Log.w(TAG, "Browse parent not in the cache: " + parentId);
243             }
244             return;
245         }
246 
247         List<MediaItemMetadata> old = children.mPreviousValue;
248         children.mPreviousValue = list;
249         children.mLiveData.onDataLoaded(old, list);
250 
251         if (Objects.equals(parentId, cache.mRootId)) {
252             mRootMediaItems.onDataLoaded(old, list);
253         }
254     }
255 
onSearchData(@ullable List<MediaItemMetadata> list)256     private void onSearchData(@Nullable List<MediaItemMetadata> list) {
257         mSearchMediaItems.onDataLoaded(null, list);
258     }
259 
260     private final SubscriptionCallback mBrowseCallback = new SubscriptionCallback() {
261         @Override
262         public void onChildrenLoaded(@NonNull String parentId,
263                 @NonNull List<MediaBrowserCompat.MediaItem> children) {
264 
265             onBrowseData(parentId, children.stream()
266                     .filter(Objects::nonNull)
267                     .map(MediaItemMetadata::new)
268                     .collect(Collectors.toList()));
269         }
270 
271         @Override
272         public void onChildrenLoaded(@NonNull String parentId,
273                 @NonNull List<MediaBrowserCompat.MediaItem> children,
274                 @NonNull Bundle options) {
275             onChildrenLoaded(parentId, children);
276         }
277 
278         @Override
279         public void onError(@NonNull String parentId) {
280             onBrowseData(parentId, null);
281         }
282 
283         @Override
284         public void onError(@NonNull String parentId, @NonNull Bundle options) {
285             onError(parentId);
286         }
287     };
288 
289     private final SearchCallback mSearchCallback = new SearchCallback() {
290         @Override
291         public void onSearchResult(@NonNull String query, Bundle extras,
292                 @NonNull List<MediaBrowserCompat.MediaItem> items) {
293             super.onSearchResult(query, extras, items);
294             if (Objects.equals(mSearchQuery, query)) {
295                 onSearchData(items.stream()
296                         .filter(Objects::nonNull)
297                         .map(MediaItemMetadata::new)
298                         .collect(toList()));
299             }
300         }
301 
302         @Override
303         public void onError(@NonNull String query, Bundle extras) {
304             super.onError(query, extras);
305             if (Objects.equals(mSearchQuery, query)) {
306                 onSearchData(null);
307             }
308         }
309     };
310 }
311