1 /* 2 * Copyright (C) 2016 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.dvr.recorder; 18 19 import android.annotation.SuppressLint; 20 import android.annotation.TargetApi; 21 import android.content.Context; 22 import android.content.SharedPreferences; 23 import android.os.AsyncTask; 24 import android.os.Build; 25 import android.support.annotation.MainThread; 26 import android.text.TextUtils; 27 import android.util.ArraySet; 28 import android.util.Log; 29 import android.util.LongSparseArray; 30 import com.android.tv.TvSingletons; 31 import com.android.tv.common.SoftPreconditions; 32 import com.android.tv.common.experiments.Experiments; 33 import com.android.tv.common.util.CollectionUtils; 34 import com.android.tv.common.util.SharedPreferencesUtils; 35 import com.android.tv.data.Program; 36 import com.android.tv.data.epg.EpgReader; 37 import com.android.tv.dvr.DvrDataManager; 38 import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; 39 import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; 40 import com.android.tv.dvr.DvrManager; 41 import com.android.tv.dvr.WritableDvrDataManager; 42 import com.android.tv.dvr.data.ScheduledRecording; 43 import com.android.tv.dvr.data.SeasonEpisodeNumber; 44 import com.android.tv.dvr.data.SeriesInfo; 45 import com.android.tv.dvr.data.SeriesRecording; 46 import com.android.tv.dvr.provider.EpisodicProgramLoadTask; 47 import java.util.ArrayList; 48 import java.util.Arrays; 49 import java.util.Collection; 50 import java.util.Collections; 51 import java.util.Comparator; 52 import java.util.HashMap; 53 import java.util.HashSet; 54 import java.util.Iterator; 55 import java.util.List; 56 import java.util.Map; 57 import java.util.Map.Entry; 58 import java.util.Set; 59 import javax.inject.Provider; 60 61 /** 62 * Creates the {@link com.android.tv.dvr.data.ScheduledRecording}s for the {@link 63 * com.android.tv.dvr.data.SeriesRecording}. 64 * 65 * <p>The current implementation assumes that the series recordings are scheduled only for one 66 * channel. 67 */ 68 @TargetApi(Build.VERSION_CODES.N) 69 public class SeriesRecordingScheduler { 70 private static final String TAG = "SeriesRecordingSchd"; 71 private static final boolean DEBUG = false; 72 73 private static final String KEY_FETCHED_SERIES_IDS = 74 "SeriesRecordingScheduler.fetched_series_ids"; 75 76 @SuppressLint("StaticFieldLeak") 77 private static SeriesRecordingScheduler sInstance; 78 79 /** Creates and returns the {@link SeriesRecordingScheduler}. */ 80 public static synchronized SeriesRecordingScheduler getInstance(Context context) { 81 if (sInstance == null) { 82 sInstance = new SeriesRecordingScheduler(context); 83 } 84 return sInstance; 85 } 86 87 private final Context mContext; 88 private final DvrManager mDvrManager; 89 private final WritableDvrDataManager mDataManager; 90 private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>(); 91 private final LongSparseArray<FetchSeriesInfoTask> mFetchSeriesInfoTasks = 92 new LongSparseArray<>(); 93 private final Set<String> mFetchedSeriesIds = new ArraySet<>(); 94 private final SharedPreferences mSharedPreferences; 95 private boolean mStarted; 96 private boolean mPaused; 97 private final Set<Long> mPendingSeriesRecordings = new ArraySet<>(); 98 99 private final SeriesRecordingListener mSeriesRecordingListener = 100 new SeriesRecordingListener() { 101 @Override 102 public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { 103 for (SeriesRecording seriesRecording : seriesRecordings) { 104 executeFetchSeriesInfoTask(seriesRecording); 105 } 106 } 107 108 @Override 109 public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { 110 // Cancel the update. 111 for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); 112 iter.hasNext(); ) { 113 SeriesRecordingUpdateTask task = iter.next(); 114 if (CollectionUtils.subtract( 115 task.getSeriesRecordings(), 116 seriesRecordings, 117 SeriesRecording.ID_COMPARATOR) 118 .isEmpty()) { 119 task.cancel(true); 120 iter.remove(); 121 } 122 } 123 for (SeriesRecording seriesRecording : seriesRecordings) { 124 FetchSeriesInfoTask task = 125 mFetchSeriesInfoTasks.get(seriesRecording.getId()); 126 if (task != null) { 127 task.cancel(true); 128 mFetchSeriesInfoTasks.remove(seriesRecording.getId()); 129 } 130 } 131 } 132 133 @Override 134 public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { 135 List<SeriesRecording> stopped = new ArrayList<>(); 136 List<SeriesRecording> normal = new ArrayList<>(); 137 for (SeriesRecording r : seriesRecordings) { 138 if (r.isStopped()) { 139 stopped.add(r); 140 } else { 141 normal.add(r); 142 } 143 } 144 if (!stopped.isEmpty()) { 145 onSeriesRecordingRemoved(SeriesRecording.toArray(stopped)); 146 } 147 if (!normal.isEmpty()) { 148 updateSchedules(normal); 149 } 150 } 151 }; 152 153 private final ScheduledRecordingListener mScheduledRecordingListener = 154 new ScheduledRecordingListener() { 155 @Override 156 public void onScheduledRecordingAdded(ScheduledRecording... schedules) { 157 // No need to update series recordings when the new schedule is added. 158 } 159 160 @Override 161 public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { 162 handleScheduledRecordingChange(Arrays.asList(schedules)); 163 } 164 165 @Override 166 public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { 167 List<ScheduledRecording> schedulesForUpdate = new ArrayList<>(); 168 for (ScheduledRecording r : schedules) { 169 if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED 170 || r.getState() 171 == ScheduledRecording.STATE_RECORDING_CLIPPED) 172 && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET 173 && !TextUtils.isEmpty(r.getSeasonNumber()) 174 && !TextUtils.isEmpty(r.getEpisodeNumber())) { 175 schedulesForUpdate.add(r); 176 } 177 } 178 if (!schedulesForUpdate.isEmpty()) { 179 handleScheduledRecordingChange(schedulesForUpdate); 180 } 181 } 182 183 private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) { 184 if (schedules.isEmpty()) { 185 return; 186 } 187 Set<Long> seriesRecordingIds = new HashSet<>(); 188 for (ScheduledRecording r : schedules) { 189 if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) { 190 seriesRecordingIds.add(r.getSeriesRecordingId()); 191 } 192 } 193 if (!seriesRecordingIds.isEmpty()) { 194 List<SeriesRecording> seriesRecordings = new ArrayList<>(); 195 for (Long id : seriesRecordingIds) { 196 SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id); 197 if (seriesRecording != null) { 198 seriesRecordings.add(seriesRecording); 199 } 200 } 201 if (!seriesRecordings.isEmpty()) { 202 updateSchedules(seriesRecordings); 203 } 204 } 205 } 206 }; 207 208 private SeriesRecordingScheduler(Context context) { 209 mContext = context.getApplicationContext(); 210 TvSingletons tvSingletons = TvSingletons.getSingletons(context); 211 mDvrManager = tvSingletons.getDvrManager(); 212 mDataManager = (WritableDvrDataManager) tvSingletons.getDvrDataManager(); 213 mSharedPreferences = 214 context.getSharedPreferences( 215 SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE); 216 mFetchedSeriesIds.addAll( 217 mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS, Collections.emptySet())); 218 } 219 220 /** Starts the scheduler. */ 221 @MainThread 222 public void start() { 223 SoftPreconditions.checkState(mDataManager.isInitialized()); 224 if (mStarted) { 225 return; 226 } 227 if (DEBUG) Log.d(TAG, "start"); 228 mStarted = true; 229 mDataManager.addSeriesRecordingListener(mSeriesRecordingListener); 230 mDataManager.addScheduledRecordingListener(mScheduledRecordingListener); 231 startFetchingSeriesInfo(); 232 updateSchedules(mDataManager.getSeriesRecordings()); 233 } 234 235 @MainThread 236 public void stop() { 237 if (!mStarted) { 238 return; 239 } 240 if (DEBUG) Log.d(TAG, "stop"); 241 mStarted = false; 242 for (int i = 0; i < mFetchSeriesInfoTasks.size(); i++) { 243 FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(mFetchSeriesInfoTasks.keyAt(i)); 244 task.cancel(true); 245 } 246 mFetchSeriesInfoTasks.clear(); 247 for (SeriesRecordingUpdateTask task : mScheduleTasks) { 248 task.cancel(true); 249 } 250 mScheduleTasks.clear(); 251 mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); 252 mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener); 253 } 254 255 private void startFetchingSeriesInfo() { 256 for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) { 257 if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) { 258 executeFetchSeriesInfoTask(seriesRecording); 259 } 260 } 261 } 262 263 private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) { 264 if (Experiments.CLOUD_EPG.get()) { 265 FetchSeriesInfoTask task = 266 new FetchSeriesInfoTask( 267 seriesRecording, 268 TvSingletons.getSingletons(mContext).providesEpgReader()); 269 task.execute(); 270 mFetchSeriesInfoTasks.put(seriesRecording.getId(), task); 271 } 272 } 273 274 /** Pauses the updates of the series recordings. */ 275 public void pauseUpdate() { 276 if (DEBUG) Log.d(TAG, "Schedule paused"); 277 if (mPaused) { 278 return; 279 } 280 mPaused = true; 281 if (!mStarted) { 282 return; 283 } 284 for (SeriesRecordingUpdateTask task : mScheduleTasks) { 285 for (SeriesRecording r : task.getSeriesRecordings()) { 286 mPendingSeriesRecordings.add(r.getId()); 287 } 288 task.cancel(true); 289 } 290 } 291 292 /** Resumes the updates of the series recordings. */ 293 public void resumeUpdate() { 294 if (DEBUG) Log.d(TAG, "Schedule resumed"); 295 if (!mPaused) { 296 return; 297 } 298 mPaused = false; 299 if (!mStarted) { 300 return; 301 } 302 if (!mPendingSeriesRecordings.isEmpty()) { 303 List<SeriesRecording> seriesRecordings = new ArrayList<>(); 304 for (long seriesRecordingId : mPendingSeriesRecordings) { 305 SeriesRecording seriesRecording = 306 mDataManager.getSeriesRecording(seriesRecordingId); 307 if (seriesRecording != null) { 308 seriesRecordings.add(seriesRecording); 309 } 310 } 311 if (!seriesRecordings.isEmpty()) { 312 updateSchedules(seriesRecordings); 313 } 314 } 315 } 316 317 /** 318 * Update schedules for the given series recordings. If it's paused, the update will be done 319 * after it's resumed. 320 */ 321 public void updateSchedules(Collection<SeriesRecording> seriesRecordings) { 322 if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings); 323 if (!mStarted) { 324 if (DEBUG) Log.d(TAG, "Not started yet."); 325 return; 326 } 327 if (mPaused) { 328 for (SeriesRecording r : seriesRecordings) { 329 mPendingSeriesRecordings.add(r.getId()); 330 } 331 if (DEBUG) { 332 Log.d( 333 TAG, 334 "The scheduler has been paused. Adding to the pending list. size=" 335 + mPendingSeriesRecordings.size()); 336 } 337 return; 338 } 339 Set<SeriesRecording> previousSeriesRecordings = new HashSet<>(); 340 for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); 341 iter.hasNext(); ) { 342 SeriesRecordingUpdateTask task = iter.next(); 343 if (CollectionUtils.containsAny( 344 task.getSeriesRecordings(), seriesRecordings, SeriesRecording.ID_COMPARATOR)) { 345 // The task is affected by the seriesRecordings 346 task.cancel(true); 347 previousSeriesRecordings.addAll(task.getSeriesRecordings()); 348 iter.remove(); 349 } 350 } 351 List<SeriesRecording> seriesRecordingsToUpdate = 352 CollectionUtils.union( 353 seriesRecordings, previousSeriesRecordings, SeriesRecording.ID_COMPARATOR); 354 for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator(); 355 iter.hasNext(); ) { 356 SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId()); 357 if (seriesRecording == null || seriesRecording.isStopped()) { 358 // Series recording has been removed or stopped. 359 iter.remove(); 360 } 361 } 362 if (seriesRecordingsToUpdate.isEmpty()) { 363 return; 364 } 365 if (needToReadAllChannels(seriesRecordingsToUpdate)) { 366 SeriesRecordingUpdateTask task = 367 new SeriesRecordingUpdateTask(seriesRecordingsToUpdate); 368 mScheduleTasks.add(task); 369 if (DEBUG) Log.d(TAG, "Added schedule task: " + task); 370 task.execute(); 371 } else { 372 for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { 373 SeriesRecordingUpdateTask task = 374 new SeriesRecordingUpdateTask(Collections.singletonList(seriesRecording)); 375 mScheduleTasks.add(task); 376 if (DEBUG) Log.d(TAG, "Added schedule task: " + task); 377 task.execute(); 378 } 379 } 380 } 381 382 private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) { 383 for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { 384 if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) { 385 return true; 386 } 387 } 388 return false; 389 } 390 391 /** 392 * Pick one program per an episode. 393 * 394 * <p>Note that the programs which has been already scheduled have the highest priority, and all 395 * of them are added even though they are the same episodes. That's because the schedules should 396 * be added to the series recording. 397 * 398 * <p>If there are no existing schedules for an episode, one program which starts earlier is 399 * picked. 400 */ 401 private LongSparseArray<List<Program>> pickOneProgramPerEpisode( 402 List<SeriesRecording> seriesRecordings, List<Program> programs) { 403 return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs); 404 } 405 406 /** @see #pickOneProgramPerEpisode(List, List) */ 407 public static LongSparseArray<List<Program>> pickOneProgramPerEpisode( 408 DvrDataManager dataManager, 409 List<SeriesRecording> seriesRecordings, 410 List<Program> programs) { 411 // Initialize. 412 LongSparseArray<List<Program>> result = new LongSparseArray<>(); 413 Map<String, Long> seriesRecordingIds = new HashMap<>(); 414 for (SeriesRecording seriesRecording : seriesRecordings) { 415 result.put(seriesRecording.getId(), new ArrayList<>()); 416 seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId()); 417 } 418 // Group programs by the episode. 419 Map<SeasonEpisodeNumber, List<Program>> programsForEpisodeMap = new HashMap<>(); 420 for (Program program : programs) { 421 long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId()); 422 if (TextUtils.isEmpty(program.getSeasonNumber()) 423 || TextUtils.isEmpty(program.getEpisodeNumber())) { 424 // Add all the programs if it doesn't have season number or episode number. 425 result.get(seriesRecordingId).add(program); 426 continue; 427 } 428 SeasonEpisodeNumber seasonEpisodeNumber = 429 new SeasonEpisodeNumber( 430 seriesRecordingId, 431 program.getSeasonNumber(), 432 program.getEpisodeNumber()); 433 List<Program> programsForEpisode = programsForEpisodeMap.get(seasonEpisodeNumber); 434 if (programsForEpisode == null) { 435 programsForEpisode = new ArrayList<>(); 436 programsForEpisodeMap.put(seasonEpisodeNumber, programsForEpisode); 437 } 438 programsForEpisode.add(program); 439 } 440 // Pick one program. 441 for (Entry<SeasonEpisodeNumber, List<Program>> entry : programsForEpisodeMap.entrySet()) { 442 List<Program> programsForEpisode = entry.getValue(); 443 Collections.sort( 444 programsForEpisode, 445 new Comparator<Program>() { 446 @Override 447 public int compare(Program lhs, Program rhs) { 448 // Place the existing schedule first. 449 boolean lhsScheduled = isProgramScheduled(dataManager, lhs); 450 boolean rhsScheduled = isProgramScheduled(dataManager, rhs); 451 if (lhsScheduled && !rhsScheduled) { 452 return -1; 453 } 454 if (!lhsScheduled && rhsScheduled) { 455 return 1; 456 } 457 // Sort by the start time in ascending order. 458 return lhs.compareTo(rhs); 459 } 460 }); 461 boolean added = false; 462 // Add all the scheduled programs 463 List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId); 464 for (Program program : programsForEpisode) { 465 if (isProgramScheduled(dataManager, program)) { 466 programsForSeries.add(program); 467 added = true; 468 } else if (!added) { 469 programsForSeries.add(program); 470 break; 471 } 472 } 473 } 474 return result; 475 } 476 477 private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) { 478 ScheduledRecording schedule = 479 dataManager.getScheduledRecordingForProgramId(program.getId()); 480 return schedule != null 481 && schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED; 482 } 483 484 private void updateFetchedSeries() { 485 mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply(); 486 } 487 488 /** 489 * This works only for the existing series recordings. Do not use this task for the "adding 490 * series recording" UI. 491 */ 492 private class SeriesRecordingUpdateTask extends EpisodicProgramLoadTask { 493 SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) { 494 super(mContext, seriesRecordings); 495 } 496 497 @Override 498 protected void onPostExecute(List<Program> programs) { 499 if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs); 500 mScheduleTasks.remove(this); 501 if (programs == null) { 502 Log.e( 503 TAG, 504 "Creating schedules for series recording failed: " + getSeriesRecordings()); 505 return; 506 } 507 LongSparseArray<List<Program>> seriesProgramMap = 508 pickOneProgramPerEpisode(getSeriesRecordings(), programs); 509 for (SeriesRecording seriesRecording : getSeriesRecordings()) { 510 // Check the series recording is still valid. 511 SeriesRecording actualSeriesRecording = 512 mDataManager.getSeriesRecording(seriesRecording.getId()); 513 if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) { 514 continue; 515 } 516 List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId()); 517 if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null 518 && !programsToSchedule.isEmpty()) { 519 mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule); 520 } 521 } 522 } 523 524 @Override 525 protected void onCancelled(List<Program> programs) { 526 mScheduleTasks.remove(this); 527 } 528 529 @Override 530 public String toString() { 531 return "SeriesRecordingUpdateTask:{" 532 + "series_recordings=" 533 + getSeriesRecordings() 534 + "}"; 535 } 536 } 537 538 private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> { 539 private final SeriesRecording mSeriesRecording; 540 private final Provider<EpgReader> mEpgReaderProvider; 541 542 FetchSeriesInfoTask( 543 SeriesRecording seriesRecording, Provider<EpgReader> epgReaderProvider) { 544 mSeriesRecording = seriesRecording; 545 mEpgReaderProvider = epgReaderProvider; 546 } 547 548 @Override 549 protected SeriesInfo doInBackground(Void... voids) { 550 return mEpgReaderProvider.get().getSeriesInfo(mSeriesRecording.getSeriesId()); 551 } 552 553 @Override 554 protected void onPostExecute(SeriesInfo seriesInfo) { 555 if (seriesInfo != null) { 556 mDataManager.updateSeriesRecording( 557 SeriesRecording.buildFrom(mSeriesRecording) 558 .setTitle(seriesInfo.getTitle()) 559 .setDescription(seriesInfo.getDescription()) 560 .setLongDescription(seriesInfo.getLongDescription()) 561 .setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds()) 562 .setPosterUri(seriesInfo.getPosterUri()) 563 .setPhotoUri(seriesInfo.getPhotoUri()) 564 .build()); 565 mFetchedSeriesIds.add(seriesInfo.getId()); 566 updateFetchedSeries(); 567 } 568 mFetchSeriesInfoTasks.remove(mSeriesRecording.getId()); 569 } 570 571 @Override 572 protected void onCancelled(SeriesInfo seriesInfo) { 573 mFetchSeriesInfoTasks.remove(mSeriesRecording.getId()); 574 } 575 } 576 } 577