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; 18 19 import android.annotation.TargetApi; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.media.tv.TvContract; 23 import android.media.tv.TvContract.Programs; 24 import android.net.Uri; 25 import android.os.Build; 26 import android.support.annotation.Nullable; 27 import android.support.annotation.VisibleForTesting; 28 import android.support.annotation.WorkerThread; 29 import android.text.TextUtils; 30 31 import com.android.tv.TvApplication; 32 import com.android.tv.common.SoftPreconditions; 33 import com.android.tv.data.Program; 34 import com.android.tv.util.AsyncDbTask.AsyncProgramQueryTask; 35 import com.android.tv.util.AsyncDbTask.CursorFilter; 36 import com.android.tv.util.PermissionUtils; 37 38 import java.util.ArrayList; 39 import java.util.Collection; 40 import java.util.Collections; 41 import java.util.HashSet; 42 import java.util.List; 43 import java.util.Objects; 44 import java.util.Set; 45 46 /** 47 * A wrapper of AsyncProgramQueryTask to load the episodic programs for the series recordings. 48 */ 49 @TargetApi(Build.VERSION_CODES.N) 50 abstract public class EpisodicProgramLoadTask { 51 private static final String TAG = "EpisodicProgramLoadTask"; 52 53 private static final int PROGRAM_ID_INDEX = Program.getColumnIndex(Programs._ID); 54 private static final int START_TIME_INDEX = 55 Program.getColumnIndex(Programs.COLUMN_START_TIME_UTC_MILLIS); 56 private static final int RECORDING_PROHIBITED_INDEX = 57 Program.getColumnIndex(Programs.COLUMN_RECORDING_PROHIBITED); 58 59 private static final String PARAM_START_TIME = "start_time"; 60 private static final String PARAM_END_TIME = "end_time"; 61 62 private static final String PROGRAM_PREDICATE = 63 Programs.COLUMN_START_TIME_UTC_MILLIS + ">? AND " 64 + Programs.COLUMN_RECORDING_PROHIBITED + "=0"; 65 private static final String PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM = 66 Programs.COLUMN_END_TIME_UTC_MILLIS + ">? AND " 67 + Programs.COLUMN_RECORDING_PROHIBITED + "=0"; 68 private static final String CHANNEL_ID_PREDICATE = Programs.COLUMN_CHANNEL_ID + "=?"; 69 private static final String PROGRAM_TITLE_PREDICATE = Programs.COLUMN_TITLE + "=?"; 70 71 private final Context mContext; 72 private final DvrDataManager mDataManager; 73 private boolean mQueryAllChannels; 74 private boolean mLoadCurrentProgram; 75 private boolean mLoadScheduledEpisode; 76 private boolean mLoadDisallowedProgram; 77 // If true, match programs with OPTION_CHANNEL_ALL. 78 private boolean mIgnoreChannelOption; 79 private final ArrayList<SeriesRecording> mSeriesRecordings = new ArrayList<>(); 80 private AsyncProgramQueryTask mProgramQueryTask; 81 82 /** 83 * 84 * Constructor used to load programs for one series recording with the given channel option. 85 */ 86 public EpisodicProgramLoadTask(Context context, SeriesRecording seriesRecording) { 87 this(context, Collections.singletonList(seriesRecording)); 88 } 89 90 /** 91 * Constructor used to load programs for multiple series recordings. The channel option is 92 * {@link SeriesRecording#OPTION_CHANNEL_ALL}. 93 */ 94 public EpisodicProgramLoadTask(Context context, Collection<SeriesRecording> seriesRecordings) { 95 mContext = context.getApplicationContext(); 96 mDataManager = TvApplication.getSingletons(context).getDvrDataManager(); 97 mSeriesRecordings.addAll(seriesRecordings); 98 } 99 100 /** 101 * Returns the series recordings. 102 */ 103 public List<SeriesRecording> getSeriesRecordings() { 104 return mSeriesRecordings; 105 } 106 107 /** 108 * Returns the program query task. It is {@code null} until it is executed. 109 */ 110 @Nullable 111 public AsyncProgramQueryTask getTask() { 112 return mProgramQueryTask; 113 } 114 115 /** 116 * Enables loading current programs. The default value is {@code false}. 117 */ 118 public EpisodicProgramLoadTask setLoadCurrentProgram(boolean loadCurrentProgram) { 119 SoftPreconditions.checkState(mProgramQueryTask == null, TAG, 120 "Can't change setting after execution."); 121 mLoadCurrentProgram = loadCurrentProgram; 122 return this; 123 } 124 125 /** 126 * Enables already schedules episodes. The default value is {@code false}. 127 */ 128 public EpisodicProgramLoadTask setLoadScheduledEpisode(boolean loadScheduledEpisode) { 129 SoftPreconditions.checkState(mProgramQueryTask == null, TAG, 130 "Can't change setting after execution."); 131 mLoadScheduledEpisode = loadScheduledEpisode; 132 return this; 133 } 134 135 /** 136 * Enables loading disallowed programs whose schedules were removed manually by the user. 137 * The default value is {@code false}. 138 */ 139 public EpisodicProgramLoadTask setLoadDisallowedProgram(boolean loadDisallowedProgram) { 140 SoftPreconditions.checkState(mProgramQueryTask == null, TAG, 141 "Can't change setting after execution."); 142 mLoadDisallowedProgram = loadDisallowedProgram; 143 return this; 144 } 145 146 /** 147 * Gives the option whether to ignore the channel option when matching programs. 148 * If {@code ignoreChannelOption} is {@code true}, the program will be matched with 149 * {@link SeriesRecording#OPTION_CHANNEL_ALL} option. 150 */ 151 public EpisodicProgramLoadTask setIgnoreChannelOption(boolean ignoreChannelOption) { 152 SoftPreconditions.checkState(mProgramQueryTask == null, TAG, 153 "Can't change setting after execution."); 154 mIgnoreChannelOption = ignoreChannelOption; 155 return this; 156 } 157 158 /** 159 * Executes the task. 160 * 161 * @see com.android.tv.util.AsyncDbTask#executeOnDbThread 162 */ 163 public void execute() { 164 if (SoftPreconditions.checkState(mProgramQueryTask == null, TAG, 165 "Can't execute task: the task is already running.")) { 166 mQueryAllChannels = mSeriesRecordings.size() > 1 167 || mSeriesRecordings.get(0).getChannelOption() 168 == SeriesRecording.OPTION_CHANNEL_ALL 169 || mIgnoreChannelOption; 170 mProgramQueryTask = createTask(); 171 mProgramQueryTask.executeOnDbThread(); 172 } 173 } 174 175 /** 176 * Cancels the task. 177 * 178 * @see android.os.AsyncTask#cancel 179 */ 180 public void cancel(boolean mayInterruptIfRunning) { 181 if (mProgramQueryTask != null) { 182 mProgramQueryTask.cancel(mayInterruptIfRunning); 183 } 184 } 185 186 /** 187 * Runs on the UI thread after the program loading finishes successfully. 188 */ 189 protected void onPostExecute(List<Program> programs) { 190 } 191 192 /** 193 * Runs on the UI thread after the program loading was canceled. 194 */ 195 protected void onCancelled(List<Program> programs) { 196 } 197 198 private AsyncProgramQueryTask createTask() { 199 SqlParams sqlParams = createSqlParams(); 200 return new AsyncProgramQueryTask(mContext.getContentResolver(), sqlParams.uri, 201 sqlParams.selection, sqlParams.selectionArgs, null, sqlParams.filter) { 202 @Override 203 protected void onPostExecute(List<Program> programs) { 204 EpisodicProgramLoadTask.this.onPostExecute(programs); 205 } 206 207 @Override 208 protected void onCancelled(List<Program> programs) { 209 EpisodicProgramLoadTask.this.onCancelled(programs); 210 } 211 }; 212 } 213 214 private SqlParams createSqlParams() { 215 SqlParams sqlParams = new SqlParams(); 216 if (PermissionUtils.hasAccessAllEpg(mContext)) { 217 sqlParams.uri = Programs.CONTENT_URI; 218 // Base 219 StringBuilder selection = new StringBuilder(mLoadCurrentProgram 220 ? PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM : PROGRAM_PREDICATE); 221 List<String> args = new ArrayList<>(); 222 args.add(Long.toString(System.currentTimeMillis())); 223 // Channel option 224 if (!mQueryAllChannels) { 225 selection.append(" AND ").append(CHANNEL_ID_PREDICATE); 226 args.add(Long.toString(mSeriesRecordings.get(0).getChannelId())); 227 } 228 // Title 229 if (mSeriesRecordings.size() == 1) { 230 selection.append(" AND ").append(PROGRAM_TITLE_PREDICATE); 231 args.add(mSeriesRecordings.get(0).getTitle()); 232 } 233 sqlParams.selection = selection.toString(); 234 sqlParams.selectionArgs = args.toArray(new String[args.size()]); 235 sqlParams.filter = new SeriesRecordingCursorFilter(mSeriesRecordings); 236 } else { 237 // The query includes the current program. Will be filtered if needed. 238 if (mQueryAllChannels) { 239 sqlParams.uri = Programs.CONTENT_URI.buildUpon() 240 .appendQueryParameter(PARAM_START_TIME, 241 String.valueOf(System.currentTimeMillis())) 242 .appendQueryParameter(PARAM_END_TIME, String.valueOf(Long.MAX_VALUE)) 243 .build(); 244 } else { 245 sqlParams.uri = TvContract.buildProgramsUriForChannel( 246 mSeriesRecordings.get(0).getChannelId(), 247 System.currentTimeMillis(), Long.MAX_VALUE); 248 } 249 sqlParams.selection = null; 250 sqlParams.selectionArgs = null; 251 sqlParams.filter = new SeriesRecordingCursorFilterForNonSystem(mSeriesRecordings); 252 } 253 return sqlParams; 254 } 255 256 @VisibleForTesting 257 static boolean isEpisodeScheduled(Collection<ScheduledEpisode> scheduledEpisodes, 258 ScheduledEpisode episode) { 259 // The episode whose season number or episode number is null will always be scheduled. 260 return scheduledEpisodes.contains(episode) && !TextUtils.isEmpty(episode.seasonNumber) 261 && !TextUtils.isEmpty(episode.episodeNumber); 262 } 263 264 /** 265 * Filter the programs which match the series recording. The episodes which the schedules are 266 * already created for are filtered out too. 267 */ 268 private class SeriesRecordingCursorFilter implements CursorFilter { 269 private final Set<Long> mDisallowedProgramIds = new HashSet<>(); 270 private final Set<ScheduledEpisode> mScheduledEpisodes = new HashSet<>(); 271 272 SeriesRecordingCursorFilter(List<SeriesRecording> seriesRecordings) { 273 if (!mLoadDisallowedProgram) { 274 mDisallowedProgramIds.addAll(mDataManager.getDisallowedProgramIds()); 275 } 276 if (!mLoadScheduledEpisode) { 277 Set<Long> seriesRecordingIds = new HashSet<>(); 278 for (SeriesRecording r : seriesRecordings) { 279 seriesRecordingIds.add(r.getId()); 280 } 281 for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) { 282 if (seriesRecordingIds.contains(r.getSeriesRecordingId()) 283 && r.getState() != ScheduledRecording.STATE_RECORDING_FAILED 284 && r.getState() != ScheduledRecording.STATE_RECORDING_CLIPPED) { 285 mScheduledEpisodes.add(new ScheduledEpisode(r)); 286 } 287 } 288 } 289 } 290 291 @Override 292 @WorkerThread 293 public boolean filter(Cursor c) { 294 if (!mLoadDisallowedProgram 295 && mDisallowedProgramIds.contains(c.getLong(PROGRAM_ID_INDEX))) { 296 return false; 297 } 298 Program program = Program.fromCursor(c); 299 for (SeriesRecording seriesRecording : mSeriesRecordings) { 300 boolean programMatches; 301 if (mIgnoreChannelOption) { 302 programMatches = seriesRecording.matchProgram(program, 303 SeriesRecording.OPTION_CHANNEL_ALL); 304 } else { 305 programMatches = seriesRecording.matchProgram(program); 306 } 307 if (programMatches) { 308 return mLoadScheduledEpisode 309 || !isEpisodeScheduled(mScheduledEpisodes, new ScheduledEpisode( 310 seriesRecording.getId(), program.getSeasonNumber(), 311 program.getEpisodeNumber())); 312 } 313 } 314 return false; 315 } 316 } 317 318 private class SeriesRecordingCursorFilterForNonSystem extends SeriesRecordingCursorFilter { 319 SeriesRecordingCursorFilterForNonSystem(List<SeriesRecording> seriesRecordings) { 320 super(seriesRecordings); 321 } 322 323 @Override 324 public boolean filter(Cursor c) { 325 return (mLoadCurrentProgram || c.getLong(START_TIME_INDEX) > System.currentTimeMillis()) 326 && c.getInt(RECORDING_PROHIBITED_INDEX) != 0 && super.filter(c); 327 } 328 } 329 330 private static class SqlParams { 331 public Uri uri; 332 public String selection; 333 public String[] selectionArgs; 334 public CursorFilter filter; 335 } 336 337 /** 338 * A plain java object which includes the season/episode number for the series recording. 339 */ 340 public static class ScheduledEpisode { 341 public final long seriesRecordingId; 342 public final String seasonNumber; 343 public final String episodeNumber; 344 345 /** 346 * Create a new Builder with the values set from an existing {@link ScheduledRecording}. 347 */ 348 ScheduledEpisode(ScheduledRecording r) { 349 this(r.getSeriesRecordingId(), r.getSeasonNumber(), r.getEpisodeNumber()); 350 } 351 352 public ScheduledEpisode(long seriesRecordingId, String seasonNumber, String episodeNumber) { 353 this.seriesRecordingId = seriesRecordingId; 354 this.seasonNumber = seasonNumber; 355 this.episodeNumber = episodeNumber; 356 } 357 358 @Override 359 public boolean equals(Object o) { 360 if (this == o) return true; 361 if (!(o instanceof ScheduledEpisode)) return false; 362 ScheduledEpisode that = (ScheduledEpisode) o; 363 return seriesRecordingId == that.seriesRecordingId 364 && Objects.equals(seasonNumber, that.seasonNumber) 365 && Objects.equals(episodeNumber, that.episodeNumber); 366 } 367 368 @Override 369 public int hashCode() { 370 return Objects.hash(seriesRecordingId, seasonNumber, episodeNumber); 371 } 372 373 @Override 374 public String toString() { 375 return "ScheduledEpisode{" + 376 "seriesRecordingId=" + seriesRecordingId + 377 ", seasonNumber='" + seasonNumber + 378 ", episodeNumber=" + episodeNumber + 379 '}'; 380 } 381 } 382 } 383