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