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