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