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.provider;
18
19import android.annotation.SuppressLint;
20import android.annotation.TargetApi;
21import android.content.ContentUris;
22import android.content.Context;
23import android.database.ContentObserver;
24import android.media.tv.TvContract.Programs;
25import android.net.Uri;
26import android.os.Build;
27import android.os.Handler;
28import android.os.Looper;
29import android.support.annotation.MainThread;
30import android.support.annotation.VisibleForTesting;
31import android.util.Log;
32
33import com.android.tv.TvApplication;
34import com.android.tv.data.ChannelDataManager;
35import com.android.tv.data.Program;
36import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
37import com.android.tv.dvr.DvrDataManagerImpl;
38import com.android.tv.dvr.DvrManager;
39import com.android.tv.dvr.data.ScheduledRecording;
40import com.android.tv.dvr.data.SeriesRecording;
41import com.android.tv.dvr.recorder.SeriesRecordingScheduler;
42import com.android.tv.util.AsyncDbTask.AsyncQueryProgramTask;
43import com.android.tv.util.TvUriMatcher;
44
45import java.util.ArrayList;
46import java.util.Collections;
47import java.util.HashSet;
48import java.util.LinkedList;
49import java.util.List;
50import java.util.Objects;
51import java.util.Queue;
52import java.util.Set;
53
54/**
55 * A class to synchronizes DVR DB with TvProvider.
56 *
57 * <p>The current implementation of AsyncDbTask allows only one task to run at a time, and all the
58 * other tasks are blocked until the current one finishes. As this class performs the low priority
59 * jobs which take long time, it should not block others if possible. For this reason, only one
60 * program is queried at a time and others are queued and will be executed on the other
61 * AsyncDbTask's after the current one finishes to minimize the execution time of one AsyncDbTask.
62 */
63@MainThread
64@TargetApi(Build.VERSION_CODES.N)
65public class DvrDbSync {
66    private static final String TAG = "DvrDbSync";
67    private static final boolean DEBUG = false;
68
69    private final Context mContext;
70    private final DvrManager mDvrManager;
71    private final DvrDataManagerImpl mDataManager;
72    private final ChannelDataManager mChannelDataManager;
73    private final Queue<Long> mProgramIdQueue = new LinkedList<>();
74    private QueryProgramTask mQueryProgramTask;
75    private final SeriesRecordingScheduler mSeriesRecordingScheduler;
76    private final ContentObserver mContentObserver = new ContentObserver(new Handler(
77            Looper.getMainLooper())) {
78        @SuppressLint("SwitchIntDef")
79        @Override
80        public void onChange(boolean selfChange, Uri uri) {
81            switch (TvUriMatcher.match(uri)) {
82                case TvUriMatcher.MATCH_PROGRAM:
83                    if (DEBUG) Log.d(TAG, "onProgramsUpdated");
84                    onProgramsUpdated();
85                    break;
86                case TvUriMatcher.MATCH_PROGRAM_ID:
87                    if (DEBUG) {
88                        Log.d(TAG, "onProgramUpdated: programId=" + ContentUris.parseId(uri));
89                    }
90                    onProgramUpdated(ContentUris.parseId(uri));
91                    break;
92            }
93        }
94    };
95
96    private final ChannelDataManager.Listener mChannelDataManagerListener =
97            new ChannelDataManager.Listener() {
98                @Override
99                public void onLoadFinished() {
100                    start();
101                }
102
103                @Override
104                public void onChannelListUpdated() {
105                    onChannelsUpdated();
106                }
107
108                @Override
109                public void onChannelBrowsableChanged() { }
110            };
111
112    private final ScheduledRecordingListener mScheduleListener = new ScheduledRecordingListener() {
113        @Override
114        public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
115            for (ScheduledRecording schedule : schedules) {
116                addProgramIdToCheckIfNeeded(schedule);
117            }
118            startNextUpdateIfNeeded();
119        }
120
121        @Override
122        public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
123            for (ScheduledRecording schedule : schedules) {
124                mProgramIdQueue.remove(schedule.getProgramId());
125            }
126        }
127
128        @Override
129        public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
130            for (ScheduledRecording schedule : schedules) {
131                mProgramIdQueue.remove(schedule.getProgramId());
132                addProgramIdToCheckIfNeeded(schedule);
133            }
134            startNextUpdateIfNeeded();
135        }
136    };
137
138    public DvrDbSync(Context context, DvrDataManagerImpl dataManager) {
139        this(context, dataManager, TvApplication.getSingletons(context).getChannelDataManager(),
140                TvApplication.getSingletons(context).getDvrManager(),
141                SeriesRecordingScheduler.getInstance(context));
142    }
143
144    @VisibleForTesting
145    DvrDbSync(Context context, DvrDataManagerImpl dataManager,
146            ChannelDataManager channelDataManager, DvrManager dvrManager,
147            SeriesRecordingScheduler seriesRecordingScheduler) {
148        mContext = context;
149        mDvrManager = dvrManager;
150        mDataManager = dataManager;
151        mChannelDataManager = channelDataManager;
152        mSeriesRecordingScheduler = seriesRecordingScheduler;
153    }
154
155    /**
156     * Starts the DB sync.
157     */
158    public void start() {
159        if (!mChannelDataManager.isDbLoadFinished()) {
160            mChannelDataManager.addListener(mChannelDataManagerListener);
161            return;
162        }
163        mContext.getContentResolver().registerContentObserver(Programs.CONTENT_URI, true,
164                mContentObserver);
165        mDataManager.addScheduledRecordingListener(mScheduleListener);
166        onChannelsUpdated();
167        onProgramsUpdated();
168    }
169
170    /**
171     * Stops the DB sync.
172     */
173    public void stop() {
174        mProgramIdQueue.clear();
175        if (mQueryProgramTask != null) {
176            mQueryProgramTask.cancel(true);
177        }
178        mChannelDataManager.removeListener(mChannelDataManagerListener);
179        mDataManager.removeScheduledRecordingListener(mScheduleListener);
180        mContext.getContentResolver().unregisterContentObserver(mContentObserver);
181    }
182
183    private void onChannelsUpdated() {
184        List<SeriesRecording> seriesRecordingsToUpdate = new ArrayList<>();
185        for (SeriesRecording r : mDataManager.getSeriesRecordings()) {
186            if (r.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE
187                    && !mChannelDataManager.doesChannelExistInDb(r.getChannelId())) {
188                seriesRecordingsToUpdate.add(SeriesRecording.buildFrom(r)
189                        .setChannelOption(SeriesRecording.OPTION_CHANNEL_ALL)
190                        .setState(SeriesRecording.STATE_SERIES_STOPPED).build());
191            }
192        }
193        if (!seriesRecordingsToUpdate.isEmpty()) {
194            mDataManager.updateSeriesRecording(
195                    SeriesRecording.toArray(seriesRecordingsToUpdate));
196        }
197        List<ScheduledRecording> schedulesToRemove = new ArrayList<>();
198        for (ScheduledRecording r : mDataManager.getAvailableScheduledRecordings()) {
199            if (!mChannelDataManager.doesChannelExistInDb(r.getChannelId())) {
200                schedulesToRemove.add(r);
201                mProgramIdQueue.remove(r.getProgramId());
202            }
203        }
204        if (!schedulesToRemove.isEmpty()) {
205            mDataManager.removeScheduledRecording(
206                    ScheduledRecording.toArray(schedulesToRemove));
207        }
208    }
209
210    private void onProgramsUpdated() {
211        for (ScheduledRecording schedule : mDataManager.getAvailableScheduledRecordings()) {
212            addProgramIdToCheckIfNeeded(schedule);
213        }
214        startNextUpdateIfNeeded();
215    }
216
217    private void onProgramUpdated(long programId) {
218        addProgramIdToCheckIfNeeded(mDataManager.getScheduledRecordingForProgramId(programId));
219        startNextUpdateIfNeeded();
220    }
221
222    private void addProgramIdToCheckIfNeeded(ScheduledRecording schedule) {
223        if (schedule == null) {
224            return;
225        }
226        long programId = schedule.getProgramId();
227        if (programId != ScheduledRecording.ID_NOT_SET
228                && !mProgramIdQueue.contains(programId)
229                && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
230                || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) {
231            if (DEBUG) Log.d(TAG, "Program ID enqueued: " + programId);
232            mProgramIdQueue.offer(programId);
233            // There are schedules to be updated. Pause the SeriesRecordingScheduler until all the
234            // schedule updates finish.
235            // Note that the SeriesRecordingScheduler should be paused even though the program to
236            // check is not episodic because it can be changed to the episodic program after the
237            // update, which affect the SeriesRecordingScheduler.
238            mSeriesRecordingScheduler.pauseUpdate();
239        }
240    }
241
242    private void startNextUpdateIfNeeded() {
243        if (mQueryProgramTask != null && !mQueryProgramTask.isCancelled()) {
244            return;
245        }
246        if (!mProgramIdQueue.isEmpty()) {
247            if (DEBUG) Log.d(TAG, "Program ID dequeued: " + mProgramIdQueue.peek());
248            mQueryProgramTask = new QueryProgramTask(mProgramIdQueue.poll());
249            mQueryProgramTask.executeOnDbThread();
250        } else {
251            mSeriesRecordingScheduler.resumeUpdate();
252        }
253    }
254
255    @VisibleForTesting
256    void handleUpdateProgram(Program program, long programId) {
257        Set<SeriesRecording> seriesRecordingsToUpdate = new HashSet<>();
258        ScheduledRecording schedule = mDataManager.getScheduledRecordingForProgramId(programId);
259        if (schedule != null
260                && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
261                || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) {
262            if (program == null) {
263                mDataManager.removeScheduledRecording(schedule);
264                if (schedule.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
265                    SeriesRecording seriesRecording =
266                            mDataManager.getSeriesRecording(schedule.getSeriesRecordingId());
267                    if (seriesRecording != null) {
268                        seriesRecordingsToUpdate.add(seriesRecording);
269                    }
270                }
271            } else {
272                long currentTimeMs = System.currentTimeMillis();
273                ScheduledRecording.Builder builder = ScheduledRecording.buildFrom(schedule)
274                        .setEndTimeMs(program.getEndTimeUtcMillis())
275                        .setSeasonNumber(program.getSeasonNumber())
276                        .setEpisodeNumber(program.getEpisodeNumber())
277                        .setEpisodeTitle(program.getEpisodeTitle())
278                        .setProgramDescription(program.getDescription())
279                        .setProgramLongDescription(program.getLongDescription())
280                        .setProgramPosterArtUri(program.getPosterArtUri())
281                        .setProgramThumbnailUri(program.getThumbnailUri());
282                boolean needUpdate = false;
283                // Check the series recording.
284                SeriesRecording seriesRecordingForOldSchedule =
285                        mDataManager.getSeriesRecording(schedule.getSeriesRecordingId());
286                if (program.isEpisodic()) {
287                    // New program belongs to a series.
288                    SeriesRecording seriesRecording =
289                            mDataManager.getSeriesRecording(program.getSeriesId());
290                    if (seriesRecording == null) {
291                        // The new program is episodic while the previous one isn't.
292                        SeriesRecording newSeriesRecording = mDvrManager.addSeriesRecording(
293                                program, Collections.singletonList(program),
294                                SeriesRecording.STATE_SERIES_STOPPED);
295                        builder.setSeriesRecordingId(newSeriesRecording.getId());
296                        needUpdate = true;
297                    } else if (seriesRecording.getId() != schedule.getSeriesRecordingId()) {
298                        // The new program belongs to the other series.
299                        builder.setSeriesRecordingId(seriesRecording.getId());
300                        needUpdate = true;
301                        seriesRecordingsToUpdate.add(seriesRecording);
302                        if (seriesRecordingForOldSchedule != null) {
303                            seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
304                        }
305                    } else if (!Objects.equals(schedule.getSeasonNumber(),
306                                    program.getSeasonNumber())
307                            || !Objects.equals(schedule.getEpisodeNumber(),
308                                    program.getEpisodeNumber())) {
309                        // The episode number has been changed.
310                        if (seriesRecordingForOldSchedule != null) {
311                            seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
312                        }
313                    }
314                } else if (seriesRecordingForOldSchedule != null) {
315                    // Old program belongs to a series but the new one doesn't.
316                    seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
317                }
318                // Change start time only when the recording is not started yet.
319                boolean needToChangeStartTime =
320                        schedule.getState() != ScheduledRecording.STATE_RECORDING_IN_PROGRESS
321                        && program.getStartTimeUtcMillis() != schedule.getStartTimeMs();
322                if (needToChangeStartTime) {
323                    builder.setStartTimeMs(program.getStartTimeUtcMillis());
324                    needUpdate = true;
325                }
326                if (needUpdate || schedule.getEndTimeMs() != program.getEndTimeUtcMillis()
327                        || !Objects.equals(schedule.getSeasonNumber(), program.getSeasonNumber())
328                        || !Objects.equals(schedule.getEpisodeNumber(), program.getEpisodeNumber())
329                        || !Objects.equals(schedule.getEpisodeTitle(), program.getEpisodeTitle())
330                        || !Objects.equals(schedule.getProgramDescription(),
331                        program.getDescription())
332                        || !Objects.equals(schedule.getProgramLongDescription(),
333                        program.getLongDescription())
334                        || !Objects.equals(schedule.getProgramPosterArtUri(),
335                        program.getPosterArtUri())
336                        || !Objects.equals(schedule.getProgramThumbnailUri(),
337                        program.getThumbnailUri())) {
338                    mDataManager.updateScheduledRecording(builder.build());
339                }
340                if (!seriesRecordingsToUpdate.isEmpty()) {
341                    // The series recordings will be updated after it's resumed.
342                    mSeriesRecordingScheduler.updateSchedules(seriesRecordingsToUpdate);
343                }
344            }
345        }
346    }
347
348    private class QueryProgramTask extends AsyncQueryProgramTask {
349        private final long mProgramId;
350
351        QueryProgramTask(long programId) {
352            super(mContext.getContentResolver(), programId);
353            mProgramId = programId;
354        }
355
356        @Override
357        protected void onCancelled(Program program) {
358            if (mQueryProgramTask == this) {
359                mQueryProgramTask = null;
360            }
361            startNextUpdateIfNeeded();
362        }
363
364        @Override
365        protected void onPostExecute(Program program) {
366            if (mQueryProgramTask == this) {
367                mQueryProgramTask = null;
368            }
369            handleUpdateProgram(program, mProgramId);
370            startNextUpdateIfNeeded();
371        }
372    }
373}
374