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