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