/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.tv.recommendation; import android.annotation.SuppressLint; import android.content.Context; import android.database.ContentObserver; import android.database.Cursor; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import android.util.Log; import com.android.tv.TvSingletons; import com.android.tv.common.WeakHandler; import com.android.tv.common.util.PermissionUtils; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ProgramImpl; import com.android.tv.data.WatchedHistoryManager; import com.android.tv.data.api.Channel; import com.android.tv.data.api.Program; import com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** Manages teh data need to make recommendations. */ public class RecommendationDataManager implements WatchedHistoryManager.Listener { private static final String TAG = "RecommendationDataManag"; private static final int MSG_START = 1000; private static final int MSG_STOP = 1001; private static final int MSG_UPDATE_CHANNELS = 1002; private static final int MSG_UPDATE_WATCH_HISTORY = 1003; private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED = 1004; private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED = 1005; private static final int MSG_FIRST = MSG_START; private static final int MSG_LAST = MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED; private static RecommendationDataManager sManager; private final ContentObserver mContentObserver; private final Map mChannelRecordMap = new ConcurrentHashMap<>(); private final Map mAvailableChannelRecordMap = new ConcurrentHashMap<>(); private final Context mContext; private boolean mStarted; private boolean mCancelLoadTask; private boolean mChannelRecordMapLoaded; private int mIndexWatchChannelId = -1; private int mIndexProgramTitle = -1; private int mIndexProgramStartTime = -1; private int mIndexProgramEndTime = -1; private int mIndexWatchStartTime = -1; private int mIndexWatchEndTime = -1; private TvInputManager mTvInputManager; private final Set mInputs = new HashSet<>(); private final HandlerThread mHandlerThread; private final Handler mHandler; private final Handler mMainHandler; @Nullable private WatchedHistoryManager mWatchedHistoryManager; private final ChannelDataManager mChannelDataManager; private final ChannelDataManager.Listener mChannelDataListener = new ChannelDataManager.Listener() { @Override @MainThread public void onLoadFinished() { updateChannelData(); } @Override @MainThread public void onChannelListUpdated() { updateChannelData(); } @Override @MainThread public void onChannelBrowsableChanged() { updateChannelData(); } }; // For thread safety, this variable is handled only on main thread. private final List mListeners = new ArrayList<>(); /** * Gets instance of RecommendationDataManager, and adds a {@link Listener}. The listener methods * will be called in the same thread as its caller of the method. Note that {@link * #release(Listener)} should be called when this manager is not needed any more. */ public static synchronized RecommendationDataManager acquireManager( Context context, @NonNull Listener listener) { if (sManager == null) { sManager = new RecommendationDataManager(context); } sManager.addListener(listener); return sManager; } private final TvInputCallback mInternalCallback = new TvInputCallback() { @Override public void onInputStateChanged(String inputId, int state) {} @Override public void onInputAdded(String inputId) { if (!mStarted) { return; } mInputs.add(inputId); if (!mChannelRecordMapLoaded) { return; } boolean channelRecordMapChanged = false; for (ChannelRecord channelRecord : mChannelRecordMap.values()) { if (channelRecord.getChannel().getInputId().equals(inputId)) { channelRecord.setInputRemoved(false); mAvailableChannelRecordMap.put( channelRecord.getChannel().getId(), channelRecord); channelRecordMapChanged = true; } } if (channelRecordMapChanged && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) { mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED); } } @Override public void onInputRemoved(String inputId) { if (!mStarted) { return; } mInputs.remove(inputId); if (!mChannelRecordMapLoaded) { return; } boolean channelRecordMapChanged = false; for (ChannelRecord channelRecord : mChannelRecordMap.values()) { if (channelRecord.getChannel().getInputId().equals(inputId)) { channelRecord.setInputRemoved(true); mAvailableChannelRecordMap.remove(channelRecord.getChannel().getId()); channelRecordMapChanged = true; } } if (channelRecordMapChanged && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) { mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED); } } @Override public void onInputUpdated(String inputId) {} }; private RecommendationDataManager(Context context) { mContext = context.getApplicationContext(); mHandlerThread = new HandlerThread("RecommendationDataManager"); mHandlerThread.start(); mHandler = new RecommendationHandler(mHandlerThread.getLooper(), this); mMainHandler = new RecommendationMainHandler(Looper.getMainLooper(), this); mContentObserver = new RecommendationContentObserver(mHandler); mChannelDataManager = TvSingletons.getSingletons(mContext).getChannelDataManager(); runOnMainThread(this::start); } /** * Removes the {@link Listener}, and releases RecommendationDataManager if there are no * listeners remained. */ public void release(@NonNull final Listener listener) { runOnMainThread( () -> { removeListener(listener); if (mListeners.size() == 0) { stop(); } }); } /** Returns a {@link ChannelRecord} corresponds to the channel ID {@code ChannelId}. */ public ChannelRecord getChannelRecord(long channelId) { return mAvailableChannelRecordMap.get(channelId); } /** Returns the number of channels registered in ChannelRecord map. */ public int getChannelRecordCount() { return mAvailableChannelRecordMap.size(); } /** Returns a Collection of ChannelRecords. */ public Collection getChannelRecords() { return Collections.unmodifiableCollection(mAvailableChannelRecordMap.values()); } @MainThread private void start() { mHandler.sendEmptyMessage(MSG_START); mChannelDataManager.addListener(mChannelDataListener); if (mChannelDataManager.isDbLoadFinished()) { updateChannelData(); } } @MainThread private void stop() { if (mWatchedHistoryManager != null) { mWatchedHistoryManager.setListener(null); } for (int what = MSG_FIRST; what <= MSG_LAST; ++what) { mHandler.removeMessages(what); } mChannelDataManager.removeListener(mChannelDataListener); mHandler.sendEmptyMessage(MSG_STOP); mHandlerThread.quitSafely(); mMainHandler.removeCallbacksAndMessages(null); sManager = null; } @MainThread private void updateChannelData() { mHandler.removeMessages(MSG_UPDATE_CHANNELS); mHandler.obtainMessage(MSG_UPDATE_CHANNELS, mChannelDataManager.getBrowsableChannelList()) .sendToTarget(); } private void addListener(Listener listener) { runOnMainThread(() -> mListeners.add(listener)); } @MainThread private void removeListener(Listener listener) { mListeners.remove(listener); } private void onStart() { if (!mStarted) { mStarted = true; mCancelLoadTask = false; if (!PermissionUtils.hasAccessWatchedHistory(mContext)) { mWatchedHistoryManager = new WatchedHistoryManager(mContext); mWatchedHistoryManager.setListener(this); mWatchedHistoryManager.start(); } else { mContext.getContentResolver() .registerContentObserver( TvContract.WatchedPrograms.CONTENT_URI, true, mContentObserver); mHandler.obtainMessage( MSG_UPDATE_WATCH_HISTORY, TvContract.WatchedPrograms.CONTENT_URI) .sendToTarget(); } mTvInputManager = (TvInputManager) mContext.getSystemService(Context.TV_INPUT_SERVICE); mTvInputManager.registerCallback(mInternalCallback, mHandler); for (TvInputInfo input : mTvInputManager.getTvInputList()) { mInputs.add(input.getId()); } } if (mChannelRecordMapLoaded) { mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED); } } private void onStop() { mContext.getContentResolver().unregisterContentObserver(mContentObserver); mCancelLoadTask = true; mChannelRecordMap.clear(); mAvailableChannelRecordMap.clear(); mInputs.clear(); mTvInputManager.unregisterCallback(mInternalCallback); mStarted = false; } @WorkerThread private void onUpdateChannels(List channels) { boolean isChannelRecordMapChanged = false; Set removedChannelIdSet = new HashSet<>(mChannelRecordMap.keySet()); // Builds removedChannelIdSet. for (Channel channel : channels) { if (updateChannelRecordMapFromChannel(channel)) { isChannelRecordMapChanged = true; } removedChannelIdSet.remove(channel.getId()); } if (!removedChannelIdSet.isEmpty()) { for (Long channelId : removedChannelIdSet) { mChannelRecordMap.remove(channelId); if (mAvailableChannelRecordMap.remove(channelId) != null) { isChannelRecordMapChanged = true; } } } if (isChannelRecordMapChanged && mChannelRecordMapLoaded && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) { mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED); } } @WorkerThread private void onLoadWatchHistory(Uri uri) { List history = new ArrayList<>(); try (Cursor cursor = mContext.getContentResolver().query(uri, null, null, null, null)) { if (cursor != null && cursor.moveToLast()) { do { if (mCancelLoadTask) { return; } history.add(createWatchedProgramFromWatchedProgramCursor(cursor)); } while (cursor.moveToPrevious()); } } catch (Exception e) { Log.e(TAG, "Error trying to load watch history from " + uri, e); return; } for (WatchedProgram watchedProgram : history) { final ChannelRecord channelRecord = updateChannelRecordFromWatchedProgram(watchedProgram); if (mChannelRecordMapLoaded && channelRecord != null) { runOnMainThread( () -> { for (Listener l : mListeners) { l.onNewWatchLog(channelRecord); } }); } } if (!mChannelRecordMapLoaded) { mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED); } } private WatchedProgram convertFromWatchedHistoryManagerRecords( WatchedHistoryManager.WatchedRecord watchedRecord) { long endTime = watchedRecord.watchedStartTime + watchedRecord.duration; Program program = new ProgramImpl.Builder() .setChannelId(watchedRecord.channelId) .setTitle("") .setStartTimeUtcMillis(watchedRecord.watchedStartTime) .setEndTimeUtcMillis(endTime) .build(); return new WatchedProgram(program, watchedRecord.watchedStartTime, endTime); } @Override public void onLoadFinished() { for (WatchedHistoryManager.WatchedRecord record : mWatchedHistoryManager.getWatchedHistory()) { updateChannelRecordFromWatchedProgram(convertFromWatchedHistoryManagerRecords(record)); } mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED); } @Override public void onNewRecordAdded(WatchedHistoryManager.WatchedRecord watchedRecord) { final ChannelRecord channelRecord = updateChannelRecordFromWatchedProgram( convertFromWatchedHistoryManagerRecords(watchedRecord)); if (mChannelRecordMapLoaded && channelRecord != null) { runOnMainThread( () -> { for (Listener l : mListeners) { l.onNewWatchLog(channelRecord); } }); } } private WatchedProgram createWatchedProgramFromWatchedProgramCursor(Cursor cursor) { // Have to initiate the indexes of WatchedProgram Columns. if (mIndexWatchChannelId == -1) { mIndexWatchChannelId = cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_CHANNEL_ID); mIndexProgramTitle = cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_TITLE); mIndexProgramStartTime = cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS); mIndexProgramEndTime = cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS); mIndexWatchStartTime = cursor.getColumnIndex( TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS); mIndexWatchEndTime = cursor.getColumnIndex( TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS); } Program program = new ProgramImpl.Builder() .setChannelId(cursor.getLong(mIndexWatchChannelId)) .setTitle(cursor.getString(mIndexProgramTitle)) .setStartTimeUtcMillis(cursor.getLong(mIndexProgramStartTime)) .setEndTimeUtcMillis(cursor.getLong(mIndexProgramEndTime)) .build(); return new WatchedProgram( program, cursor.getLong(mIndexWatchStartTime), cursor.getLong(mIndexWatchEndTime)); } private void onNotifyChannelRecordMapLoaded() { mChannelRecordMapLoaded = true; runOnMainThread( () -> { for (Listener l : mListeners) { l.onChannelRecordLoaded(); } }); } private void onNotifyChannelRecordMapChanged() { runOnMainThread( () -> { for (Listener l : mListeners) { l.onChannelRecordChanged(); } }); } /** Returns true if ChannelRecords are added into mChannelRecordMap or removed from it. */ private boolean updateChannelRecordMapFromChannel(Channel channel) { if (!channel.isBrowsable()) { mChannelRecordMap.remove(channel.getId()); return mAvailableChannelRecordMap.remove(channel.getId()) != null; } ChannelRecord channelRecord = mChannelRecordMap.get(channel.getId()); boolean inputRemoved = !mInputs.contains(channel.getInputId()); if (channelRecord == null) { ChannelRecord record = new ChannelRecord(mContext, channel, inputRemoved); mChannelRecordMap.put(channel.getId(), record); if (!inputRemoved) { mAvailableChannelRecordMap.put(channel.getId(), record); return true; } return false; } boolean oldInputRemoved = channelRecord.isInputRemoved(); channelRecord.setChannel(channel, inputRemoved); return oldInputRemoved != inputRemoved; } private ChannelRecord updateChannelRecordFromWatchedProgram(WatchedProgram program) { ChannelRecord channelRecord = null; if (program != null && program.getWatchEndTimeMs() != 0L) { channelRecord = mChannelRecordMap.get(program.getProgram().getChannelId()); if (channelRecord != null && channelRecord.getLastWatchEndTimeMs() < program.getWatchEndTimeMs()) { channelRecord.logWatchHistory(program); } } return channelRecord; } private class RecommendationContentObserver extends ContentObserver { public RecommendationContentObserver(Handler handler) { super(handler); } @SuppressLint("SwitchIntDef") @Override public void onChange(final boolean selfChange, final Uri uri) { switch (TvUriMatcher.match(uri)) { case TvUriMatcher.MATCH_WATCHED_PROGRAM_ID: if (!mHandler.hasMessages( MSG_UPDATE_WATCH_HISTORY, TvContract.WatchedPrograms.CONTENT_URI)) { mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY, uri).sendToTarget(); } break; } } } private void runOnMainThread(Runnable r) { if (Looper.myLooper() == Looper.getMainLooper()) { r.run(); } else { mMainHandler.post(r); } } /** A listener interface to receive notification about the recommendation data. @MainThread */ public interface Listener { /** * Called when loading channel record map from database is finished. It will be called after * RecommendationDataManager.start() is finished. * *

Note that this method is called on the main thread. */ void onChannelRecordLoaded(); /** * Called when a new watch log is added into the corresponding channelRecord. * *

Note that this method is called on the main thread. * * @param channelRecord The channel record corresponds to the new watch log. */ void onNewWatchLog(ChannelRecord channelRecord); /** * Called when the channel record map changes. * *

Note that this method is called on the main thread. */ void onChannelRecordChanged(); } private static class RecommendationHandler extends WeakHandler { public RecommendationHandler(@NonNull Looper looper, RecommendationDataManager ref) { super(looper, ref); } @Override public void handleMessage(Message msg, @NonNull RecommendationDataManager dataManager) { switch (msg.what) { case MSG_START: dataManager.onStart(); break; case MSG_STOP: if (dataManager.mStarted) { dataManager.onStop(); } break; case MSG_UPDATE_CHANNELS: if (dataManager.mStarted) { dataManager.onUpdateChannels((List) msg.obj); } break; case MSG_UPDATE_WATCH_HISTORY: if (dataManager.mStarted) { dataManager.onLoadWatchHistory((Uri) msg.obj); } break; case MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED: if (dataManager.mStarted) { dataManager.onNotifyChannelRecordMapLoaded(); } break; case MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED: if (dataManager.mStarted) { dataManager.onNotifyChannelRecordMapChanged(); } break; } } } private static class RecommendationMainHandler extends WeakHandler { public RecommendationMainHandler(@NonNull Looper looper, RecommendationDataManager ref) { super(looper, ref); } @Override protected void handleMessage(Message msg, @NonNull RecommendationDataManager referent) {} } }