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.android.tv.recommendation;
18 
19 import android.content.Context;
20 import android.content.UriMatcher;
21 import android.database.ContentObserver;
22 import android.database.Cursor;
23 import android.media.tv.TvContract;
24 import android.media.tv.TvInputInfo;
25 import android.media.tv.TvInputManager;
26 import android.media.tv.TvInputManager.TvInputCallback;
27 import android.net.Uri;
28 import android.os.Handler;
29 import android.os.HandlerThread;
30 import android.os.Looper;
31 import android.os.Message;
32 import android.support.annotation.MainThread;
33 import android.support.annotation.NonNull;
34 import android.support.annotation.Nullable;
35 import android.support.annotation.WorkerThread;
36 
37 import com.android.tv.TvApplication;
38 import com.android.tv.common.WeakHandler;
39 import com.android.tv.data.Channel;
40 import com.android.tv.data.ChannelDataManager;
41 import com.android.tv.data.Program;
42 import com.android.tv.data.WatchedHistoryManager;
43 import com.android.tv.util.PermissionUtils;
44 
45 import java.util.ArrayList;
46 import java.util.Collection;
47 import java.util.Collections;
48 import java.util.HashSet;
49 import java.util.List;
50 import java.util.Map;
51 import java.util.Set;
52 import java.util.concurrent.ConcurrentHashMap;
53 
54 public class RecommendationDataManager implements WatchedHistoryManager.Listener {
55     private static final UriMatcher sUriMatcher;
56     private static final int MATCH_CHANNEL = 1;
57     private static final int MATCH_CHANNEL_ID = 2;
58     private static final int MATCH_WATCHED_PROGRAM_ID = 3;
59     static {
60         sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL)61         sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID)62         sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID);
sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID)63         sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID);
64     }
65 
66     private static final int MSG_START = 1000;
67     private static final int MSG_STOP = 1001;
68     private static final int MSG_UPDATE_CHANNELS = 1002;
69     private static final int MSG_UPDATE_WATCH_HISTORY = 1003;
70     private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED = 1004;
71     private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED = 1005;
72 
73     private static final int MSG_FIRST = MSG_START;
74     private static final int MSG_LAST = MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED;
75 
76     private static RecommendationDataManager sManager;
77     private final ContentObserver mContentObserver;
78     private final Map<Long, ChannelRecord> mChannelRecordMap = new ConcurrentHashMap<>();
79     private final Map<Long, ChannelRecord> mAvailableChannelRecordMap = new ConcurrentHashMap<>();
80 
81     private final Context mContext;
82     private boolean mStarted;
83     private boolean mCancelLoadTask;
84     private boolean mChannelRecordMapLoaded;
85     private int mIndexWatchChannelId = -1;
86     private int mIndexProgramTitle = -1;
87     private int mIndexProgramStartTime = -1;
88     private int mIndexProgramEndTime = -1;
89     private int mIndexWatchStartTime = -1;
90     private int mIndexWatchEndTime = -1;
91     private TvInputManager mTvInputManager;
92     private final Set<String> mInputs = new HashSet<>();
93 
94     private final HandlerThread mHandlerThread;
95     private final Handler mHandler;
96     private final Handler mMainHandler;
97     @Nullable
98     private WatchedHistoryManager mWatchedHistoryManager;
99     private final ChannelDataManager mChannelDataManager;
100     private final ChannelDataManager.Listener mChannelDataListener =
101             new ChannelDataManager.Listener() {
102         @Override
103         @MainThread
104         public void onLoadFinished() {
105             updateChannelData();
106         }
107 
108         @Override
109         @MainThread
110         public void onChannelListUpdated() {
111             updateChannelData();
112         }
113 
114         @Override
115         @MainThread
116         public void onChannelBrowsableChanged() {
117             updateChannelData();
118         }
119     };
120 
121     // For thread safety, this variable is handled only on main thread.
122     private final List<Listener> mListeners = new ArrayList<>();
123 
124     /**
125      * Gets instance of RecommendationDataManager, and adds a {@link Listener}.
126      * The listener methods will be called in the same thread as its caller of the method.
127      * Note that {@link #release(Listener)} should be called when this manager is not needed
128      * any more.
129      */
acquireManager( Context context, @NonNull Listener listener)130     public synchronized static RecommendationDataManager acquireManager(
131             Context context, @NonNull Listener listener) {
132         if (sManager == null) {
133             sManager = new RecommendationDataManager(context, listener);
134         }
135         return sManager;
136     }
137 
138     private final TvInputCallback mInternalCallback =
139             new TvInputCallback() {
140                 @Override
141                 public void onInputStateChanged(String inputId, int state) { }
142 
143                 @Override
144                 public void onInputAdded(String inputId) {
145                     if (!mStarted) {
146                         return;
147                     }
148                     mInputs.add(inputId);
149                     if (!mChannelRecordMapLoaded) {
150                         return;
151                     }
152                     boolean channelRecordMapChanged = false;
153                     for (ChannelRecord channelRecord : mChannelRecordMap.values()) {
154                         if (channelRecord.getChannel().getInputId().equals(inputId)) {
155                             channelRecord.setInputRemoved(false);
156                             mAvailableChannelRecordMap.put(channelRecord.getChannel().getId(),
157                                     channelRecord);
158                             channelRecordMapChanged = true;
159                         }
160                     }
161                     if (channelRecordMapChanged
162                             && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
163                         mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
164                     }
165                 }
166 
167                 @Override
168                 public void onInputRemoved(String inputId) {
169                     if (!mStarted) {
170                         return;
171                     }
172                     mInputs.remove(inputId);
173                     if (!mChannelRecordMapLoaded) {
174                         return;
175                     }
176                     boolean channelRecordMapChanged = false;
177                     for (ChannelRecord channelRecord : mChannelRecordMap.values()) {
178                         if (channelRecord.getChannel().getInputId().equals(inputId)) {
179                             channelRecord.setInputRemoved(true);
180                             mAvailableChannelRecordMap.remove(channelRecord.getChannel().getId());
181                             channelRecordMapChanged = true;
182                         }
183                     }
184                     if (channelRecordMapChanged
185                             && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
186                         mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
187                     }
188                 }
189 
190                 @Override
191                 public void onInputUpdated(String inputId) { }
192             };
193 
RecommendationDataManager(Context context, final Listener listener)194     private RecommendationDataManager(Context context, final Listener listener) {
195         mContext = context.getApplicationContext();
196         mHandlerThread = new HandlerThread("RecommendationDataManager");
197         mHandlerThread.start();
198         mHandler = new RecommendationHandler(mHandlerThread.getLooper(), this);
199         mMainHandler = new RecommendationMainHandler(Looper.getMainLooper(), this);
200         mContentObserver = new RecommendationContentObserver(mHandler);
201         mChannelDataManager = TvApplication.getSingletons(mContext).getChannelDataManager();
202         runOnMainThread(new Runnable() {
203             @Override
204             public void run() {
205                 addListener(listener);
206                 start();
207             }
208         });
209     }
210 
211     /**
212      * Removes the {@link Listener}, and releases RecommendationDataManager
213      * if there are no listeners remained.
214      */
release(@onNull final Listener listener)215     public void release(@NonNull final Listener listener) {
216         runOnMainThread(new Runnable() {
217             @Override
218             public void run() {
219                 removeListener(listener);
220                 if (mListeners.size() == 0) {
221                     stop();
222                 }
223             }
224         });
225     }
226 
227     /**
228      * Returns a {@link ChannelRecord} corresponds to the channel ID {@code ChannelId}.
229      */
getChannelRecord(long channelId)230     public ChannelRecord getChannelRecord(long channelId) {
231         return mAvailableChannelRecordMap.get(channelId);
232     }
233 
234     /**
235      * Returns the number of channels registered in ChannelRecord map.
236      */
getChannelRecordCount()237     public int getChannelRecordCount() {
238         return mAvailableChannelRecordMap.size();
239     }
240 
241     /**
242      * Returns a Collection of ChannelRecords.
243      */
getChannelRecords()244     public Collection<ChannelRecord> getChannelRecords() {
245         return Collections.unmodifiableCollection(mAvailableChannelRecordMap.values());
246     }
247 
248     @MainThread
start()249     private void start() {
250         mHandler.sendEmptyMessage(MSG_START);
251         mChannelDataManager.addListener(mChannelDataListener);
252         if (mChannelDataManager.isDbLoadFinished()) {
253             updateChannelData();
254         }
255     }
256 
257     @MainThread
stop()258     private void stop() {
259         for (int what = MSG_FIRST; what <= MSG_LAST; ++what) {
260             mHandler.removeMessages(what);
261         }
262         mChannelDataManager.removeListener(mChannelDataListener);
263         mHandler.sendEmptyMessage(MSG_STOP);
264         mHandlerThread.quitSafely();
265         mMainHandler.removeCallbacksAndMessages(null);
266         sManager = null;
267     }
268 
269     @MainThread
updateChannelData()270     private void updateChannelData() {
271         mHandler.removeMessages(MSG_UPDATE_CHANNELS);
272         mHandler.obtainMessage(MSG_UPDATE_CHANNELS, mChannelDataManager.getBrowsableChannelList())
273                 .sendToTarget();
274     }
275 
276     @MainThread
addListener(Listener listener)277     private void addListener(Listener listener) {
278         mListeners.add(listener);
279     }
280 
281     @MainThread
removeListener(Listener listener)282     private void removeListener(Listener listener) {
283         mListeners.remove(listener);
284     }
285 
onStart()286     private void onStart() {
287         if (!mStarted) {
288             mStarted = true;
289             mCancelLoadTask = false;
290             if (!PermissionUtils.hasAccessWatchedHistory(mContext)) {
291                 mWatchedHistoryManager = new WatchedHistoryManager(mContext);
292                 mWatchedHistoryManager.setListener(this);
293                 mWatchedHistoryManager.start();
294             } else {
295                 mContext.getContentResolver().registerContentObserver(
296                         TvContract.WatchedPrograms.CONTENT_URI, true, mContentObserver);
297                 mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY,
298                         TvContract.WatchedPrograms.CONTENT_URI)
299                         .sendToTarget();
300             }
301             mTvInputManager = (TvInputManager) mContext.getSystemService(Context.TV_INPUT_SERVICE);
302             mTvInputManager.registerCallback(mInternalCallback, mHandler);
303             for (TvInputInfo input : mTvInputManager.getTvInputList()) {
304                 mInputs.add(input.getId());
305             }
306         }
307         if (mChannelRecordMapLoaded) {
308             mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
309         }
310     }
311 
onStop()312     private void onStop() {
313         mContext.getContentResolver().unregisterContentObserver(mContentObserver);
314         mCancelLoadTask = true;
315         mChannelRecordMap.clear();
316         mAvailableChannelRecordMap.clear();
317         mInputs.clear();
318         mTvInputManager.unregisterCallback(mInternalCallback);
319         mStarted = false;
320     }
321 
322     @WorkerThread
onUpdateChannels(List<Channel> channels)323     private void onUpdateChannels(List<Channel> channels) {
324         boolean isChannelRecordMapChanged = false;
325         Set<Long> removedChannelIdSet = new HashSet<>(mChannelRecordMap.keySet());
326         // Builds removedChannelIdSet.
327         for (Channel channel : channels) {
328             if (updateChannelRecordMapFromChannel(channel)) {
329                 isChannelRecordMapChanged = true;
330             }
331             removedChannelIdSet.remove(channel.getId());
332         }
333 
334         if (!removedChannelIdSet.isEmpty()) {
335             for (Long channelId : removedChannelIdSet) {
336                 mChannelRecordMap.remove(channelId);
337                 if (mAvailableChannelRecordMap.remove(channelId) != null) {
338                     isChannelRecordMapChanged = true;
339                 }
340             }
341         }
342         if (isChannelRecordMapChanged && mChannelRecordMapLoaded
343                 && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
344             mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
345         }
346     }
347 
348     @WorkerThread
onLoadWatchHistory(Uri uri)349     private void onLoadWatchHistory(Uri uri) {
350         List<WatchedProgram> history = new ArrayList<>();
351         try (Cursor cursor = mContext.getContentResolver().query(uri, null, null, null, null)) {
352             if (cursor != null && cursor.moveToLast()) {
353                 do {
354                     if (mCancelLoadTask) {
355                         return;
356                     }
357                     history.add(createWatchedProgramFromWatchedProgramCursor(cursor));
358                 } while (cursor.moveToPrevious());
359             }
360         }
361         for (WatchedProgram watchedProgram : history) {
362             final ChannelRecord channelRecord =
363                     updateChannelRecordFromWatchedProgram(watchedProgram);
364             if (mChannelRecordMapLoaded && channelRecord != null) {
365                 runOnMainThread(new Runnable() {
366                     @Override
367                     public void run() {
368                         for (Listener l : mListeners) {
369                             l.onNewWatchLog(channelRecord);
370                         }
371                     }
372                 });
373             }
374         }
375         if (!mChannelRecordMapLoaded) {
376             mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
377         }
378     }
379 
convertFromWatchedHistoryManagerRecords( WatchedHistoryManager.WatchedRecord watchedRecord)380     private WatchedProgram convertFromWatchedHistoryManagerRecords(
381             WatchedHistoryManager.WatchedRecord watchedRecord) {
382         long endTime = watchedRecord.watchedStartTime + watchedRecord.duration;
383         Program program = new Program.Builder()
384                 .setChannelId(watchedRecord.channelId)
385                 .setTitle("")
386                 .setStartTimeUtcMillis(watchedRecord.watchedStartTime)
387                 .setEndTimeUtcMillis(endTime)
388                 .build();
389         return new WatchedProgram(program, watchedRecord.watchedStartTime, endTime);
390     }
391 
392     @Override
onLoadFinished()393     public void onLoadFinished() {
394         for (WatchedHistoryManager.WatchedRecord record
395                 : mWatchedHistoryManager.getWatchedHistory()) {
396             updateChannelRecordFromWatchedProgram(
397                     convertFromWatchedHistoryManagerRecords(record));
398         }
399         mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
400     }
401 
402     @Override
onNewRecordAdded(WatchedHistoryManager.WatchedRecord watchedRecord)403     public void onNewRecordAdded(WatchedHistoryManager.WatchedRecord watchedRecord) {
404         final ChannelRecord channelRecord = updateChannelRecordFromWatchedProgram(
405                 convertFromWatchedHistoryManagerRecords(watchedRecord));
406         if (mChannelRecordMapLoaded && channelRecord != null) {
407             runOnMainThread(new Runnable() {
408                 @Override
409                 public void run() {
410                     for (Listener l : mListeners) {
411                         l.onNewWatchLog(channelRecord);
412                     }
413                 }
414             });
415         }
416     }
417 
createWatchedProgramFromWatchedProgramCursor(Cursor cursor)418     private WatchedProgram createWatchedProgramFromWatchedProgramCursor(Cursor cursor) {
419         // Have to initiate the indexes of WatchedProgram Columns.
420         if (mIndexWatchChannelId == -1) {
421             mIndexWatchChannelId = cursor.getColumnIndex(
422                     TvContract.WatchedPrograms.COLUMN_CHANNEL_ID);
423             mIndexProgramTitle = cursor.getColumnIndex(
424                     TvContract.WatchedPrograms.COLUMN_TITLE);
425             mIndexProgramStartTime = cursor.getColumnIndex(
426                     TvContract.WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS);
427             mIndexProgramEndTime = cursor.getColumnIndex(
428                     TvContract.WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
429             mIndexWatchStartTime = cursor.getColumnIndex(
430                     TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
431             mIndexWatchEndTime = cursor.getColumnIndex(
432                     TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
433         }
434 
435         Program program = new Program.Builder()
436                 .setChannelId(cursor.getLong(mIndexWatchChannelId))
437                 .setTitle(cursor.getString(mIndexProgramTitle))
438                 .setStartTimeUtcMillis(cursor.getLong(mIndexProgramStartTime))
439                 .setEndTimeUtcMillis(cursor.getLong(mIndexProgramEndTime))
440                 .build();
441 
442         return new WatchedProgram(program,
443                 cursor.getLong(mIndexWatchStartTime),
444                 cursor.getLong(mIndexWatchEndTime));
445     }
446 
onNotifyChannelRecordMapLoaded()447     private void onNotifyChannelRecordMapLoaded() {
448         mChannelRecordMapLoaded = true;
449         runOnMainThread(new Runnable() {
450             @Override
451             public void run() {
452                 for (Listener l : mListeners) {
453                     l.onChannelRecordLoaded();
454                 }
455             }
456         });
457     }
458 
onNotifyChannelRecordMapChanged()459     private void onNotifyChannelRecordMapChanged() {
460         runOnMainThread(new Runnable() {
461             @Override
462             public void run() {
463                 for (Listener l : mListeners) {
464                     l.onChannelRecordChanged();
465                 }
466             }
467         });
468     }
469 
470     /**
471      * Returns true if ChannelRecords are added into mChannelRecordMap or removed from it.
472      */
updateChannelRecordMapFromChannel(Channel channel)473     private boolean updateChannelRecordMapFromChannel(Channel channel) {
474         if (!channel.isBrowsable()) {
475             mChannelRecordMap.remove(channel.getId());
476             return mAvailableChannelRecordMap.remove(channel.getId()) != null;
477         }
478         ChannelRecord channelRecord = mChannelRecordMap.get(channel.getId());
479         boolean inputRemoved = !mInputs.contains(channel.getInputId());
480         if (channelRecord == null) {
481             ChannelRecord record = new ChannelRecord(mContext, channel, inputRemoved);
482             mChannelRecordMap.put(channel.getId(), record);
483             if (!inputRemoved) {
484                 mAvailableChannelRecordMap.put(channel.getId(), record);
485                 return true;
486             }
487             return false;
488         }
489         boolean oldInputRemoved = channelRecord.isInputRemoved();
490         channelRecord.setChannel(channel, inputRemoved);
491         return oldInputRemoved != inputRemoved;
492     }
493 
updateChannelRecordFromWatchedProgram(WatchedProgram program)494     private ChannelRecord updateChannelRecordFromWatchedProgram(WatchedProgram program) {
495         ChannelRecord channelRecord = null;
496         if (program != null && program.getWatchEndTimeMs() != 0l) {
497             channelRecord = mChannelRecordMap.get(program.getProgram().getChannelId());
498             if (channelRecord != null
499                     && channelRecord.getLastWatchEndTimeMs() < program.getWatchEndTimeMs()) {
500                 channelRecord.logWatchHistory(program);
501             }
502         }
503         return channelRecord;
504     }
505 
506     private class RecommendationContentObserver extends ContentObserver {
RecommendationContentObserver(Handler handler)507         public RecommendationContentObserver(Handler handler) {
508             super(handler);
509         }
510 
511         @Override
onChange(final boolean selfChange, final Uri uri)512         public void onChange(final boolean selfChange, final Uri uri) {
513             switch (sUriMatcher.match(uri)) {
514                 case MATCH_WATCHED_PROGRAM_ID:
515                     if (!mHandler.hasMessages(MSG_UPDATE_WATCH_HISTORY,
516                             TvContract.WatchedPrograms.CONTENT_URI)) {
517                         mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY, uri).sendToTarget();
518                     }
519                     break;
520             }
521         }
522     }
523 
runOnMainThread(Runnable r)524     private void runOnMainThread(Runnable r) {
525         if (Looper.myLooper() == Looper.getMainLooper()) {
526             r.run();
527         } else {
528             mMainHandler.post(r);
529         }
530     }
531 
532     /**
533      * A listener interface to receive notification about the recommendation data.
534      *
535      * @MainThread
536      */
537     public interface Listener {
538         /**
539          * Called when loading channel record map from database is finished.
540          * It will be called after RecommendationDataManager.start() is finished.
541          *
542          * <p>Note that this method is called on the main thread.
543          */
onChannelRecordLoaded()544         void onChannelRecordLoaded();
545 
546         /**
547          * Called when a new watch log is added into the corresponding channelRecord.
548          *
549          * <p>Note that this method is called on the main thread.
550          *
551          * @param channelRecord The channel record corresponds to the new watch log.
552          */
onNewWatchLog(ChannelRecord channelRecord)553         void onNewWatchLog(ChannelRecord channelRecord);
554 
555         /**
556          * Called when the channel record map changes.
557          *
558          * <p>Note that this method is called on the main thread.
559          */
onChannelRecordChanged()560         void onChannelRecordChanged();
561     }
562 
563     private static class RecommendationHandler extends WeakHandler<RecommendationDataManager> {
RecommendationHandler(@onNull Looper looper, RecommendationDataManager ref)564         public RecommendationHandler(@NonNull Looper looper, RecommendationDataManager ref) {
565             super(looper, ref);
566         }
567 
568         @Override
handleMessage(Message msg, @NonNull RecommendationDataManager dataManager)569         public void handleMessage(Message msg, @NonNull RecommendationDataManager dataManager) {
570             switch (msg.what) {
571                 case MSG_START:
572                     dataManager.onStart();
573                     break;
574                 case MSG_STOP:
575                     if (dataManager.mStarted) {
576                         dataManager.onStop();
577                     }
578                     break;
579                 case MSG_UPDATE_CHANNELS:
580                     if (dataManager.mStarted) {
581                         dataManager.onUpdateChannels((List<Channel>) msg.obj);
582                     }
583                     break;
584                 case MSG_UPDATE_WATCH_HISTORY:
585                     if (dataManager.mStarted) {
586                         dataManager.onLoadWatchHistory((Uri) msg.obj);
587                     }
588                     break;
589                 case MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED:
590                     if (dataManager.mStarted) {
591                         dataManager.onNotifyChannelRecordMapLoaded();
592                     }
593                     break;
594                 case MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED:
595                     if (dataManager.mStarted) {
596                         dataManager.onNotifyChannelRecordMapChanged();
597                     }
598                     break;
599             }
600         }
601     }
602 
603     private static class RecommendationMainHandler extends WeakHandler<RecommendationDataManager> {
RecommendationMainHandler(@onNull Looper looper, RecommendationDataManager ref)604         public RecommendationMainHandler(@NonNull Looper looper, RecommendationDataManager ref) {
605             super(looper, ref);
606         }
607 
608         @Override
handleMessage(Message msg, @NonNull RecommendationDataManager referent)609         protected void handleMessage(Message msg, @NonNull RecommendationDataManager referent) { }
610     }
611 }
612