• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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