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.dvr; 18 19 import android.annotation.TargetApi; 20 import android.content.Context; 21 import android.media.tv.TvInputInfo; 22 import android.os.Build; 23 import android.support.annotation.MainThread; 24 import android.support.annotation.NonNull; 25 import android.support.annotation.VisibleForTesting; 26 import android.util.ArraySet; 27 import android.util.Range; 28 29 import com.android.tv.ApplicationSingletons; 30 import com.android.tv.TvApplication; 31 import com.android.tv.common.SoftPreconditions; 32 import com.android.tv.data.Channel; 33 import com.android.tv.data.ChannelDataManager; 34 import com.android.tv.data.Program; 35 import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; 36 import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; 37 import com.android.tv.dvr.recorder.InputTaskScheduler; 38 import com.android.tv.util.CompositeComparator; 39 import com.android.tv.dvr.data.ScheduledRecording; 40 import com.android.tv.dvr.data.SeriesRecording; 41 import com.android.tv.util.Utils; 42 43 import java.util.ArrayList; 44 import java.util.Collections; 45 import java.util.Comparator; 46 import java.util.HashMap; 47 import java.util.Iterator; 48 import java.util.List; 49 import java.util.Map; 50 import java.util.Set; 51 import java.util.concurrent.CopyOnWriteArraySet; 52 53 /** 54 * A class to manage the schedules. 55 */ 56 @TargetApi(Build.VERSION_CODES.N) 57 @MainThread 58 public class DvrScheduleManager { 59 private static final String TAG = "DvrScheduleManager"; 60 61 /** 62 * The default priority of scheduled recording. 63 */ 64 public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1; 65 /** 66 * The default priority of series recording. 67 */ 68 public static final long DEFAULT_SERIES_PRIORITY = DEFAULT_PRIORITY >> 1; 69 // The new priority will have the offset from the existing one. 70 private static final long PRIORITY_OFFSET = 1024; 71 72 private static final Comparator<ScheduledRecording> RESULT_COMPARATOR = 73 new CompositeComparator<>( 74 ScheduledRecording.PRIORITY_COMPARATOR.reversed(), 75 ScheduledRecording.START_TIME_COMPARATOR, 76 ScheduledRecording.ID_COMPARATOR.reversed()); 77 78 // The candidate comparator should be the consistent with 79 // InputTaskScheduler#CANDIDATE_COMPARATOR. 80 private static final Comparator<ScheduledRecording> CANDIDATE_COMPARATOR = 81 new CompositeComparator<>( 82 ScheduledRecording.PRIORITY_COMPARATOR, 83 ScheduledRecording.END_TIME_COMPARATOR, 84 ScheduledRecording.ID_COMPARATOR); 85 86 private final Context mContext; 87 private final DvrDataManagerImpl mDataManager; 88 private final ChannelDataManager mChannelDataManager; 89 90 private final Map<String, List<ScheduledRecording>> mInputScheduleMap = new HashMap<>(); 91 // The inner map is a hash map from scheduled recording to its conflicting status, i.e., 92 // the boolean value true denotes the schedule is just partially conflicting, which means 93 // although there's conflict, it might still be recorded partially. 94 private final Map<String, Map<Long, ConflictInfo>> mInputConflictInfoMap = new HashMap<>(); 95 96 private boolean mInitialized; 97 98 private final Set<OnInitializeListener> mOnInitializeListeners = new CopyOnWriteArraySet<>(); 99 private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>(); 100 private final Set<OnConflictStateChangeListener> mOnConflictStateChangeListeners = 101 new ArraySet<>(); 102 103 public DvrScheduleManager(Context context) { 104 mContext = context; 105 ApplicationSingletons appSingletons = TvApplication.getSingletons(context); 106 mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager(); 107 mChannelDataManager = appSingletons.getChannelDataManager(); 108 if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { 109 buildData(); 110 } else { 111 mDataManager.addDvrScheduleLoadFinishedListener( 112 new OnDvrScheduleLoadFinishedListener() { 113 @Override 114 public void onDvrScheduleLoadFinished() { 115 mDataManager.removeDvrScheduleLoadFinishedListener(this); 116 if (mChannelDataManager.isDbLoadFinished() && !mInitialized) { 117 buildData(); 118 } 119 } 120 }); 121 } 122 ScheduledRecordingListener scheduledRecordingListener = new ScheduledRecordingListener() { 123 @Override 124 public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { 125 if (!mInitialized) { 126 return; 127 } 128 for (ScheduledRecording schedule : scheduledRecordings) { 129 if (!schedule.isNotStarted() && !schedule.isInProgress()) { 130 continue; 131 } 132 TvInputInfo input = Utils 133 .getTvInputInfoForInputId(mContext, schedule.getInputId()); 134 if (!SoftPreconditions.checkArgument(input != null, TAG, 135 "Input was removed for : " + schedule)) { 136 // Input removed. 137 mInputScheduleMap.remove(schedule.getInputId()); 138 mInputConflictInfoMap.remove(schedule.getInputId()); 139 continue; 140 } 141 String inputId = input.getId(); 142 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 143 if (schedules == null) { 144 schedules = new ArrayList<>(); 145 mInputScheduleMap.put(inputId, schedules); 146 } 147 schedules.add(schedule); 148 } 149 onSchedulesChanged(); 150 notifyScheduledRecordingAdded(scheduledRecordings); 151 } 152 153 @Override 154 public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { 155 if (!mInitialized) { 156 return; 157 } 158 for (ScheduledRecording schedule : scheduledRecordings) { 159 TvInputInfo input = Utils 160 .getTvInputInfoForInputId(mContext, schedule.getInputId()); 161 if (input == null) { 162 // Input removed. 163 mInputScheduleMap.remove(schedule.getInputId()); 164 mInputConflictInfoMap.remove(schedule.getInputId()); 165 continue; 166 } 167 String inputId = input.getId(); 168 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 169 if (schedules != null) { 170 schedules.remove(schedule); 171 if (schedules.isEmpty()) { 172 mInputScheduleMap.remove(inputId); 173 } 174 } 175 Map<Long, ConflictInfo> conflictInfo = mInputConflictInfoMap.get(inputId); 176 if (conflictInfo != null) { 177 conflictInfo.remove(schedule.getId()); 178 if (conflictInfo.isEmpty()) { 179 mInputConflictInfoMap.remove(inputId); 180 } 181 } 182 } 183 onSchedulesChanged(); 184 notifyScheduledRecordingRemoved(scheduledRecordings); 185 } 186 187 @Override 188 public void onScheduledRecordingStatusChanged( 189 ScheduledRecording... scheduledRecordings) { 190 if (!mInitialized) { 191 return; 192 } 193 for (ScheduledRecording schedule : scheduledRecordings) { 194 TvInputInfo input = Utils 195 .getTvInputInfoForInputId(mContext, schedule.getInputId()); 196 if (!SoftPreconditions.checkArgument(input != null, TAG, 197 "Input was removed for : " + schedule)) { 198 // Input removed. 199 mInputScheduleMap.remove(schedule.getInputId()); 200 mInputConflictInfoMap.remove(schedule.getInputId()); 201 continue; 202 } 203 String inputId = input.getId(); 204 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 205 if (schedules == null) { 206 schedules = new ArrayList<>(); 207 mInputScheduleMap.put(inputId, schedules); 208 } 209 // Compare ID because ScheduledRecording.equals() doesn't work if the state 210 // is changed. 211 for (Iterator<ScheduledRecording> i = schedules.iterator(); i.hasNext(); ) { 212 if (i.next().getId() == schedule.getId()) { 213 i.remove(); 214 break; 215 } 216 } 217 if (schedule.isNotStarted() || schedule.isInProgress()) { 218 schedules.add(schedule); 219 } 220 if (schedules.isEmpty()) { 221 mInputScheduleMap.remove(inputId); 222 } 223 // Update conflict list as well 224 Map<Long, ConflictInfo> conflictInfo = mInputConflictInfoMap.get(inputId); 225 if (conflictInfo != null) { 226 ConflictInfo oldConflictInfo = conflictInfo.get(schedule.getId()); 227 if (oldConflictInfo != null) { 228 oldConflictInfo.schedule = schedule; 229 } 230 } 231 } 232 onSchedulesChanged(); 233 notifyScheduledRecordingStatusChanged(scheduledRecordings); 234 } 235 }; 236 mDataManager.addScheduledRecordingListener(scheduledRecordingListener); 237 ChannelDataManager.Listener channelDataManagerListener = new ChannelDataManager.Listener() { 238 @Override 239 public void onLoadFinished() { 240 if (mDataManager.isDvrScheduleLoadFinished() && !mInitialized) { 241 buildData(); 242 } 243 } 244 245 @Override 246 public void onChannelListUpdated() { 247 if (mDataManager.isDvrScheduleLoadFinished()) { 248 buildData(); 249 } 250 } 251 252 @Override 253 public void onChannelBrowsableChanged() { 254 } 255 }; 256 mChannelDataManager.addListener(channelDataManagerListener); 257 } 258 259 /** 260 * Returns the started recordings for the given input. 261 */ 262 private List<ScheduledRecording> getStartedRecordings(String inputId) { 263 if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { 264 return Collections.emptyList(); 265 } 266 List<ScheduledRecording> result = new ArrayList<>(); 267 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 268 if (schedules != null) { 269 for (ScheduledRecording schedule : schedules) { 270 if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { 271 result.add(schedule); 272 } 273 } 274 } 275 return result; 276 } 277 278 private void buildData() { 279 mInputScheduleMap.clear(); 280 for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { 281 if (!schedule.isNotStarted() && !schedule.isInProgress()) { 282 continue; 283 } 284 Channel channel = mChannelDataManager.getChannel(schedule.getChannelId()); 285 if (channel != null) { 286 String inputId = channel.getInputId(); 287 // Do not check whether the input is valid or not. The input might be temporarily 288 // invalid. 289 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 290 if (schedules == null) { 291 schedules = new ArrayList<>(); 292 mInputScheduleMap.put(inputId, schedules); 293 } 294 schedules.add(schedule); 295 } 296 } 297 if (!mInitialized) { 298 mInitialized = true; 299 notifyInitialize(); 300 } 301 onSchedulesChanged(); 302 } 303 304 private void onSchedulesChanged() { 305 // TODO: notify conflict state change when some conflicting recording becomes partially 306 // conflicting, vice versa. 307 List<ScheduledRecording> addedConflicts = new ArrayList<>(); 308 List<ScheduledRecording> removedConflicts = new ArrayList<>(); 309 for (String inputId : mInputScheduleMap.keySet()) { 310 Map<Long, ConflictInfo> oldConflictInfo = mInputConflictInfoMap.get(inputId); 311 Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>(); 312 if (oldConflictInfo != null) { 313 for (ConflictInfo conflictInfo : oldConflictInfo.values()) { 314 oldConflictMap.put(conflictInfo.schedule.getId(), conflictInfo.schedule); 315 } 316 } 317 List<ConflictInfo> conflicts = getConflictingSchedulesInfo(inputId); 318 if (conflicts.isEmpty()) { 319 mInputConflictInfoMap.remove(inputId); 320 } else { 321 Map<Long, ConflictInfo> conflictInfos = new HashMap<>(); 322 for (ConflictInfo conflictInfo : conflicts) { 323 conflictInfos.put(conflictInfo.schedule.getId(), conflictInfo); 324 if (oldConflictMap.remove(conflictInfo.schedule.getId()) == null) { 325 addedConflicts.add(conflictInfo.schedule); 326 } 327 } 328 mInputConflictInfoMap.put(inputId, conflictInfos); 329 } 330 removedConflicts.addAll(oldConflictMap.values()); 331 } 332 if (!removedConflicts.isEmpty()) { 333 notifyConflictStateChange(false, ScheduledRecording.toArray(removedConflicts)); 334 } 335 if (!addedConflicts.isEmpty()) { 336 notifyConflictStateChange(true, ScheduledRecording.toArray(addedConflicts)); 337 } 338 } 339 340 /** 341 * Returns {@code true} if this class has been initialized. 342 */ 343 public boolean isInitialized() { 344 return mInitialized; 345 } 346 347 /** 348 * Adds a {@link ScheduledRecordingListener}. 349 */ 350 public final void addScheduledRecordingListener(ScheduledRecordingListener listener) { 351 mScheduledRecordingListeners.add(listener); 352 } 353 354 /** 355 * Removes a {@link ScheduledRecordingListener}. 356 */ 357 public final void removeScheduledRecordingListener(ScheduledRecordingListener listener) { 358 mScheduledRecordingListeners.remove(listener); 359 } 360 361 /** 362 * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} for each listener. 363 */ 364 private void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { 365 for (ScheduledRecordingListener l : mScheduledRecordingListeners) { 366 l.onScheduledRecordingAdded(scheduledRecordings); 367 } 368 } 369 370 /** 371 * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} for each listener. 372 */ 373 private void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { 374 for (ScheduledRecordingListener l : mScheduledRecordingListeners) { 375 l.onScheduledRecordingRemoved(scheduledRecordings); 376 } 377 } 378 379 /** 380 * Calls {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} for each listener. 381 */ 382 private void notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { 383 for (ScheduledRecordingListener l : mScheduledRecordingListeners) { 384 l.onScheduledRecordingStatusChanged(scheduledRecordings); 385 } 386 } 387 388 /** 389 * Adds a {@link OnInitializeListener}. 390 */ 391 public final void addOnInitializeListener(OnInitializeListener listener) { 392 mOnInitializeListeners.add(listener); 393 } 394 395 /** 396 * Removes a {@link OnInitializeListener}. 397 */ 398 public final void removeOnInitializeListener(OnInitializeListener listener) { 399 mOnInitializeListeners.remove(listener); 400 } 401 402 /** 403 * Calls {@link OnInitializeListener#onInitialize} for each listener. 404 */ 405 private void notifyInitialize() { 406 for (OnInitializeListener l : mOnInitializeListeners) { 407 l.onInitialize(); 408 } 409 } 410 411 /** 412 * Adds a {@link OnConflictStateChangeListener}. 413 */ 414 public final void addOnConflictStateChangeListener(OnConflictStateChangeListener listener) { 415 mOnConflictStateChangeListeners.add(listener); 416 } 417 418 /** 419 * Removes a {@link OnConflictStateChangeListener}. 420 */ 421 public final void removeOnConflictStateChangeListener(OnConflictStateChangeListener listener) { 422 mOnConflictStateChangeListeners.remove(listener); 423 } 424 425 /** 426 * Calls {@link OnConflictStateChangeListener#onConflictStateChange} for each listener. 427 */ 428 private void notifyConflictStateChange(boolean conflict, 429 ScheduledRecording... scheduledRecordings) { 430 for (OnConflictStateChangeListener l : mOnConflictStateChangeListeners) { 431 l.onConflictStateChange(conflict, scheduledRecordings); 432 } 433 } 434 435 /** 436 * Returns the priority for the program if it is recorded. 437 * <p> 438 * The recording will have the higher priority than the existing ones. 439 */ 440 public long suggestNewPriority() { 441 if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { 442 return DEFAULT_PRIORITY; 443 } 444 return suggestHighestPriority(); 445 } 446 447 private long suggestHighestPriority() { 448 long highestPriority = DEFAULT_PRIORITY - PRIORITY_OFFSET; 449 for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { 450 if (schedule.getPriority() > highestPriority) { 451 highestPriority = schedule.getPriority(); 452 } 453 } 454 return highestPriority + PRIORITY_OFFSET; 455 } 456 457 /** 458 * Suggests the higher priority than the schedules which overlap with {@code schedule}. 459 */ 460 public long suggestHighestPriority(ScheduledRecording schedule) { 461 List<ScheduledRecording> schedules = mInputScheduleMap.get(schedule.getInputId()); 462 if (schedules == null) { 463 return DEFAULT_PRIORITY; 464 } 465 long highestPriority = Long.MIN_VALUE; 466 for (ScheduledRecording r : schedules) { 467 if (!r.equals(schedule) && r.isOverLapping(schedule) 468 && r.getPriority() > highestPriority) { 469 highestPriority = r.getPriority(); 470 } 471 } 472 if (highestPriority == Long.MIN_VALUE || highestPriority < schedule.getPriority()) { 473 return schedule.getPriority(); 474 } 475 return highestPriority + PRIORITY_OFFSET; 476 } 477 478 /** 479 * Suggests the higher priority than the schedules which overlap with {@code schedule}. 480 */ 481 public long suggestHighestPriority(String inputId, Range<Long> peroid, long basePriority) { 482 List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); 483 if (schedules == null) { 484 return DEFAULT_PRIORITY; 485 } 486 long highestPriority = Long.MIN_VALUE; 487 for (ScheduledRecording r : schedules) { 488 if (r.isOverLapping(peroid) && r.getPriority() > highestPriority) { 489 highestPriority = r.getPriority(); 490 } 491 } 492 if (highestPriority == Long.MIN_VALUE || highestPriority < basePriority) { 493 return basePriority; 494 } 495 return highestPriority + PRIORITY_OFFSET; 496 } 497 498 /** 499 * Returns the priority for a series recording. 500 * <p> 501 * The recording will have the higher priority than the existing series. 502 */ 503 public long suggestNewSeriesPriority() { 504 if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { 505 return DEFAULT_SERIES_PRIORITY; 506 } 507 return suggestHighestSeriesPriority(); 508 } 509 510 /** 511 * Returns the priority for a series recording by order of series recording priority. 512 * 513 * Higher order will have higher priority. 514 */ 515 public static long suggestSeriesPriority(int order) { 516 return DEFAULT_SERIES_PRIORITY + order * PRIORITY_OFFSET; 517 } 518 519 private long suggestHighestSeriesPriority() { 520 long highestPriority = DEFAULT_SERIES_PRIORITY - PRIORITY_OFFSET; 521 for (SeriesRecording schedule : mDataManager.getSeriesRecordings()) { 522 if (schedule.getPriority() > highestPriority) { 523 highestPriority = schedule.getPriority(); 524 } 525 } 526 return highestPriority + PRIORITY_OFFSET; 527 } 528 529 /** 530 * Returns a sorted list of all scheduled recordings that will not be recorded if 531 * this program is going to be recorded, with their priorities in decending order. 532 * <p> 533 * An empty list means there is no conflicts. If there is conflict, a priority higher than 534 * the first recording in the returned list should be assigned to the new schedule of this 535 * program to guarantee the program would be completely recorded. 536 */ 537 public List<ScheduledRecording> getConflictingSchedules(Program program) { 538 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 539 SoftPreconditions.checkState(Program.isValid(program), TAG, 540 "Program is invalid: " + program); 541 SoftPreconditions.checkState( 542 program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(), TAG, 543 "Program duration is empty: " + program); 544 if (!mInitialized || !Program.isValid(program) 545 || program.getStartTimeUtcMillis() >= program.getEndTimeUtcMillis()) { 546 return Collections.emptyList(); 547 } 548 TvInputInfo input = Utils.getTvInputInfoForProgram(mContext, program); 549 if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { 550 return Collections.emptyList(); 551 } 552 return getConflictingSchedules(input, Collections.singletonList( 553 ScheduledRecording.builder(input.getId(), program) 554 .setPriority(suggestHighestPriority()) 555 .build())); 556 } 557 558 /** 559 * Returns list of all conflicting scheduled recordings for the given {@code seriesRecording} 560 * recording. 561 * <p> 562 * Any empty list means there is no conflicts. 563 */ 564 public List<ScheduledRecording> getConflictingSchedules(SeriesRecording seriesRecording) { 565 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 566 SoftPreconditions.checkState(seriesRecording != null, TAG, "series recording is null"); 567 if (!mInitialized || seriesRecording == null) { 568 return Collections.emptyList(); 569 } 570 TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, seriesRecording.getInputId()); 571 if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { 572 return Collections.emptyList(); 573 } 574 List<ScheduledRecording> scheduledRecordingForSeries = mDataManager.getScheduledRecordings( 575 seriesRecording.getId()); 576 List<ScheduledRecording> availableScheduledRecordingForSeries = new ArrayList<>(); 577 for (ScheduledRecording scheduledRecording : scheduledRecordingForSeries) { 578 if (scheduledRecording.isNotStarted() || scheduledRecording.isInProgress()) { 579 availableScheduledRecordingForSeries.add(scheduledRecording); 580 } 581 } 582 if (availableScheduledRecordingForSeries.isEmpty()) { 583 return Collections.emptyList(); 584 } 585 return getConflictingSchedules(input, availableScheduledRecordingForSeries); 586 } 587 588 /** 589 * Returns a sorted list of all scheduled recordings that will not be recorded if 590 * this channel is going to be recorded, with their priority in decending order. 591 * <p> 592 * An empty list means there is no conflicts. If there is conflict, a priority higher than 593 * the first recording in the returned list should be assigned to the new schedule of this 594 * channel to guarantee the channel would be completely recorded in the designated time range. 595 */ 596 public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs, 597 long endTimeMs) { 598 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 599 SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); 600 SoftPreconditions.checkState(startTimeMs < endTimeMs, TAG, "Recording duration is empty."); 601 if (!mInitialized || channelId == Channel.INVALID_ID || startTimeMs >= endTimeMs) { 602 return Collections.emptyList(); 603 } 604 TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); 605 if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { 606 return Collections.emptyList(); 607 } 608 return getConflictingSchedules(input, Collections.singletonList( 609 ScheduledRecording.builder(input.getId(), channelId, startTimeMs, endTimeMs) 610 .setPriority(suggestHighestPriority()) 611 .build())); 612 } 613 614 /** 615 * Returns all the scheduled recordings that conflicts and will not be recorded or clipped for 616 * the given input. 617 */ 618 @NonNull 619 private List<ConflictInfo> getConflictingSchedulesInfo(String inputId) { 620 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 621 TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId); 622 SoftPreconditions.checkState(input != null, TAG, "Can't find input for : " + inputId); 623 if (!mInitialized || input == null) { 624 return Collections.emptyList(); 625 } 626 List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); 627 if (schedules == null || schedules.isEmpty()) { 628 return Collections.emptyList(); 629 } 630 return getConflictingSchedulesInfo(schedules, input.getTunerCount()); 631 } 632 633 /** 634 * Checks if the schedule is conflicting. 635 * 636 * <p>Note that the {@code schedule} should be the existing one. If not, this returns 637 * {@code false}. 638 */ 639 public boolean isConflicting(ScheduledRecording schedule) { 640 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 641 TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); 642 SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " 643 + schedule.getChannelId()); 644 if (!mInitialized || input == null) { 645 return false; 646 } 647 Map<Long, ConflictInfo> conflicts = mInputConflictInfoMap.get(input.getId()); 648 return conflicts != null && conflicts.containsKey(schedule.getId()); 649 } 650 651 /** 652 * Checks if the schedule is partially conflicting, i.e., part of the scheduled program might be 653 * recorded even if the priority of the schedule is not raised. 654 * <p> 655 * If the given schedule is not conflicting or is totally conflicting, i.e., cannot be recorded 656 * at all, this method returns {@code false} in both cases. 657 */ 658 public boolean isPartiallyConflicting(@NonNull ScheduledRecording schedule) { 659 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 660 TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); 661 SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " 662 + schedule.getChannelId()); 663 if (!mInitialized || input == null) { 664 return false; 665 } 666 Map<Long, ConflictInfo> conflicts = mInputConflictInfoMap.get(input.getId()); 667 if (conflicts != null) { 668 ConflictInfo conflictInfo = conflicts.get(schedule.getId()); 669 return conflictInfo != null && conflictInfo.partialConflict; 670 } 671 return false; 672 } 673 674 /** 675 * Returns priority ordered list of all scheduled recordings that will not be recorded if 676 * this channel is tuned to. 677 */ 678 public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) { 679 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 680 SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); 681 TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); 682 SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " 683 + channelId); 684 if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { 685 return Collections.emptyList(); 686 } 687 return getConflictingSchedulesForTune(input.getId(), channelId, System.currentTimeMillis(), 688 suggestHighestPriority(), getStartedRecordings(input.getId()), 689 input.getTunerCount()); 690 } 691 692 @VisibleForTesting 693 public static List<ScheduledRecording> getConflictingSchedulesForTune(String inputId, 694 long channelId, long currentTimeMs, long newPriority, 695 List<ScheduledRecording> startedRecordings, int tunerCount) { 696 boolean channelFound = false; 697 for (ScheduledRecording schedule : startedRecordings) { 698 if (schedule.getChannelId() == channelId) { 699 channelFound = true; 700 break; 701 } 702 } 703 List<ScheduledRecording> schedules; 704 if (!channelFound) { 705 // The current channel is not being recorded. 706 schedules = new ArrayList<>(startedRecordings); 707 schedules.add(ScheduledRecording 708 .builder(inputId, channelId, currentTimeMs, currentTimeMs + 1) 709 .setPriority(newPriority) 710 .build()); 711 } else { 712 schedules = startedRecordings; 713 } 714 return getConflictingSchedules(schedules, tunerCount); 715 } 716 717 /** 718 * Returns priority ordered list of all scheduled recordings that will not be recorded if 719 * the user keeps watching this channel. 720 * <p> 721 * Note that if the user keeps watching the channel, the channel can be recorded. 722 */ 723 public List<ScheduledRecording> getConflictingSchedulesForWatching(long channelId) { 724 SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); 725 SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); 726 TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); 727 SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " 728 + channelId); 729 if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { 730 return Collections.emptyList(); 731 } 732 List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); 733 if (schedules == null || schedules.isEmpty()) { 734 return Collections.emptyList(); 735 } 736 return getConflictingSchedulesForWatching(input.getId(), channelId, 737 System.currentTimeMillis(), suggestNewPriority(), schedules, input.getTunerCount()); 738 } 739 740 private List<ScheduledRecording> getConflictingSchedules(TvInputInfo input, 741 List<ScheduledRecording> schedulesToAdd) { 742 SoftPreconditions.checkNotNull(input); 743 if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { 744 return Collections.emptyList(); 745 } 746 List<ScheduledRecording> currentSchedules = mInputScheduleMap.get(input.getId()); 747 if (currentSchedules == null || currentSchedules.isEmpty()) { 748 return Collections.emptyList(); 749 } 750 return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount()); 751 } 752 753 @VisibleForTesting 754 static List<ScheduledRecording> getConflictingSchedulesForWatching(String inputId, 755 long channelId, long currentTimeMs, long newPriority, 756 @NonNull List<ScheduledRecording> schedules, int tunerCount) { 757 List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules); 758 List<ScheduledRecording> schedulesSameChannel = new ArrayList<>(); 759 for (ScheduledRecording schedule : schedules) { 760 if (schedule.getChannelId() == channelId) { 761 schedulesSameChannel.add(schedule); 762 schedulesToCheck.remove(schedule); 763 } 764 } 765 // Assume that the user will watch the current channel forever. 766 schedulesToCheck.add(ScheduledRecording 767 .builder(inputId, channelId, currentTimeMs, Long.MAX_VALUE) 768 .setPriority(newPriority) 769 .build()); 770 List<ScheduledRecording> result = new ArrayList<>(); 771 result.addAll(getConflictingSchedules(schedulesSameChannel, 1)); 772 result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount)); 773 Collections.sort(result, RESULT_COMPARATOR); 774 return result; 775 } 776 777 @VisibleForTesting 778 static List<ScheduledRecording> getConflictingSchedules(List<ScheduledRecording> schedulesToAdd, 779 List<ScheduledRecording> currentSchedules, int tunerCount) { 780 List<ScheduledRecording> schedulesToCheck = new ArrayList<>(currentSchedules); 781 // When the duplicate schedule is to be added, remove the current duplicate recording. 782 for (Iterator<ScheduledRecording> iter = schedulesToCheck.iterator(); iter.hasNext(); ) { 783 ScheduledRecording schedule = iter.next(); 784 for (ScheduledRecording toAdd : schedulesToAdd) { 785 if (schedule.getType() == ScheduledRecording.TYPE_PROGRAM) { 786 if (toAdd.getProgramId() == schedule.getProgramId()) { 787 iter.remove(); 788 break; 789 } 790 } else { 791 if (toAdd.getChannelId() == schedule.getChannelId() 792 && toAdd.getStartTimeMs() == schedule.getStartTimeMs() 793 && toAdd.getEndTimeMs() == schedule.getEndTimeMs()) { 794 iter.remove(); 795 break; 796 } 797 } 798 } 799 } 800 schedulesToCheck.addAll(schedulesToAdd); 801 List<Range<Long>> ranges = new ArrayList<>(); 802 for (ScheduledRecording schedule : schedulesToAdd) { 803 ranges.add(new Range<>(schedule.getStartTimeMs(), schedule.getEndTimeMs())); 804 } 805 return getConflictingSchedules(schedulesToCheck, tunerCount, ranges); 806 } 807 808 /** 809 * Returns all conflicting scheduled recordings for the given schedules and count of tuner. 810 */ 811 public static List<ScheduledRecording> getConflictingSchedules( 812 List<ScheduledRecording> schedules, int tunerCount) { 813 return getConflictingSchedules(schedules, tunerCount, null); 814 } 815 816 @VisibleForTesting 817 static List<ScheduledRecording> getConflictingSchedules( 818 List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) { 819 List<ScheduledRecording> result = new ArrayList<>(); 820 for (ConflictInfo conflictInfo : 821 getConflictingSchedulesInfo(schedules, tunerCount, periods)) { 822 result.add(conflictInfo.schedule); 823 } 824 return result; 825 } 826 827 @VisibleForTesting 828 static List<ConflictInfo> getConflictingSchedulesInfo(List<ScheduledRecording> schedules, 829 int tunerCount) { 830 return getConflictingSchedulesInfo(schedules, tunerCount, null); 831 } 832 833 /** 834 * This is the core method to calculate all the conflicting schedules (in given periods). 835 * <p> 836 * Note that this method will ignore duplicated schedules with a same hash code. (Please refer 837 * to {@link ScheduledRecording#hashCode}.) 838 * 839 * @return A {@link HashMap} from {@link ScheduledRecording} to {@link Boolean}. The boolean 840 * value denotes if the scheduled recording is partially conflicting, i.e., is possible 841 * to be partially recorded under the given schedules and tuner count {@code true}, 842 * or not {@code false}. 843 */ 844 private static List<ConflictInfo> getConflictingSchedulesInfo( 845 List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) { 846 List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules); 847 // Sort by the same order as that in InputTaskScheduler. 848 Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator()); 849 List<ScheduledRecording> recordings = new ArrayList<>(); 850 Map<ScheduledRecording, ConflictInfo> conflicts = new HashMap<>(); 851 Map<ScheduledRecording, ScheduledRecording> modified2OriginalSchedules = new HashMap<>(); 852 // Simulate InputTaskScheduler. 853 while (!schedulesToCheck.isEmpty()) { 854 ScheduledRecording schedule = schedulesToCheck.remove(0); 855 removeFinishedRecordings(recordings, schedule.getStartTimeMs()); 856 if (recordings.size() < tunerCount) { 857 recordings.add(schedule); 858 if (modified2OriginalSchedules.containsKey(schedule)) { 859 // Schedule has been modified, which means it's already conflicted. 860 // Modify its state to partially conflicted. 861 ScheduledRecording originalSchedule = modified2OriginalSchedules.get(schedule); 862 conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, true)); 863 } 864 } else { 865 ScheduledRecording candidate = findReplaceableRecording(recordings, schedule); 866 if (candidate != null) { 867 if (!modified2OriginalSchedules.containsKey(candidate)) { 868 conflicts.put(candidate, new ConflictInfo(candidate, true)); 869 } 870 recordings.remove(candidate); 871 recordings.add(schedule); 872 if (modified2OriginalSchedules.containsKey(schedule)) { 873 // Schedule has been modified, which means it's already conflicted. 874 // Modify its state to partially conflicted. 875 ScheduledRecording originalSchedule = 876 modified2OriginalSchedules.get(schedule); 877 conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, true)); 878 } 879 } else { 880 if (!modified2OriginalSchedules.containsKey(schedule)) { 881 // if schedule has been modified, it's already conflicted. 882 // No need to add it again. 883 conflicts.put(schedule, new ConflictInfo(schedule, false)); 884 } 885 long earliestEndTime = getEarliestEndTime(recordings); 886 if (earliestEndTime < schedule.getEndTimeMs()) { 887 // The schedule can starts when other recording ends even though it's 888 // clipped. 889 ScheduledRecording modifiedSchedule = ScheduledRecording.buildFrom(schedule) 890 .setStartTimeMs(earliestEndTime).build(); 891 ScheduledRecording originalSchedule = 892 modified2OriginalSchedules.getOrDefault(schedule, schedule); 893 modified2OriginalSchedules.put(modifiedSchedule, originalSchedule); 894 int insertPosition = Collections.binarySearch(schedulesToCheck, 895 modifiedSchedule, 896 ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR); 897 if (insertPosition >= 0) { 898 schedulesToCheck.add(insertPosition, modifiedSchedule); 899 } else { 900 schedulesToCheck.add(-insertPosition - 1, modifiedSchedule); 901 } 902 } 903 } 904 } 905 } 906 // Returns only the schedules with the given range. 907 if (periods != null && !periods.isEmpty()) { 908 for (Iterator<ScheduledRecording> iter = conflicts.keySet().iterator(); 909 iter.hasNext(); ) { 910 boolean overlapping = false; 911 ScheduledRecording schedule = iter.next(); 912 for (Range<Long> period : periods) { 913 if (schedule.isOverLapping(period)) { 914 overlapping = true; 915 break; 916 } 917 } 918 if (!overlapping) { 919 iter.remove(); 920 } 921 } 922 } 923 List<ConflictInfo> result = new ArrayList<>(conflicts.values()); 924 Collections.sort(result, new Comparator<ConflictInfo>() { 925 @Override 926 public int compare(ConflictInfo lhs, ConflictInfo rhs) { 927 return RESULT_COMPARATOR.compare(lhs.schedule, rhs.schedule); 928 } 929 }); 930 return result; 931 } 932 933 private static void removeFinishedRecordings(List<ScheduledRecording> recordings, 934 long currentTimeMs) { 935 for (Iterator<ScheduledRecording> iter = recordings.iterator(); iter.hasNext(); ) { 936 if (iter.next().getEndTimeMs() <= currentTimeMs) { 937 iter.remove(); 938 } 939 } 940 } 941 942 /** 943 * @see InputTaskScheduler#getReplacableTask 944 */ 945 private static ScheduledRecording findReplaceableRecording(List<ScheduledRecording> recordings, 946 ScheduledRecording schedule) { 947 // Returns the recording with the following priority. 948 // 1. The recording with the lowest priority is returned. 949 // 2. If the priorities are the same, the recording which finishes early is returned. 950 // 3. If 1) and 2) are the same, the early created schedule is returned. 951 ScheduledRecording candidate = null; 952 for (ScheduledRecording recording : recordings) { 953 if (schedule.getPriority() > recording.getPriority()) { 954 if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, recording) > 0) { 955 candidate = recording; 956 } 957 } 958 } 959 return candidate; 960 } 961 962 private static long getEarliestEndTime(List<ScheduledRecording> recordings) { 963 long earliest = Long.MAX_VALUE; 964 for (ScheduledRecording recording : recordings) { 965 if (earliest > recording.getEndTimeMs()) { 966 earliest = recording.getEndTimeMs(); 967 } 968 } 969 return earliest; 970 } 971 972 @VisibleForTesting 973 static class ConflictInfo { 974 public ScheduledRecording schedule; 975 public boolean partialConflict; 976 977 ConflictInfo(ScheduledRecording schedule, boolean partialConflict) { 978 this.schedule = schedule; 979 this.partialConflict = partialConflict; 980 } 981 } 982 983 /** 984 * A listener which is notified the initialization of schedule manager. 985 */ 986 public interface OnInitializeListener { 987 /** 988 * Called when the schedule manager has been initialized. 989 */ 990 void onInitialize(); 991 } 992 993 /** 994 * A listener which is notified the conflict state change of the schedules. 995 */ 996 public interface OnConflictStateChangeListener { 997 /** 998 * Called when the conflicting schedules change. 999 * <p> 1000 * Note that this can be called before 1001 * {@link ScheduledRecordingListener#onScheduledRecordingAdded} is called. 1002 * 1003 * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise 1004 * {@code false}. 1005 * @param schedules the schedules 1006 */ 1007 void onConflictStateChange(boolean conflict, ScheduledRecording... schedules); 1008 } 1009 }