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
17package com.android.tv.dvr.recorder;
18
19import android.annotation.SuppressLint;
20import android.annotation.TargetApi;
21import android.content.Context;
22import android.content.SharedPreferences;
23import android.os.AsyncTask;
24import android.os.Build;
25import android.support.annotation.MainThread;
26import android.text.TextUtils;
27import android.util.ArraySet;
28import android.util.Log;
29import android.util.LongSparseArray;
30
31import com.android.tv.ApplicationSingletons;
32import com.android.tv.TvApplication;
33import com.android.tv.common.CollectionUtils;
34import com.android.tv.common.SharedPreferencesUtils;
35import com.android.tv.common.SoftPreconditions;
36import com.android.tv.data.Program;
37import com.android.tv.data.epg.EpgFetcher;
38import com.android.tv.dvr.DvrDataManager;
39import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
40import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
41import com.android.tv.dvr.DvrManager;
42import com.android.tv.dvr.WritableDvrDataManager;
43import com.android.tv.dvr.data.SeasonEpisodeNumber;
44import com.android.tv.dvr.data.ScheduledRecording;
45import com.android.tv.dvr.data.SeriesInfo;
46import com.android.tv.dvr.data.SeriesRecording;
47import com.android.tv.dvr.provider.EpisodicProgramLoadTask;
48import com.android.tv.experiments.Experiments;
49
50import com.android.tv.util.LocationUtils;
51import java.util.ArrayList;
52import java.util.Arrays;
53import java.util.Collection;
54import java.util.Collections;
55import java.util.Comparator;
56import java.util.HashMap;
57import java.util.HashSet;
58import java.util.Iterator;
59import java.util.List;
60import java.util.Map;
61import java.util.Map.Entry;
62import 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)
71public 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     */
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
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
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
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
254    private void startFetchingSeriesInfo() {
255        for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) {
256            if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) {
257                executeFetchSeriesInfoTask(seriesRecording);
258            }
259        }
260    }
261
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     */
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     */
293    public void resumeUpdate() {
294        if (DEBUG) Log.d(TAG, "Schedule resumed");
295        if (!mPaused) {
296            return;
297        }
298        mPaused = false;
299        if (!mStarted) {
300            return;
301        }
302        if (!mPendingSeriesRecordings.isEmpty()) {
303            List<SeriesRecording> seriesRecordings = new ArrayList<>();
304            for (long seriesRecordingId : mPendingSeriesRecordings) {
305                SeriesRecording seriesRecording =
306                        mDataManager.getSeriesRecording(seriesRecordingId);
307                if (seriesRecording != null) {
308                    seriesRecordings.add(seriesRecording);
309                }
310            }
311            if (!seriesRecordings.isEmpty()) {
312                updateSchedules(seriesRecordings);
313            }
314        }
315    }
316
317    /**
318     * Update schedules for the given series recordings. If it's paused, the update will be done
319     * after it's resumed.
320     */
321    public void updateSchedules(Collection<SeriesRecording> seriesRecordings) {
322        if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings);
323        if (!mStarted) {
324            if (DEBUG) Log.d(TAG, "Not started yet.");
325            return;
326        }
327        if (mPaused) {
328            for (SeriesRecording r : seriesRecordings) {
329                mPendingSeriesRecordings.add(r.getId());
330            }
331            if (DEBUG) {
332                Log.d(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
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     */
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     */
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
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
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 {
485        SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) {
486            super(mContext, seriesRecordings);
487        }
488
489        @Override
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
516        protected void onCancelled(List<Program> programs) {
517            mScheduleTasks.remove(this);
518        }
519
520        @Override
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
531        FetchSeriesInfoTask(SeriesRecording seriesRecording) {
532            mSeriesRecording = seriesRecording;
533        }
534
535        @Override
536        protected SeriesInfo doInBackground(Void... voids) {
537            return EpgFetcher.createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext))
538                    .getSeriesInfo(mSeriesRecording.getSeriesId());
539        }
540
541        @Override
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
559        protected void onCancelled(SeriesInfo seriesInfo) {
560            mFetchSeriesInfoTasks.remove(mSeriesRecording.getId());
561        }
562    }
563}
564