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