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.ui.list; 18 19 import android.annotation.TargetApi; 20 import android.content.Context; 21 import android.os.Build.VERSION_CODES; 22 import android.os.Handler; 23 import android.os.Looper; 24 import android.os.Message; 25 import androidx.leanback.widget.ArrayObjectAdapter; 26 import androidx.leanback.widget.ClassPresenterSelector; 27 import android.text.format.DateUtils; 28 import android.util.ArraySet; 29 import android.util.Log; 30 31 import com.android.tv.R; 32 import com.android.tv.TvSingletons; 33 import com.android.tv.common.SoftPreconditions; 34 import com.android.tv.dvr.DvrManager; 35 import com.android.tv.dvr.data.ScheduledRecording; 36 import com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow; 37 import com.android.tv.util.Utils; 38 39 import java.util.ArrayList; 40 import java.util.Collections; 41 import java.util.List; 42 import java.util.Set; 43 import java.util.concurrent.TimeUnit; 44 45 /** An adapter for {@link ScheduleRow}. */ 46 @TargetApi(VERSION_CODES.N) 47 @SuppressWarnings("AndroidApiChecker") // TODO(b/32513850) remove when error prone is updated 48 class ScheduleRowAdapter extends ArrayObjectAdapter { 49 private static final String TAG = "ScheduleRowAdapter"; 50 private static final boolean DEBUG = false; 51 52 private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); 53 54 private static final int MSG_UPDATE_ROW = 1; 55 56 private Context mContext; 57 private final List<String> mTitles = new ArrayList<>(); 58 private final Set<ScheduleRow> mPendingUpdate = new ArraySet<>(); 59 60 private final Handler mHandler = 61 new Handler(Looper.getMainLooper()) { 62 @Override 63 public void handleMessage(Message msg) { 64 if (msg.what == MSG_UPDATE_ROW) { 65 long currentTimeMs = System.currentTimeMillis(); 66 handleUpdateRow(currentTimeMs); 67 sendNextUpdateMessage(currentTimeMs); 68 } 69 } 70 }; 71 ScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector)72 public ScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector) { 73 super(classPresenterSelector); 74 mContext = context; 75 mTitles.add(mContext.getString(R.string.dvr_date_today)); 76 mTitles.add(mContext.getString(R.string.dvr_date_tomorrow)); 77 } 78 79 /** Returns context. */ getContext()80 protected Context getContext() { 81 return mContext; 82 } 83 84 /** Starts schedule row adapter. */ start()85 public void start() { 86 clear(); 87 List<ScheduledRecording> recordingList = 88 TvSingletons.getSingletons(mContext) 89 .getDvrDataManager() 90 .getNonStartedScheduledRecordings(); 91 recordingList.addAll( 92 TvSingletons.getSingletons(mContext).getDvrDataManager().getStartedRecordings()); 93 Collections.sort( 94 recordingList, ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR); 95 long deadLine = Utils.getLastMillisecondOfDay(System.currentTimeMillis()); 96 for (int i = 0; i < recordingList.size(); ) { 97 ArrayList<ScheduledRecording> section = new ArrayList<>(); 98 while (i < recordingList.size() && recordingList.get(i).getStartTimeMs() < deadLine) { 99 section.add(recordingList.get(i++)); 100 } 101 if (!section.isEmpty()) { 102 SchedulesHeaderRow headerRow = 103 new DateHeaderRow( 104 calculateHeaderDate(deadLine), 105 mContext.getResources() 106 .getQuantityString( 107 R.plurals.dvr_schedules_section_subtitle, 108 section.size(), 109 section.size()), 110 section.size(), 111 deadLine); 112 add(headerRow); 113 for (ScheduledRecording recording : section) { 114 add(new ScheduleRow(recording, headerRow)); 115 } 116 } 117 deadLine += ONE_DAY_MS; 118 } 119 sendNextUpdateMessage(System.currentTimeMillis()); 120 } 121 calculateHeaderDate(long deadLine)122 private String calculateHeaderDate(long deadLine) { 123 int titleIndex = 124 (int) 125 ((deadLine - Utils.getLastMillisecondOfDay(System.currentTimeMillis())) 126 / ONE_DAY_MS); 127 String headerDate; 128 if (titleIndex < mTitles.size()) { 129 headerDate = mTitles.get(titleIndex); 130 } else { 131 headerDate = 132 DateUtils.formatDateTime( 133 getContext(), 134 deadLine, 135 DateUtils.FORMAT_SHOW_WEEKDAY 136 | DateUtils.FORMAT_SHOW_DATE 137 | DateUtils.FORMAT_ABBREV_MONTH); 138 } 139 return headerDate; 140 } 141 142 /** Stops schedules row adapter. */ stop()143 public void stop() { 144 mHandler.removeCallbacksAndMessages(null); 145 DvrManager dvrManager = TvSingletons.getSingletons(getContext()).getDvrManager(); 146 for (int i = 0; i < size(); i++) { 147 if (get(i) instanceof ScheduleRow) { 148 ScheduleRow row = (ScheduleRow) get(i); 149 if (row.isScheduleCanceled()) { 150 dvrManager.removeScheduledRecording(row.getSchedule()); 151 } 152 } 153 } 154 } 155 156 /** Gets which {@link ScheduleRow} the {@link ScheduledRecording} belongs to. */ findRowByScheduledRecording(ScheduledRecording recording)157 public ScheduleRow findRowByScheduledRecording(ScheduledRecording recording) { 158 if (recording == null) { 159 return null; 160 } 161 for (int i = 0; i < size(); i++) { 162 Object item = get(i); 163 if (item instanceof ScheduleRow && ((ScheduleRow) item).getSchedule() != null) { 164 if (((ScheduleRow) item).getSchedule().getId() == recording.getId()) { 165 return (ScheduleRow) item; 166 } 167 } 168 } 169 return null; 170 } 171 findRowWithStartRequest(ScheduledRecording schedule)172 private ScheduleRow findRowWithStartRequest(ScheduledRecording schedule) { 173 for (int i = 0; i < size(); i++) { 174 Object item = get(i); 175 if (!(item instanceof ScheduleRow)) { 176 continue; 177 } 178 ScheduleRow row = (ScheduleRow) item; 179 if (row.getSchedule() != null 180 && row.isStartRecordingRequested() 181 && row.matchSchedule(schedule)) { 182 return row; 183 } 184 } 185 return null; 186 } 187 addScheduleRow(ScheduledRecording recording)188 private void addScheduleRow(ScheduledRecording recording) { 189 // This method must not be called from inherited class. 190 SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class)); 191 if (recording != null) { 192 int pre = -1; 193 int index = 0; 194 for (; index < size(); index++) { 195 if (get(index) instanceof ScheduleRow) { 196 ScheduleRow scheduleRow = (ScheduleRow) get(index); 197 if (ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR.compare( 198 scheduleRow.getSchedule(), recording) 199 > 0) { 200 break; 201 } 202 pre = index; 203 } 204 } 205 long deadLine = Utils.getLastMillisecondOfDay(recording.getStartTimeMs()); 206 if (pre >= 0 && getHeaderRow(pre).getDeadLineMs() == deadLine) { 207 SchedulesHeaderRow headerRow = ((ScheduleRow) get(pre)).getHeaderRow(); 208 headerRow.setItemCount(headerRow.getItemCount() + 1); 209 ScheduleRow addedRow = new ScheduleRow(recording, headerRow); 210 add(++pre, addedRow); 211 updateHeaderDescription(headerRow); 212 } else if (index < size() && getHeaderRow(index).getDeadLineMs() == deadLine) { 213 SchedulesHeaderRow headerRow = ((ScheduleRow) get(index)).getHeaderRow(); 214 headerRow.setItemCount(headerRow.getItemCount() + 1); 215 ScheduleRow addedRow = new ScheduleRow(recording, headerRow); 216 add(index, addedRow); 217 updateHeaderDescription(headerRow); 218 } else { 219 SchedulesHeaderRow headerRow = 220 new DateHeaderRow( 221 calculateHeaderDate(deadLine), 222 mContext.getResources() 223 .getQuantityString( 224 R.plurals.dvr_schedules_section_subtitle, 1, 1), 225 1, 226 deadLine); 227 add(++pre, headerRow); 228 ScheduleRow addedRow = new ScheduleRow(recording, headerRow); 229 add(pre, addedRow); 230 } 231 } 232 } 233 getHeaderRow(int index)234 private DateHeaderRow getHeaderRow(int index) { 235 return ((DateHeaderRow) ((ScheduleRow) get(index)).getHeaderRow()); 236 } 237 removeScheduleRow(ScheduleRow scheduleRow)238 private void removeScheduleRow(ScheduleRow scheduleRow) { 239 // This method must not be called from inherited class. 240 SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class)); 241 if (scheduleRow != null) { 242 scheduleRow.setSchedule(null); 243 SchedulesHeaderRow headerRow = scheduleRow.getHeaderRow(); 244 remove(scheduleRow); 245 // Changes the count information of header which the removed row belongs to. 246 if (headerRow != null) { 247 int currentCount = headerRow.getItemCount(); 248 headerRow.setItemCount(--currentCount); 249 if (headerRow.getItemCount() == 0) { 250 remove(headerRow); 251 } else { 252 replace(indexOf(headerRow), headerRow); 253 updateHeaderDescription(headerRow); 254 } 255 } 256 } 257 } 258 updateHeaderDescription(SchedulesHeaderRow headerRow)259 private void updateHeaderDescription(SchedulesHeaderRow headerRow) { 260 headerRow.setDescription( 261 mContext.getResources() 262 .getQuantityString( 263 R.plurals.dvr_schedules_section_subtitle, 264 headerRow.getItemCount(), 265 headerRow.getItemCount())); 266 } 267 268 /** Called when a schedule recording is added to dvr date manager. */ onScheduledRecordingAdded(ScheduledRecording schedule)269 public void onScheduledRecordingAdded(ScheduledRecording schedule) { 270 if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + schedule); 271 ScheduleRow row = findRowWithStartRequest(schedule); 272 // If the start recording is requested, onScheduledRecordingAdded is called with NOT_STARTED 273 // state. And then onScheduleRecordingUpdated will be called with IN_PROGRESS. 274 // It happens in a short time and causes blinking. To avoid this intermediate state change, 275 // update the row in onScheduleRecordingUpdated when the state changes to IN_PROGRESS 276 // instead of in this method. 277 if (row == null) { 278 addScheduleRow(schedule); 279 sendNextUpdateMessage(System.currentTimeMillis()); 280 } 281 } 282 283 /** Called when a schedule recording is removed from dvr date manager. */ onScheduledRecordingRemoved(ScheduledRecording schedule)284 public void onScheduledRecordingRemoved(ScheduledRecording schedule) { 285 if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + schedule); 286 ScheduleRow row = findRowByScheduledRecording(schedule); 287 if (row != null) { 288 removeScheduleRow(row); 289 notifyArrayItemRangeChanged(indexOf(row), 1); 290 sendNextUpdateMessage(System.currentTimeMillis()); 291 } 292 } 293 294 /** Called when a schedule recording is updated in dvr date manager. */ onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange)295 public void onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange) { 296 if (DEBUG) Log.d(TAG, "onScheduledRecordingUpdated: " + schedule); 297 ScheduleRow row = findRowByScheduledRecording(schedule); 298 if (row != null) { 299 if (conflictChange && isStartOrStopRequested()) { 300 // Delay the conflict update until it gets the response of the start/stop request. 301 // The purpose is to avoid the intermediate conflict change. 302 addPendingUpdate(row); 303 return; 304 } 305 if (row.isStopRecordingRequested()) { 306 // Wait until the recording is finished 307 if (schedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED 308 || schedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED 309 || schedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED) { 310 row.setStopRecordingRequested(false); 311 if (!isStartOrStopRequested()) { 312 executePendingUpdate(); 313 } 314 row.setSchedule(schedule); 315 } 316 } else { 317 row.setSchedule(schedule); 318 if (!willBeKept(schedule)) { 319 removeScheduleRow(row); 320 } 321 } 322 notifyArrayItemRangeChanged(indexOf(row), 1); 323 sendNextUpdateMessage(System.currentTimeMillis()); 324 } else { 325 row = findRowWithStartRequest(schedule); 326 // When the start recording was requested, we give the highest priority. So it is 327 // guaranteed that the state will be changed from NOT_STARTED to the other state. 328 // Update the row with the next state not to show the intermediate state which causes 329 // blinking. 330 if (row != null 331 && schedule.getState() != ScheduledRecording.STATE_RECORDING_NOT_STARTED) { 332 // This can be called multiple times, so do not call 333 // ScheduleRow.setStartRecordingRequested(false) here. 334 row.setStartRecordingRequested(false); 335 if (!isStartOrStopRequested()) { 336 executePendingUpdate(); 337 } 338 row.setSchedule(schedule); 339 notifyArrayItemRangeChanged(indexOf(row), 1); 340 sendNextUpdateMessage(System.currentTimeMillis()); 341 } 342 } 343 } 344 345 /** Checks if there is a row which requested start/stop recording. */ isStartOrStopRequested()346 protected boolean isStartOrStopRequested() { 347 for (int i = 0; i < size(); i++) { 348 Object item = get(i); 349 if (item instanceof ScheduleRow) { 350 ScheduleRow row = (ScheduleRow) item; 351 if (row.isStartRecordingRequested() || row.isStopRecordingRequested()) { 352 return true; 353 } 354 } 355 } 356 return false; 357 } 358 359 /** Delays update of the row. */ addPendingUpdate(ScheduleRow row)360 protected void addPendingUpdate(ScheduleRow row) { 361 mPendingUpdate.add(row); 362 } 363 364 /** Executes the pending updates. */ executePendingUpdate()365 protected void executePendingUpdate() { 366 for (ScheduleRow row : mPendingUpdate) { 367 int index = indexOf(row); 368 if (index != -1) { 369 notifyArrayItemRangeChanged(index, 1); 370 } 371 } 372 mPendingUpdate.clear(); 373 } 374 375 /** To check whether the recording should be kept or not. */ willBeKept(ScheduledRecording schedule)376 protected boolean willBeKept(ScheduledRecording schedule) { 377 // CANCELED state means that the schedule was removed temporarily, which should be shown 378 // in the list so that the user can reschedule it. 379 return schedule.getEndTimeMs() > System.currentTimeMillis() 380 && (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS 381 || schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED 382 || schedule.getState() == ScheduledRecording.STATE_RECORDING_CANCELED); 383 } 384 385 /** Handle the message to update/remove rows. */ handleUpdateRow(long currentTimeMs)386 protected void handleUpdateRow(long currentTimeMs) { 387 for (int i = 0; i < size(); i++) { 388 Object item = get(i); 389 if (item instanceof ScheduleRow) { 390 ScheduleRow row = (ScheduleRow) item; 391 if (row.getEndTimeMs() <= currentTimeMs) { 392 removeScheduleRow(row); 393 } 394 } 395 } 396 } 397 398 /** Returns the next update time. Return {@link Long#MAX_VALUE} if no timer is necessary. */ getNextTimerMs(long currentTimeMs)399 protected long getNextTimerMs(long currentTimeMs) { 400 long earliest = Long.MAX_VALUE; 401 for (int i = 0; i < size(); i++) { 402 Object item = get(i); 403 if (item instanceof ScheduleRow) { 404 // If the schedule was finished earlier than the end time, it should be removed 405 // when it reaches the end time in this class. 406 ScheduleRow row = (ScheduleRow) item; 407 if (earliest > row.getEndTimeMs()) { 408 earliest = row.getEndTimeMs(); 409 } 410 } 411 } 412 return earliest; 413 } 414 415 /** Send update message at the time returned by {@link #getNextTimerMs}. */ sendNextUpdateMessage(long currentTimeMs)416 protected final void sendNextUpdateMessage(long currentTimeMs) { 417 mHandler.removeMessages(MSG_UPDATE_ROW); 418 long nextTime = getNextTimerMs(currentTimeMs); 419 if (nextTime != Long.MAX_VALUE) { 420 mHandler.sendEmptyMessageDelayed(MSG_UPDATE_ROW, nextTime - System.currentTimeMillis()); 421 } 422 } 423 } 424