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