Home | History | Annotate | Download | only in recommendation
      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);
     61         sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
     62         sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_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      */
    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 
    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      */
    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      */
    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      */
    237     public int getChannelRecordCount() {
    238         return mAvailableChannelRecordMap.size();
    239     }
    240 
    241     /**
    242      * Returns a Collection of ChannelRecords.
    243      */
    244     public Collection<ChannelRecord> getChannelRecords() {
    245         return Collections.unmodifiableCollection(mAvailableChannelRecordMap.values());
    246     }
    247 
    248     @MainThread
    249     private void start() {
    250         mHandler.sendEmptyMessage(MSG_START);
    251         mChannelDataManager.addListener(mChannelDataListener);
    252         if (mChannelDataManager.isDbLoadFinished()) {
    253             updateChannelData();
    254         }
    255     }
    256 
    257     @MainThread
    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
    270     private void updateChannelData() {
    271         mHandler.removeMessages(MSG_UPDATE_CHANNELS);
    272         mHandler.obtainMessage(MSG_UPDATE_CHANNELS, mChannelDataManager.getBrowsableChannelList())
    273                 .sendToTarget();
    274     }
    275 
    276     @MainThread
    277     private void addListener(Listener listener) {
    278         mListeners.add(listener);
    279     }
    280 
    281     @MainThread
    282     private void removeListener(Listener listener) {
    283         mListeners.remove(listener);
    284     }
    285 
    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 
    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
    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
    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 
    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
    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
    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 
    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 
    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 
    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      */
    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 
    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 {
    507         public RecommendationContentObserver(Handler handler) {
    508             super(handler);
    509         }
    510 
    511         @Override
    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 
    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          */
    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          */
    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          */
    560         void onChannelRecordChanged();
    561     }
    562 
    563     private static class RecommendationHandler extends WeakHandler<RecommendationDataManager> {
    564         public RecommendationHandler(@NonNull Looper looper, RecommendationDataManager ref) {
    565             super(looper, ref);
    566         }
    567 
    568         @Override
    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> {
    604         public RecommendationMainHandler(@NonNull Looper looper, RecommendationDataManager ref) {
    605             super(looper, ref);
    606         }
    607 
    608         @Override
    609         protected void handleMessage(Message msg, @NonNull RecommendationDataManager referent) { }
    610     }
    611 }
    612