diff options
author | Nick Chalko <nchalko@google.com> | 2016-08-31 16:00:31 -0700 |
---|---|---|
committer | Nick Chalko <nchalko@google.com> | 2016-09-07 05:38:33 -0700 |
commit | 65fda1eaa94968bb55d5ded10dcb0b3f37fb05f2 (patch) | |
tree | ffc8e4c5a71c130d3782bf03e674f9d77ca77f72 /src/com/android/tv/dvr | |
parent | ad819718f80e796cf039f96537b5c8cd127c042b (diff) | |
download | TV-65fda1eaa94968bb55d5ded10dcb0b3f37fb05f2.tar.gz |
Sync to ub-tv-dev at http://ag/1415258
Bug: 30970843
Change-Id: I0aa43094d103de28956a3d9b56a594ea46a20543
Diffstat (limited to 'src/com/android/tv/dvr')
94 files changed, 16530 insertions, 1963 deletions
diff --git a/src/com/android/tv/dvr/BaseDvrDataManager.java b/src/com/android/tv/dvr/BaseDvrDataManager.java index 0fb469be..6af77940 100644 --- a/src/com/android/tv/dvr/BaseDvrDataManager.java +++ b/src/com/android/tv/dvr/BaseDvrDataManager.java @@ -20,17 +20,23 @@ import android.annotation.TargetApi; import android.content.Context; import android.os.Build; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; import android.util.ArraySet; import android.util.Log; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.dvr.ScheduledRecording.RecordingState; import com.android.tv.util.Clock; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; /** * Base implementation of @{link DataManagerInternal}. @@ -42,8 +48,14 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { private final static boolean DEBUG = false; protected final Clock mClock; + private final Set<OnDvrScheduleLoadFinishedListener> mOnDvrScheduleLoadFinishedListeners = + new CopyOnWriteArraySet<>(); + private final Set<OnRecordedProgramLoadFinishedListener> + mOnRecordedProgramLoadFinishedListeners = new CopyOnWriteArraySet<>(); private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>(); + private final Set<SeriesRecordingListener> mSeriesRecordingListeners = new ArraySet<>(); private final Set<RecordedProgramListener> mRecordedProgramListeners = new ArraySet<>(); + private final HashMap<Long, ScheduledRecording> mDeletedScheduleMap = new HashMap<>(); BaseDvrDataManager(Context context, Clock clock) { SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG); @@ -51,6 +63,28 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } @Override + public void addDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener) { + mOnDvrScheduleLoadFinishedListeners.add(listener); + } + + @Override + public void removeDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener) { + mOnDvrScheduleLoadFinishedListeners.remove(listener); + } + + @Override + public void addRecordedProgramLoadFinishedListener( + OnRecordedProgramLoadFinishedListener listener) { + mOnRecordedProgramLoadFinishedListeners.add(listener); + } + + @Override + public void removeRecordedProgramLoadFinishedListener( + OnRecordedProgramLoadFinishedListener listener) { + mOnRecordedProgramLoadFinishedListeners.remove(listener); + } + + @Override public final void addScheduledRecordingListener(ScheduledRecordingListener listener) { mScheduledRecordingListeners.add(listener); } @@ -61,6 +95,16 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } @Override + public final void addSeriesRecordingListener(SeriesRecordingListener listener) { + mSeriesRecordingListeners.add(listener); + } + + @Override + public final void removeSeriesRecordingListener(SeriesRecordingListener listener) { + mSeriesRecordingListeners.remove(listener); + } + + @Override public final void addRecordedProgramListener(RecordedProgramListener listener) { mRecordedProgramListeners.add(listener); } @@ -71,6 +115,27 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } /** + * Calls {@link OnDvrScheduleLoadFinishedListener#onDvrScheduleLoadFinished} for each listener. + */ + protected final void notifyDvrScheduleLoadFinished() { + for (OnDvrScheduleLoadFinishedListener l : mOnDvrScheduleLoadFinishedListeners) { + if (DEBUG) Log.d(TAG, "notify DVR schedule load finished"); + l.onDvrScheduleLoadFinished(); + } + } + + /** + * Calls {@link OnRecordedProgramLoadFinishedListener#onRecordedProgramLoadFinished()} + * for each listener. + */ + protected final void notifyRecordedProgramLoadFinished() { + for (OnRecordedProgramLoadFinishedListener l : mOnRecordedProgramLoadFinishedListeners) { + if (DEBUG) Log.d(TAG, "notify recorded programs load finished"); + l.onRecordedProgramLoadFinished(); + } + } + + /** * Calls {@link RecordedProgramListener#onRecordedProgramAdded(RecordedProgram)} * for each listener. */ @@ -104,10 +169,44 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } /** - * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded(ScheduledRecording)} + * Calls {@link SeriesRecordingListener#onSeriesRecordingAdded} + * for each listener. + */ + protected final void notifySeriesRecordingAdded(SeriesRecording... seriesRecordings) { + for (SeriesRecordingListener l : mSeriesRecordingListeners) { + if (DEBUG) Log.d(TAG, "notify " + l + "added " + seriesRecordings); + l.onSeriesRecordingAdded(seriesRecordings); + } + } + + /** + * Calls {@link SeriesRecordingListener#onSeriesRecordingRemoved} + * for each listener. + */ + protected final void notifySeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + for (SeriesRecordingListener l : mSeriesRecordingListeners) { + if (DEBUG) Log.d(TAG, "notify " + l + "removed " + seriesRecordings); + l.onSeriesRecordingRemoved(seriesRecordings); + } + } + + /** + * Calls + * {@link SeriesRecordingListener#onSeriesRecordingChanged} + * for each listener. + */ + protected final void notifySeriesRecordingChanged(SeriesRecording... seriesRecordings) { + for (SeriesRecordingListener l : mSeriesRecordingListeners) { + if (DEBUG) Log.d(TAG, "notify " + l + "changed " + seriesRecordings); + l.onSeriesRecordingChanged(seriesRecordings); + } + } + + /** + * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} * for each listener. */ - protected final void notifyScheduledRecordingAdded(ScheduledRecording scheduledRecording) { + protected final void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecording) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { if (DEBUG) Log.d(TAG, "notify " + l + "added " + scheduledRecording); l.onScheduledRecordingAdded(scheduledRecording); @@ -115,25 +214,23 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } /** - * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved(ScheduledRecording)} + * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} * for each listener. */ - protected final void notifyScheduledRecordingRemoved(ScheduledRecording scheduledRecording) { + protected final void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecording) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { - if (DEBUG) { - Log.d(TAG, "notify " + l + "removed " + scheduledRecording); - } + if (DEBUG) Log.d(TAG, "notify " + l + "removed " + scheduledRecording); l.onScheduledRecordingRemoved(scheduledRecording); } } /** * Calls - * {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged(ScheduledRecording)} + * {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} * for each listener. */ protected final void notifyScheduledRecordingStatusChanged( - ScheduledRecording scheduledRecording) { + ScheduledRecording... scheduledRecording) { for (ScheduledRecordingListener l : mScheduledRecordingListeners) { if (DEBUG) Log.d(TAG, "notify " + l + "changed " + scheduledRecording); l.onScheduledRecordingStatusChanged(scheduledRecording); @@ -155,16 +252,74 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } @Override + public List<ScheduledRecording> getAvailableScheduledRecordings() { + return filterEndTimeIsPast(getRecordingsWithState( + ScheduledRecording.STATE_RECORDING_IN_PROGRESS, + ScheduledRecording.STATE_RECORDING_NOT_STARTED)); + } + + @Override + public List<ScheduledRecording> getAvailableAndCanceledScheduledRecordings() { + return filterEndTimeIsPast(getRecordingsWithState( + ScheduledRecording.STATE_RECORDING_IN_PROGRESS, + ScheduledRecording.STATE_RECORDING_NOT_STARTED, + ScheduledRecording.STATE_RECORDING_CANCELED)); + } + + @Override public List<ScheduledRecording> getStartedRecordings() { - return filterEndTimeIsPast( - getRecordingsWithState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS)); + return filterEndTimeIsPast(getRecordingsWithState( + ScheduledRecording.STATE_RECORDING_IN_PROGRESS)); } @Override public List<ScheduledRecording> getNonStartedScheduledRecordings() { - return filterEndTimeIsPast( - getRecordingsWithState(ScheduledRecording.STATE_RECORDING_NOT_STARTED)); + Set<Integer> states = new HashSet<>(); + states.add(ScheduledRecording.STATE_RECORDING_NOT_STARTED); + return filterEndTimeIsPast(getRecordingsWithState( + ScheduledRecording.STATE_RECORDING_NOT_STARTED)); + } + + @Override + public void changeState(ScheduledRecording scheduledRecording, @RecordingState int newState) { + if (scheduledRecording.getState() != newState) { + updateScheduledRecording(ScheduledRecording.buildFrom(scheduledRecording) + .setState(newState).build()); + } + } + + @Override + public Collection<ScheduledRecording> getDeletedSchedules() { + return mDeletedScheduleMap.values(); } - protected abstract List<ScheduledRecording> getRecordingsWithState(int state); + @NonNull + @Override + public Collection<Long> getDisallowedProgramIds() { + return mDeletedScheduleMap.keySet(); + } + + /** + * Returns the map which contains the deleted schedules which are mapped from the program ID. + */ + protected Map<Long, ScheduledRecording> getDeletedScheduleMap() { + return mDeletedScheduleMap; + } + + /** + * Returns the schedules whose state is contained by states. + */ + protected abstract List<ScheduledRecording> getRecordingsWithState(int... states); + + @Override + public List<RecordedProgram> getRecordedPrograms(long seriesRecordingId) { + SeriesRecording seriesRecording = getSeriesRecording(seriesRecordingId); + List<RecordedProgram> result = new ArrayList<>(); + for (RecordedProgram r : getRecordedPrograms()) { + if (seriesRecording.getSeriesId().equals(r.getSeriesId())) { + result.add(r); + } + } + return result; + } } diff --git a/src/com/android/tv/dvr/ConflictChecker.java b/src/com/android/tv/dvr/ConflictChecker.java new file mode 100644 index 00000000..201e379e --- /dev/null +++ b/src/com/android/tv/dvr/ConflictChecker.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.Build; +import android.os.Message; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.ArraySet; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.InputSessionManager; +import com.android.tv.InputSessionManager.OnTvViewChannelChangeListener; +import com.android.tv.MainActivity; +import com.android.tv.TvApplication; +import com.android.tv.common.WeakHandler; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Checking the runtime conflict of DVR recording. + * <p> + * This class runs only while the {@link MainActivity} is resumed and holds the upcoming conflicts. + */ +@TargetApi(Build.VERSION_CODES.N) +@MainThread +public class ConflictChecker { + private static final String TAG = "ConflictChecker"; + private static final boolean DEBUG = false; + + private static final int MSG_CHECK_CONFLICT = 1; + + private static final long CHECK_RETRY_PERIOD_MS = TimeUnit.SECONDS.toMillis(30); + + /** + * To show watch conflict dialog, the start time of the earliest conflicting schedule should be + * less than or equal to this time. + */ + private static final long MAX_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.MINUTES.toMillis(5); + /** + * To show watch conflict dialog, the start time of the earliest conflicting schedule should be + * greater than or equal to this time. + */ + private static final long MIN_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.SECONDS.toMillis(30); + + private final MainActivity mMainActivity; + private final ChannelDataManager mChannelDataManager; + private final DvrScheduleManager mScheduleManager; + private final InputSessionManager mSessionManager; + private final ConflictCheckerHandler mHandler = new ConflictCheckerHandler(this); + + private final List<ScheduledRecording> mUpcomingConflicts = new ArrayList<>(); + private final Set<OnUpcomingConflictChangeListener> mOnUpcomingConflictChangeListeners = + new ArraySet<>(); + private final Map<Long, List<ScheduledRecording>> mCheckedConflictsMap = new HashMap<>(); + + private final ScheduledRecordingListener mScheduledRecordingListener = + new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + scheduledRecordings); + mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + scheduledRecordings); + mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingStatusChanged: " + scheduledRecordings); + mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); + } + }; + + private final OnTvViewChannelChangeListener mOnTvViewChannelChangeListener = + new OnTvViewChannelChangeListener() { + @Override + public void onTvViewChannelChange(@Nullable Uri channelUri) { + mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); + } + }; + + private boolean mStarted; + + public ConflictChecker(MainActivity mainActivity) { + mMainActivity = mainActivity; + ApplicationSingletons appSingletons = TvApplication.getSingletons(mainActivity); + mChannelDataManager = appSingletons.getChannelDataManager(); + mScheduleManager = appSingletons.getDvrScheduleManager(); + mSessionManager = appSingletons.getInputSessionManager(); + } + + /** + * Starts checking the conflict. + */ + public void start() { + if (mStarted) { + return; + } + mStarted = true; + mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT); + mScheduleManager.addScheduledRecordingListener(mScheduledRecordingListener); + mSessionManager.addOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener); + } + + /** + * Stops checking the conflict. + */ + public void stop() { + if (!mStarted) { + return; + } + mStarted = false; + mSessionManager.removeOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener); + mScheduleManager.removeScheduledRecordingListener(mScheduledRecordingListener); + mHandler.removeCallbacksAndMessages(null); + } + + /** + * Returns the upcoming conflicts. + */ + public List<ScheduledRecording> getUpcomingConflicts() { + return new ArrayList<>(mUpcomingConflicts); + } + + /** + * Adds a {@link OnUpcomingConflictChangeListener}. + */ + public void addOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) { + mOnUpcomingConflictChangeListeners.add(listener); + } + + /** + * Removes the {@link OnUpcomingConflictChangeListener}. + */ + public void removeOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) { + mOnUpcomingConflictChangeListeners.remove(listener); + } + + private void notifyUpcomingConflictChanged() { + for (OnUpcomingConflictChangeListener l : mOnUpcomingConflictChangeListeners) { + l.onUpcomingConflictChange(); + } + } + + /** + * Remembers the user's decision to record while watching the channel. + */ + public void setCheckedConflictsForChannel(long mChannelId, List<ScheduledRecording> conflicts) { + mCheckedConflictsMap.put(mChannelId, new ArrayList<>(conflicts)); + } + + void onCheckConflict() { + // Checks the conflicting schedules and setup the next re-check time. + // If there are upcoming conflicts soon, it opens the conflict dialog. + if (DEBUG) Log.d(TAG, "Handling MSG_CHECK_CONFLICT"); + mHandler.removeMessages(MSG_CHECK_CONFLICT); + mUpcomingConflicts.clear(); + if (!mScheduleManager.isInitialized() + || !mChannelDataManager.isDbLoadFinished()) { + mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT, CHECK_RETRY_PERIOD_MS); + notifyUpcomingConflictChanged(); + return; + } + if (mSessionManager.getCurrentTvViewChannelUri() == null) { + // As MainActivity is not using a tuner, no need to check the conflict. + notifyUpcomingConflictChanged(); + return; + } + Uri channelUri = mSessionManager.getCurrentTvViewChannelUri(); + if (TvContract.isChannelUriForPassthroughInput(channelUri)) { + notifyUpcomingConflictChanged(); + return; + } + long channelId = ContentUris.parseId(channelUri); + Channel channel = mChannelDataManager.getChannel(channelId); + // The conflicts caused by watching the channel. + List<ScheduledRecording> conflicts = mScheduleManager + .getConflictingSchedulesForWatching(channel.getId()); + long earliestToCheck = Long.MAX_VALUE; + long currentTimeMs = System.currentTimeMillis(); + for (ScheduledRecording schedule : conflicts) { + long startTimeMs = schedule.getStartTimeMs(); + if (startTimeMs < currentTimeMs + MIN_WATCH_CONFLICT_CHECK_TIME_MS) { + // The start time of the upcoming conflict remains less than the minimum + // check time. + continue; + } + if (startTimeMs > currentTimeMs + MAX_WATCH_CONFLICT_CHECK_TIME_MS) { + // The start time of the upcoming conflict remains greater than the + // maximum check time. Setup the next re-check time. + long nextCheckTimeMs = startTimeMs - MAX_WATCH_CONFLICT_CHECK_TIME_MS; + if (earliestToCheck > nextCheckTimeMs) { + earliestToCheck = nextCheckTimeMs; + } + } else { + // Found upcoming conflicts which will start soon. + mUpcomingConflicts.add(schedule); + // The schedule will be removed from the "upcoming conflict" when the + // recording is almost started. + long nextCheckTimeMs = startTimeMs - MIN_WATCH_CONFLICT_CHECK_TIME_MS; + if (earliestToCheck > nextCheckTimeMs) { + earliestToCheck = nextCheckTimeMs; + } + } + } + if (earliestToCheck != Long.MAX_VALUE) { + mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT, + earliestToCheck - currentTimeMs); + } + if (DEBUG) Log.d(TAG, "upcoming conflicts: " + mUpcomingConflicts); + notifyUpcomingConflictChanged(); + if (!mUpcomingConflicts.isEmpty() + && !DvrUiHelper.isChannelWatchConflictDialogShown(mMainActivity)) { + // Don't show the conflict dialog if the user already knows. + List<ScheduledRecording> checkedConflicts = mCheckedConflictsMap.get( + channel.getId()); + if (checkedConflicts == null + || !checkedConflicts.containsAll(mUpcomingConflicts)) { + DvrUiHelper.showChannelWatchConflictDialog(mMainActivity, channel); + } + } + } + + private static class ConflictCheckerHandler extends WeakHandler<ConflictChecker> { + ConflictCheckerHandler(ConflictChecker conflictChecker) { + super(conflictChecker); + } + + @Override + protected void handleMessage(Message msg, @NonNull ConflictChecker conflictChecker) { + switch (msg.what) { + case MSG_CHECK_CONFLICT: + conflictChecker.onCheckConflict(); + break; + } + } + } + + /** + * A listener for the change of upcoming conflicts. + */ + public interface OnUpcomingConflictChangeListener { + void onUpcomingConflictChange(); + } +} diff --git a/src/com/android/tv/dvr/DvrDataManager.java b/src/com/android/tv/dvr/DvrDataManager.java index c96104e5..126f3e74 100644 --- a/src/com/android/tv/dvr/DvrDataManager.java +++ b/src/com/android/tv/dvr/DvrDataManager.java @@ -17,11 +17,13 @@ package com.android.tv.dvr; import android.support.annotation.MainThread; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Range; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.dvr.ScheduledRecording.RecordingState; +import java.util.Collection; import java.util.List; /** @@ -34,16 +36,44 @@ public interface DvrDataManager { boolean isInitialized(); /** + * Returns {@code true} if the schedules were loaded, otherwise {@code false}. + */ + boolean isDvrScheduleLoadFinished(); + + /** + * Returns {@code true} if the recorded programs were loaded, otherwise {@code false}. + */ + boolean isRecordedProgramLoadFinished(); + + /** * Returns past recordings. */ List<RecordedProgram> getRecordedPrograms(); /** + * Returns past recorded programs in the given series. + */ + List<RecordedProgram> getRecordedPrograms(long seriesRecordingId); + + /** * Returns all {@link ScheduledRecording} regardless of state. + * <p> + * The result doesn't contain the deleted schedules. */ List<ScheduledRecording> getAllScheduledRecordings(); /** + * Returns all available {@link ScheduledRecording}, it contains started and non started + * recordings. + */ + List<ScheduledRecording> getAvailableScheduledRecordings(); + + /** + * Return all available and canceled {@link ScheduledRecording}. + */ + List<ScheduledRecording> getAvailableAndCanceledScheduledRecordings(); + + /** * Returns started recordings that expired. */ List<ScheduledRecording> getStartedRecordings(); @@ -54,9 +84,14 @@ public interface DvrDataManager { List<ScheduledRecording> getNonStartedScheduledRecordings(); /** - * Returns season recordings. + * Returns series recordings. */ - List<SeasonRecording> getSeasonRecordings(); + List<SeriesRecording> getSeriesRecordings(); + + /** + * Returns series recordings from the given input. + */ + List<SeriesRecording> getSeriesRecordings(String inputId); /** * Returns the next start time after {@code time} or {@link #NEXT_START_TIME_NOT_FOUND} @@ -67,15 +102,47 @@ public interface DvrDataManager { long getNextScheduledStartTimeAfter(long time); /** - * Returns a list of all Recordings with a overlap with the given time period inclusive. + * Returns a list of the schedules with a overlap with the given time period inclusive and with + * the given state. * * <p> A recording overlaps with a period when * {@code recording.getStartTime() <= period.getUpper() && * recording.getEndTime() >= period.getLower()}. * * @param period a time period in milliseconds. + * @param state the state of the schedule. */ - List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period); + List<ScheduledRecording> getScheduledRecordings(Range<Long> period, @RecordingState int state); + + /** + * Returns a list of the schedules in the given series. + */ + List<ScheduledRecording> getScheduledRecordings(long seriesRecordingId); + + /** + * Returns a list of the schedules from the given input. + */ + List<ScheduledRecording> getScheduledRecordings(String inputId); + + /** + * Add a {@link OnDvrScheduleLoadFinishedListener}. + */ + void addDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener); + + /** + * Remove a {@link OnDvrScheduleLoadFinishedListener}. + */ + void removeDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener); + + /** + * Add a {@link OnRecordedProgramLoadFinishedListener}. + */ + void addRecordedProgramLoadFinishedListener(OnRecordedProgramLoadFinishedListener listener); + + /** + * Remove a {@link OnRecordedProgramLoadFinishedListener}. + */ + void removeRecordedProgramLoadFinishedListener(OnRecordedProgramLoadFinishedListener listener); /** * Add a {@link ScheduledRecordingListener}. @@ -98,12 +165,21 @@ public interface DvrDataManager { void removeRecordedProgramListener(RecordedProgramListener listener); /** + * Add a {@link ScheduledRecordingListener}. + */ + void addSeriesRecordingListener(SeriesRecordingListener seriesRecordingListener); + + /** + * Remove a {@link ScheduledRecordingListener}. + */ + void removeSeriesRecordingListener(SeriesRecordingListener seriesRecordingListener); + + /** * Returns the scheduled recording program with the given recordingId or null if is not found. */ @Nullable ScheduledRecording getScheduledRecording(long recordingId); - /** * Returns the scheduled recording program with the given programId or null if is not found. */ @@ -116,14 +192,73 @@ public interface DvrDataManager { @Nullable RecordedProgram getRecordedProgram(long recordingId); + /** + * Returns the series recording with the given seriesId or null if is not found. + */ + @Nullable + SeriesRecording getSeriesRecording(long seriesRecordingId); + + /** + * Returns the series recording with the given series ID or {@code null} if not found. + */ + @Nullable + SeriesRecording getSeriesRecording(String seriesId); + + /** + * Returns the schedules which are marked deleted. + */ + Collection<ScheduledRecording> getDeletedSchedules(); + + /** + * Returns the program IDs which is not allowed to make a schedule automatically. + */ + @NonNull + Collection<Long> getDisallowedProgramIds(); + + /** + * Listens for the DVR schedules loading finished. + */ + interface OnDvrScheduleLoadFinishedListener { + void onDvrScheduleLoadFinished(); + } + + /** + * Listens for the recorded program loading finished. + */ + interface OnRecordedProgramLoadFinishedListener { + void onRecordedProgramLoadFinished(); + } + + /** + * Listens for changes to {@link ScheduledRecording}s. + */ interface ScheduledRecordingListener { - void onScheduledRecordingAdded(ScheduledRecording scheduledRecording); + void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings); - void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording); + void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings); - void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording); + /** + * Called when the schedules are updated. + * + * <p>Note that the passed arguments are the new objects with the same ID as the old ones. + */ + void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings); } + /** + * Listens for changes to {@link SeriesRecording}s. + */ + interface SeriesRecordingListener { + void onSeriesRecordingAdded(SeriesRecording... seriesRecordings); + + void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings); + + void onSeriesRecordingChanged(SeriesRecording... seriesRecordings); + } + + /** + * Listens for changes to {@link RecordedProgram}s. + */ interface RecordedProgramListener { void onRecordedProgramAdded(RecordedProgram recordedProgram); diff --git a/src/com/android/tv/dvr/DvrDataManagerImpl.java b/src/com/android/tv/dvr/DvrDataManagerImpl.java index 02c47750..5ae2c4ea 100644 --- a/src/com/android/tv/dvr/DvrDataManagerImpl.java +++ b/src/com/android/tv/dvr/DvrDataManagerImpl.java @@ -21,8 +21,7 @@ import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.database.ContentObserver; -import android.database.Cursor; -import android.media.tv.TvContract; +import android.media.tv.TvContract.RecordedPrograms; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -31,23 +30,34 @@ import android.os.Looper; import android.support.annotation.MainThread; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; import android.util.Range; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.data.ChannelDataManager; import com.android.tv.dvr.ScheduledRecording.RecordingState; -import com.android.tv.dvr.provider.AsyncDvrDbTask; -import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQueryTask; -import com.android.tv.util.AsyncDbTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddScheduleTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddSeriesRecordingTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDeleteScheduleTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDeleteSeriesRecordingTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQueryScheduleTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQuerySeriesRecordingTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncUpdateScheduleTask; +import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncUpdateSeriesRecordingTask; +import com.android.tv.util.AsyncDbTask.AsyncRecordedProgramQueryTask; import com.android.tv.util.Clock; +import com.android.tv.util.TvProviderUriMatcher; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map.Entry; import java.util.Set; /** @@ -60,66 +70,67 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { private static final boolean DEBUG = false; private final HashMap<Long, ScheduledRecording> mScheduledRecordings = new HashMap<>(); + private final HashMap<Long, RecordedProgram> mRecordedPrograms = new HashMap<>(); + private final HashMap<Long, SeriesRecording> mSeriesRecordings = new HashMap<>(); private final HashMap<Long, ScheduledRecording> mProgramId2ScheduledRecordings = new HashMap<>(); - private final HashMap<Long, RecordedProgram> mRecordedPrograms = new HashMap<>(); + private final HashMap<String, SeriesRecording> mSeriesId2SeriesRecordings = new HashMap<>(); private final Context mContext; - private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); - private final ContentObserver mContentObserver = new ContentObserver(mMainThreadHandler) { - + private final ContentObserver mContentObserver = new ContentObserver(new Handler( + Looper.getMainLooper())) { @Override public void onChange(boolean selfChange) { onChange(selfChange, null); } @Override - public void onChange(boolean selfChange, @Nullable final Uri uri) { - if (uri == null) { - // TODO reload everything. - } - AsyncRecordedProgramQueryTask task = new AsyncRecordedProgramQueryTask( + public void onChange(boolean selfChange, final @Nullable Uri uri) { + RecordedProgramsQueryTask task = new RecordedProgramsQueryTask( mContext.getContentResolver(), uri); task.executeOnDbThread(); mPendingTasks.add(task); } }; - private void onObservedChange(Uri uri, RecordedProgram recordedProgram) { - long id = ContentUris.parseId(uri); - if (DEBUG) { - Log.d(TAG, "changed recorded program #" + id + " to " + recordedProgram); - } - if (recordedProgram == null) { - RecordedProgram old = mRecordedPrograms.remove(id); - if (old != null) { - notifyRecordedProgramRemoved(old); - } else { - Log.w(TAG, "Could not find old version of deleted program #" + id); - } - } else { - RecordedProgram old = mRecordedPrograms.put(id, recordedProgram); - if (old == null) { - notifyRecordedProgramAdded(recordedProgram); - } else { - notifyRecordedProgramChanged(recordedProgram); - } - } - } - private boolean mDvrLoadFinished; private boolean mRecordedProgramLoadFinished; private final Set<AsyncTask> mPendingTasks = new ArraySet<>(); + private final DvrDbSync mDbSync; public DvrDataManagerImpl(Context context, Clock clock) { super(context, clock); mContext = context; + mDbSync = new DvrDbSync(context, this); } public void start() { - AsyncDvrQueryTask mDvrQueryTask = new AsyncDvrQueryTask(mContext) { + AsyncDvrQuerySeriesRecordingTask dvrQuerySeriesRecordingTask + = new AsyncDvrQuerySeriesRecordingTask(mContext) { + @Override + protected void onCancelled(List<SeriesRecording> seriesRecordings) { + mPendingTasks.remove(this); + } @Override + protected void onPostExecute(List<SeriesRecording> seriesRecordings) { + mPendingTasks.remove(this); + long maxId = 0; + for (SeriesRecording r : seriesRecordings) { + mSeriesRecordings.put(r.getId(), r); + mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); + if (maxId < r.getId()) { + maxId = r.getId(); + } + } + IdGenerator.SERIES_RECORDING.setMaxId(maxId); + } + }; + dvrQuerySeriesRecordingTask.executeOnDbThread(); + mPendingTasks.add(dvrQuerySeriesRecordingTask); + AsyncDvrQueryScheduleTask dvrQueryRecordingTask + = new AsyncDvrQueryScheduleTask(mContext) { + @Override protected void onCancelled(List<ScheduledRecording> scheduledRecordings) { mPendingTasks.remove(this); } @@ -127,22 +138,63 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { @Override protected void onPostExecute(List<ScheduledRecording> result) { mPendingTasks.remove(this); - mDvrLoadFinished = true; + long maxId = 0; + List<ScheduledRecording> toUpdate = new ArrayList<>(); for (ScheduledRecording r : result) { - mScheduledRecordings.put(r.getId(), r); + if (r.getState() == ScheduledRecording.STATE_RECORDING_DELETED) { + getDeletedScheduleMap().put(r.getProgramId(), r); + } else { + mScheduledRecordings.put(r.getId(), r); + if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) { + mProgramId2ScheduledRecordings.put(r.getProgramId(), r); + } + // Adjust the state of the schedules before DB loading is finished. + if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + if (r.getEndTimeMs() <= mClock.currentTimeMillis()) { + toUpdate.add(ScheduledRecording.buildFrom(r) + .setState(ScheduledRecording.STATE_RECORDING_FAILED) + .build()); + } else { + toUpdate.add(ScheduledRecording.buildFrom(r) + .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED) + .build()); + } + } else if (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + if (r.getEndTimeMs() <= mClock.currentTimeMillis()) { + toUpdate.add(ScheduledRecording.buildFrom(r) + .setState(ScheduledRecording.STATE_RECORDING_FAILED) + .build()); + } + } + } + if (maxId < r.getId()) { + maxId = r.getId(); + } + } + if (!toUpdate.isEmpty()) { + updateScheduledRecording(true, ScheduledRecording.toArray(toUpdate)); + } + IdGenerator.SCHEDULED_RECORDING.setMaxId(maxId); + mDvrLoadFinished = true; + notifyDvrScheduleLoadFinished(); + mDbSync.start(); + if (isInitialized()) { + SeriesRecordingScheduler.getInstance(mContext).start(); } } }; - mDvrQueryTask.executeOnDbThread(); - mPendingTasks.add(mDvrQueryTask); - AsyncRecordedProgramsQueryTask mRecordedProgramQueryTask = - new AsyncRecordedProgramsQueryTask(mContext.getContentResolver()); + dvrQueryRecordingTask.executeOnDbThread(); + mPendingTasks.add(dvrQueryRecordingTask); + RecordedProgramsQueryTask mRecordedProgramQueryTask = + new RecordedProgramsQueryTask(mContext.getContentResolver(), null); mRecordedProgramQueryTask.executeOnDbThread(); ContentResolver cr = mContext.getContentResolver(); - cr.registerContentObserver(TvContract.RecordedPrograms.CONTENT_URI, true, mContentObserver); + cr.registerContentObserver(RecordedPrograms.CONTENT_URI, true, mContentObserver); } public void stop() { + SeriesRecordingScheduler.getInstance(mContext).stop(); + mDbSync.stop(); ContentResolver cr = mContext.getContentResolver(); cr.unregisterContentObserver(mContentObserver); Iterator<AsyncTask> i = mPendingTasks.iterator(); @@ -153,11 +205,80 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } } + private void onRecordedProgramsLoadedFinished(Uri uri, List<RecordedProgram> recordedPrograms) { + if (uri == null) { + uri = RecordedPrograms.CONTENT_URI; + } + int match = TvProviderUriMatcher.match(uri); + if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM) { + if (!mRecordedProgramLoadFinished) { + for (RecordedProgram recorded : recordedPrograms) { + mRecordedPrograms.put(recorded.getId(), recorded); + } + mRecordedProgramLoadFinished = true; + notifyRecordedProgramLoadFinished(); + } else if (recordedPrograms == null || recordedPrograms.isEmpty()) { + for (RecordedProgram recorded : mRecordedPrograms.values()) { + notifyRecordedProgramRemoved(recorded); + } + mRecordedPrograms.clear(); + } else { + HashMap<Long, RecordedProgram> oldRecordedPrograms + = new HashMap<>(mRecordedPrograms); + mRecordedPrograms.clear(); + for (RecordedProgram recorded : recordedPrograms) { + mRecordedPrograms.put(recorded.getId(), recorded); + RecordedProgram old = oldRecordedPrograms.remove(recorded.getId()); + if (old == null) { + notifyRecordedProgramAdded(recorded); + } else { + notifyRecordedProgramChanged(recorded); + } + } + for (RecordedProgram recorded : oldRecordedPrograms.values()) { + notifyRecordedProgramRemoved(recorded); + } + } + if (isInitialized()) { + SeriesRecordingScheduler.getInstance(mContext).start(); + } + } else if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM_ID) { + long id = ContentUris.parseId(uri); + if (DEBUG) Log.d(TAG, "changed recorded program #" + id + " to " + recordedPrograms); + if (recordedPrograms == null || recordedPrograms.isEmpty()) { + RecordedProgram old = mRecordedPrograms.remove(id); + if (old != null) { + notifyRecordedProgramRemoved(old); + } else { + Log.w(TAG, "Could not find old version of deleted program #" + id); + } + } else { + RecordedProgram newRecorded = recordedPrograms.get(0); + RecordedProgram old = mRecordedPrograms.put(id, newRecorded); + if (old == null) { + notifyRecordedProgramAdded(newRecorded); + } else { + notifyRecordedProgramChanged(newRecorded); + } + } + } + } + @Override public boolean isInitialized() { return mDvrLoadFinished && mRecordedProgramLoadFinished; } + @Override + public boolean isDvrScheduleLoadFinished() { + return mDvrLoadFinished; + } + + @Override + public boolean isRecordedProgramLoadFinished() { + return mRecordedProgramLoadFinished; + } + private List<ScheduledRecording> getScheduledRecordingsPrograms() { if (!mDvrLoadFinished) { return Collections.emptyList(); @@ -177,24 +298,50 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } @Override + public List<RecordedProgram> getRecordedPrograms(long seriesRecordingId) { + SeriesRecording seriesRecording = getSeriesRecording(seriesRecordingId); + if (!mRecordedProgramLoadFinished || seriesRecording == null) { + return Collections.emptyList(); + } + return super.getRecordedPrograms(seriesRecordingId); + } + + @Override public List<ScheduledRecording> getAllScheduledRecordings() { return new ArrayList<>(mScheduledRecordings.values()); } - protected List<ScheduledRecording> getRecordingsWithState(@RecordingState int state) { + @Override + protected List<ScheduledRecording> getRecordingsWithState(@RecordingState int... states) { List<ScheduledRecording> result = new ArrayList<>(); for (ScheduledRecording r : mScheduledRecordings.values()) { - if (r.getState() == state) { - result.add(r); + for (int state : states) { + if (r.getState() == state) { + result.add(r); + break; + } } } return result; } @Override - public List<SeasonRecording> getSeasonRecordings() { - // If we return dummy data here, we can implement UI part independently. - return Collections.emptyList(); + public List<SeriesRecording> getSeriesRecordings() { + if (!mDvrLoadFinished) { + return Collections.emptyList(); + } + return new ArrayList<>(mSeriesRecordings.values()); + } + + @Override + public List<SeriesRecording> getSeriesRecordings(String inputId) { + List<SeriesRecording> result = new ArrayList<>(); + for (SeriesRecording r : mSeriesRecordings.values()) { + if (TextUtils.equals(r.getInputId(), inputId)) { + result.add(r); + } + } + return result; } @Override @@ -219,10 +366,33 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } @Override - public List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period) { + public List<ScheduledRecording> getScheduledRecordings(Range<Long> period, + @RecordingState int state) { List<ScheduledRecording> result = new ArrayList<>(); for (ScheduledRecording r : mScheduledRecordings.values()) { - if (r.isOverLapping(period)) { + if (r.isOverLapping(period) && r.getState() == state) { + result.add(r); + } + } + return result; + } + + @Override + public List<ScheduledRecording> getScheduledRecordings(long seriesRecordingId) { + List<ScheduledRecording> result = new ArrayList<>(); + for (ScheduledRecording r : mScheduledRecordings.values()) { + if (r.getSeriesRecordingId() == seriesRecordingId) { + result.add(r); + } + } + return result; + } + + @Override + public List<ScheduledRecording> getScheduledRecordings(String inputId) { + List<ScheduledRecording> result = new ArrayList<>(); + for (ScheduledRecording r : mScheduledRecordings.values()) { + if (TextUtils.equals(r.getInputId(), inputId)) { result.add(r); } } @@ -232,19 +402,13 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { @Nullable @Override public ScheduledRecording getScheduledRecording(long recordingId) { - if (mDvrLoadFinished) { - return mScheduledRecordings.get(recordingId); - } - return null; + return mScheduledRecordings.get(recordingId); } @Nullable @Override public ScheduledRecording getScheduledRecordingForProgramId(long programId) { - if (mDvrLoadFinished) { - return mProgramId2ScheduledRecordings.get(programId); - } - return null; + return mProgramId2ScheduledRecordings.get(programId); } @Nullable @@ -253,151 +417,231 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { return mRecordedPrograms.get(recordingId); } + @Nullable @Override - public void addScheduledRecording(final ScheduledRecording scheduledRecording) { - new AsyncDvrDbTask.AsyncAddRecordingTask(mContext) { - @Override - protected void onPostExecute(List<ScheduledRecording> scheduledRecordings) { - super.onPostExecute(scheduledRecordings); - SoftPreconditions.checkArgument(scheduledRecordings.size() == 1); - for (ScheduledRecording r : scheduledRecordings) { - if (r.getId() != -1) { - mScheduledRecordings.put(r.getId(), r); - if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) { - mProgramId2ScheduledRecordings.put(r.getProgramId(), r); - } - notifyScheduledRecordingAdded(r); - } else { - Log.w(TAG, "Error adding " + r); - } - } + public SeriesRecording getSeriesRecording(long seriesRecordingId) { + return mSeriesRecordings.get(seriesRecordingId); + } + @Nullable + @Override + public SeriesRecording getSeriesRecording(String seriesId) { + return mSeriesId2SeriesRecordings.get(seriesId); + } + + @Override + public void addScheduledRecording(ScheduledRecording... schedules) { + for (ScheduledRecording r : schedules) { + r.setId(IdGenerator.SCHEDULED_RECORDING.newId()); + mScheduledRecordings.put(r.getId(), r); + if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) { + mProgramId2ScheduledRecordings.put(r.getProgramId(), r); } - }.executeOnDbThread(scheduledRecording); + } + if (mDvrLoadFinished) { + notifyScheduledRecordingAdded(schedules); + } + new AsyncAddScheduleTask(mContext).executeOnDbThread(schedules); + removeDeletedSchedules(schedules); } @Override - public void addSeasonRecording(SeasonRecording seasonRecording) { } + public void addSeriesRecording(SeriesRecording... seriesRecordings) { + for (SeriesRecording r : seriesRecordings) { + r.setId(IdGenerator.SERIES_RECORDING.newId()); + mSeriesRecordings.put(r.getId(), r); + mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); + } + if (mDvrLoadFinished) { + notifySeriesRecordingAdded(seriesRecordings); + } + new AsyncAddSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings); + } @Override - public void removeScheduledRecording(final ScheduledRecording scheduledRecording) { - new AsyncDvrDbTask.AsyncDeleteRecordingTask(mContext) { - @Override - protected void onPostExecute(List<Integer> counts) { - super.onPostExecute(counts); - SoftPreconditions.checkArgument(counts.size() == 1); - for (Integer c : counts) { - if (c == 1) { - mScheduledRecordings.remove(scheduledRecording.getId()); - if (scheduledRecording.getProgramId() != ScheduledRecording.ID_NOT_SET) { - mProgramId2ScheduledRecordings - .remove(scheduledRecording.getProgramId()); - } - //TODO change to notifyRecordingUpdated - notifyScheduledRecordingRemoved(scheduledRecording); - } else { - Log.w(TAG, "Error removing " + scheduledRecording); - } - } + public void removeScheduledRecording(ScheduledRecording... schedules) { + removeScheduledRecording(false, schedules); + } + private void removeScheduledRecording(boolean forceDelete, ScheduledRecording... schedules) { + List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); + List<ScheduledRecording> schedulesNotToDelete = new ArrayList<>(); + for (ScheduledRecording r : schedules) { + mScheduledRecordings.remove(r.getId()); + if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) { + mProgramId2ScheduledRecordings.remove(r.getProgramId()); } - }.executeOnDbThread(scheduledRecording); + // If it belongs to the series recording and it's not started yet, do not delete. + // Instead mark deleted. + if (!forceDelete && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET + && r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + SoftPreconditions.checkState(r.getProgramId() != ScheduledRecording.ID_NOT_SET); + ScheduledRecording deleted = ScheduledRecording.buildFrom(r) + .setState(ScheduledRecording.STATE_RECORDING_DELETED).build(); + getDeletedScheduleMap().put(deleted.getProgramId(), deleted); + schedulesNotToDelete.add(deleted); + } else { + schedulesToDelete.add(r); + } + } + if (mDvrLoadFinished) { + notifyScheduledRecordingRemoved(schedules); + } + if (!schedulesToDelete.isEmpty()) { + new AsyncDeleteScheduleTask(mContext).executeOnDbThread( + ScheduledRecording.toArray(schedulesToDelete)); + } + if (!schedulesNotToDelete.isEmpty()) { + new AsyncUpdateScheduleTask(mContext).executeOnDbThread( + ScheduledRecording.toArray(schedulesNotToDelete)); + } } @Override - public void removeSeasonSchedule(SeasonRecording seasonSchedule) { } + public void removeSeriesRecording(final SeriesRecording... seriesRecordings) { + HashSet<Long> ids = new HashSet<>(); + for (SeriesRecording r : seriesRecordings) { + mSeriesRecordings.remove(r.getId()); + mSeriesId2SeriesRecordings.remove(r.getSeriesId()); + ids.add(r.getId()); + } + // Reset series recording ID of the scheduled recording. + List<ScheduledRecording> toUpdate = new ArrayList<>(); + List<ScheduledRecording> toDelete = new ArrayList<>(); + for (ScheduledRecording r : mScheduledRecordings.values()) { + if (ids.contains(r.getSeriesRecordingId())) { + if (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + toDelete.add(r); + } else { + toUpdate.add(ScheduledRecording.buildFrom(r) + .setSeriesRecordingId(SeriesRecording.ID_NOT_SET).build()); + } + } + } + if (!toUpdate.isEmpty()) { + // No need to update DB. It's handled in database automatically when the series + // recording is deleted. + updateScheduledRecording(false, ScheduledRecording.toArray(toUpdate)); + } + if (!toDelete.isEmpty()) { + removeScheduledRecording(true, ScheduledRecording.toArray(toDelete)); + } + if (mDvrLoadFinished) { + notifySeriesRecordingRemoved(seriesRecordings); + } + new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings); + removeDeletedSchedules(seriesRecordings); + } @Override - public void updateScheduledRecording(final ScheduledRecording scheduledRecording) { - new AsyncDvrDbTask.AsyncUpdateRecordingTask(mContext) { - @Override - protected void onPostExecute(List<Integer> counts) { - super.onPostExecute(counts); - SoftPreconditions.checkArgument(counts.size() == 1); - for (Integer c : counts) { - if (c == 1) { - ScheduledRecording oldScheduledRecording = mScheduledRecordings - .put(scheduledRecording.getId(), scheduledRecording); - long programId = scheduledRecording.getProgramId(); - if (oldScheduledRecording != null - && oldScheduledRecording.getProgramId() != programId - && oldScheduledRecording.getProgramId() - != ScheduledRecording.ID_NOT_SET) { - ScheduledRecording oldValueForProgramId = mProgramId2ScheduledRecordings - .get(oldScheduledRecording.getProgramId()); - if (oldValueForProgramId.getId() == scheduledRecording.getId()) { - //Only remove the old ScheduledRecording if it has the same ID as - // the new one. - mProgramId2ScheduledRecordings - .remove(oldScheduledRecording.getProgramId()); - } - } - if (programId != ScheduledRecording.ID_NOT_SET) { - mProgramId2ScheduledRecordings.put(programId, scheduledRecording); - } - //TODO change to notifyRecordingUpdated - notifyScheduledRecordingStatusChanged(scheduledRecording); - } else { - Log.w(TAG, "Error updating " + scheduledRecording); - } + public void updateScheduledRecording(final ScheduledRecording... schedules) { + updateScheduledRecording(true, schedules); + } + + private void updateScheduledRecording(boolean updateDb, + final ScheduledRecording... schedules) { + List<ScheduledRecording> toUpdate = new ArrayList<>(); + for (ScheduledRecording r : schedules) { + if (!SoftPreconditions.checkState(mScheduledRecordings.containsKey(r.getId()), TAG, + "Recording not found for: " + r)) { + continue; + } + toUpdate.add(r); + ScheduledRecording oldScheduledRecording = mScheduledRecordings.put(r.getId(), r); + // The channel ID should not be changed. + SoftPreconditions.checkState(r.getChannelId() == oldScheduledRecording.getChannelId()); + if (DEBUG) Log.d(TAG, "Updating " + oldScheduledRecording + " with " + r); + long programId = r.getProgramId(); + if (oldScheduledRecording != null && oldScheduledRecording.getProgramId() != programId + && oldScheduledRecording.getProgramId() != ScheduledRecording.ID_NOT_SET) { + ScheduledRecording oldValueForProgramId = mProgramId2ScheduledRecordings + .get(oldScheduledRecording.getProgramId()); + if (oldValueForProgramId.getId() == r.getId()) { + // Only remove the old ScheduledRecording if it has the same ID as the new one. + mProgramId2ScheduledRecordings.remove(oldScheduledRecording.getProgramId()); } } - }.executeOnDbThread(scheduledRecording); + if (programId != ScheduledRecording.ID_NOT_SET) { + mProgramId2ScheduledRecordings.put(programId, r); + } + } + ScheduledRecording[] scheduleArray = ScheduledRecording.toArray(toUpdate); + if (mDvrLoadFinished) { + notifyScheduledRecordingStatusChanged(scheduleArray); + } + if (updateDb) { + new AsyncUpdateScheduleTask(mContext).executeOnDbThread(scheduleArray); + } + removeDeletedSchedules(scheduleArray); } - private final class AsyncRecordedProgramsQueryTask - extends AsyncDbTask.AsyncQueryListTask<RecordedProgram> { - public AsyncRecordedProgramsQueryTask(ContentResolver contentResolver) { - super(contentResolver, TvContract.RecordedPrograms.CONTENT_URI, - RecordedProgram.PROJECTION, null, null, null); + @Override + public void updateSeriesRecording(final SeriesRecording... seriesRecordings) { + for (SeriesRecording r : seriesRecordings) { + SeriesRecording old = mSeriesRecordings.put(r.getId(), r); + if (old != null) { + mSeriesId2SeriesRecordings.remove(old.getSeriesId()); + } + mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); } - - @Override - protected RecordedProgram fromCursor(Cursor c) { - return RecordedProgram.fromCursor(c); + if (mDvrLoadFinished) { + notifySeriesRecordingChanged(seriesRecordings); } + new AsyncUpdateSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings); + } - @Override - protected void onCancelled(List<RecordedProgram> scheduledRecordings) { - mPendingTasks.remove(this); + private void removeDeletedSchedules(ScheduledRecording... addedSchedules) { + List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); + for (ScheduledRecording r : addedSchedules) { + ScheduledRecording deleted = getDeletedScheduleMap().remove(r.getProgramId()); + if (deleted != null) { + schedulesToDelete.add(deleted); + } + } + if (!schedulesToDelete.isEmpty()) { + new AsyncDeleteScheduleTask(mContext).executeOnDbThread( + ScheduledRecording.toArray(schedulesToDelete)); } + } - @Override - protected void onPostExecute(List<RecordedProgram> result) { - mPendingTasks.remove(this); - mRecordedProgramLoadFinished = true; - if (result != null) { - for (RecordedProgram r : result) { - mRecordedPrograms.put(r.getId(), r); - } + private void removeDeletedSchedules(SeriesRecording... removedSeriesRecordings) { + Set<Long> seriesRecordingIds = new HashSet<>(); + for (SeriesRecording r : removedSeriesRecordings) { + seriesRecordingIds.add(r.getId()); + } + List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); + Iterator<Entry<Long, ScheduledRecording>> iter = + getDeletedScheduleMap().entrySet().iterator(); + while (iter.hasNext()) { + Entry<Long, ScheduledRecording> entry = iter.next(); + if (seriesRecordingIds.contains(entry.getValue().getSeriesRecordingId())) { + schedulesToDelete.add(entry.getValue()); + iter.remove(); } } + if (!schedulesToDelete.isEmpty()) { + new AsyncDeleteScheduleTask(mContext).executeOnDbThread( + ScheduledRecording.toArray(schedulesToDelete)); + } } - private final class AsyncRecordedProgramQueryTask - extends AsyncDbTask.AsyncQueryItemTask<RecordedProgram> { - + private final class RecordedProgramsQueryTask extends AsyncRecordedProgramQueryTask { private final Uri mUri; - public AsyncRecordedProgramQueryTask(ContentResolver contentResolver, Uri uri) { - super(contentResolver, uri, RecordedProgram.PROJECTION, null, null, null); + public RecordedProgramsQueryTask(ContentResolver contentResolver, Uri uri) { + super(contentResolver, uri == null ? RecordedPrograms.CONTENT_URI : uri); mUri = uri; } @Override - protected RecordedProgram fromCursor(Cursor c) { - return RecordedProgram.fromCursor(c); - } - - @Override - protected void onCancelled(RecordedProgram recordedProgram) { + protected void onCancelled(List<RecordedProgram> scheduledRecordings) { mPendingTasks.remove(this); } @Override - protected void onPostExecute(RecordedProgram recordedProgram) { + protected void onPostExecute(List<RecordedProgram> result) { mPendingTasks.remove(this); - onObservedChange(mUri, recordedProgram); + onRecordedProgramsLoadedFinished(mUri, result); } } } diff --git a/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java b/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java deleted file mode 100644 index 95b342bb..00000000 --- a/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.tv.dvr; - -import android.content.Context; -import android.support.annotation.MainThread; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; -import android.util.Range; - -import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.recording.RecordedProgram; -import com.android.tv.util.Clock; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicLong; - -/** - * A DVR Data manager that stores values in memory suitable for testing. - */ -@VisibleForTesting // TODO(DVR): move to testing dir. -@MainThread -public final class DvrDataManagerInMemoryImpl extends BaseDvrDataManager { - private final static String TAG = "DvrDataManagerInMemory"; - private final AtomicLong mNextId = new AtomicLong(1); - private final Map<Long, ScheduledRecording> mScheduledRecordings = new HashMap<>(); - private final Map<Long, RecordedProgram> mRecordedPrograms = new HashMap<>(); - private final List<SeasonRecording> mSeasonSchedule = new ArrayList<>(); - - public DvrDataManagerInMemoryImpl(Context context, Clock clock) { - super(context, clock); - } - - @Override - public boolean isInitialized() { - return true; - } - - private List<ScheduledRecording> getScheduledRecordingsPrograms() { - return new ArrayList(mScheduledRecordings.values()); - } - - @Override - public List<RecordedProgram> getRecordedPrograms() { - return new ArrayList<>(mRecordedPrograms.values()); - } - - @Override - public List<ScheduledRecording> getAllScheduledRecordings() { - return new ArrayList<>(mScheduledRecordings.values()); - } - - public List<SeasonRecording> getSeasonRecordings() { - return mSeasonSchedule; - } - - @Override - public long getNextScheduledStartTimeAfter(long startTime) { - - List<ScheduledRecording> temp = getNonStartedScheduledRecordings(); - Collections.sort(temp, ScheduledRecording.START_TIME_COMPARATOR); - for (ScheduledRecording r : temp) { - if (r.getStartTimeMs() > startTime) { - return r.getStartTimeMs(); - } - } - return DvrDataManager.NEXT_START_TIME_NOT_FOUND; - } - - @Override - public List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period) { - List<ScheduledRecording> temp = getScheduledRecordingsPrograms(); - List<ScheduledRecording> result = new ArrayList<>(); - for (ScheduledRecording r : temp) { - if (r.isOverLapping(period)) { - result.add(r); - } - } - return result; - } - - /** - * Add a new scheduled recording. - */ - @Override - public void addScheduledRecording(ScheduledRecording scheduledRecording) { - addScheduledRecordingInternal(scheduledRecording); - } - - - public void addRecordedProgram(RecordedProgram recordedProgram) { - addRecordedProgramInternal(recordedProgram); - } - - public void updateRecordedProgram(RecordedProgram r) { - long id = r.getId(); - if (mRecordedPrograms.containsKey(id)) { - mRecordedPrograms.put(id, r); - notifyRecordedProgramChanged(r); - } else { - throw new IllegalArgumentException("Recording not found:" + r); - } - } - - public void removeRecordedProgram(RecordedProgram scheduledRecording) { - mRecordedPrograms.remove(scheduledRecording.getId()); - notifyRecordedProgramRemoved(scheduledRecording); - } - - - public ScheduledRecording addScheduledRecordingInternal(ScheduledRecording scheduledRecording) { - SoftPreconditions - .checkState(scheduledRecording.getId() == ScheduledRecording.ID_NOT_SET, TAG, - "expected id of " + ScheduledRecording.ID_NOT_SET + " but was " - + scheduledRecording); - scheduledRecording = ScheduledRecording.buildFrom(scheduledRecording) - .setId(mNextId.incrementAndGet()) - .build(); - mScheduledRecordings.put(scheduledRecording.getId(), scheduledRecording); - notifyScheduledRecordingAdded(scheduledRecording); - return scheduledRecording; - } - - public RecordedProgram addRecordedProgramInternal(RecordedProgram recordedProgram) { - SoftPreconditions.checkState(recordedProgram.getId() == RecordedProgram.ID_NOT_SET, TAG, - "expected id of " + RecordedProgram.ID_NOT_SET + " but was " + recordedProgram); - recordedProgram = RecordedProgram.buildFrom(recordedProgram) - .setId(mNextId.incrementAndGet()) - .build(); - mRecordedPrograms.put(recordedProgram.getId(), recordedProgram); - notifyRecordedProgramAdded(recordedProgram); - return recordedProgram; - } - - @Override - public void addSeasonRecording(SeasonRecording seasonRecording) { - mSeasonSchedule.add(seasonRecording); - } - - @Override - public void removeScheduledRecording(ScheduledRecording scheduledRecording) { - mScheduledRecordings.remove(scheduledRecording.getId()); - notifyScheduledRecordingRemoved(scheduledRecording); - } - - @Override - public void removeSeasonSchedule(SeasonRecording seasonSchedule) { - mSeasonSchedule.remove(seasonSchedule); - } - - @Override - public void updateScheduledRecording(ScheduledRecording r) { - long id = r.getId(); - if (mScheduledRecordings.containsKey(id)) { - mScheduledRecordings.put(id, r); - notifyScheduledRecordingStatusChanged(r); - } else { - throw new IllegalArgumentException("Recording not found:" + r); - } - } - - @Nullable - @Override - public ScheduledRecording getScheduledRecording(long id) { - return mScheduledRecordings.get(id); - } - - @Nullable - @Override - public ScheduledRecording getScheduledRecordingForProgramId(long programId) { - for (ScheduledRecording r : mScheduledRecordings.values()) { - if (r.getProgramId() == programId) { - return r; - } - } - return null; - } - - @Nullable - @Override - public RecordedProgram getRecordedProgram(long recordingId) { - return mRecordedPrograms.get(recordingId); - } - - @Override - @NonNull - protected List<ScheduledRecording> getRecordingsWithState(int state) { - ArrayList<ScheduledRecording> result = new ArrayList<>(); - for (ScheduledRecording r : mScheduledRecordings.values()) { - if(r.getState() == state){ - result.add(r); - } - } - return result; - } -} diff --git a/src/com/android/tv/dvr/DvrDbSync.java b/src/com/android/tv/dvr/DvrDbSync.java new file mode 100644 index 00000000..baa7f3d9 --- /dev/null +++ b/src/com/android/tv/dvr/DvrDbSync.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ContentUris; +import android.content.Context; +import android.database.ContentObserver; +import android.media.tv.TvContract.Programs; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.MainThread; +import android.support.annotation.VisibleForTesting; + +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.util.AsyncDbTask.AsyncQueryProgramTask; +import com.android.tv.util.TvProviderUriMatcher; + +import java.util.LinkedList; +import java.util.Objects; +import java.util.Queue; + +/** + * A class to synchronizes DVR DB with TvProvider. + */ +@MainThread +@TargetApi(Build.VERSION_CODES.N) +class DvrDbSync { + private final Context mContext; + private final DvrDataManagerImpl mDataManager; + private UpdateProgramTask mUpdateProgramTask; + private final Queue<Long> mProgramIdQueue = new LinkedList<>(); + private final ContentObserver mProgramsContentObserver = new ContentObserver(new Handler( + Looper.getMainLooper())) { + @SuppressLint("SwitchIntDef") + @Override + public void onChange(boolean selfChange, Uri uri) { + switch (TvProviderUriMatcher.match(uri)) { + case TvProviderUriMatcher.MATCH_PROGRAM: + onProgramsUpdated(); + break; + case TvProviderUriMatcher.MATCH_PROGRAM_ID: + addProgramIdToCheckIfNeeded(mDataManager.getScheduledRecordingForProgramId( + ContentUris.parseId(uri))); + break; + } + } + }; + private final ScheduledRecordingListener mScheduleListener = new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + addProgramIdToCheckIfNeeded(schedule); + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + mProgramIdQueue.remove(schedule.getProgramId()); + } + } + }; + + public DvrDbSync(Context context, DvrDataManagerImpl dataManager) { + mContext = context; + mDataManager = dataManager; + } + + /** + * Starts the DB sync. + */ + public void start() { + mContext.getContentResolver().registerContentObserver(Programs.CONTENT_URI, true, + mProgramsContentObserver); + mDataManager.addScheduledRecordingListener(mScheduleListener); + onProgramsUpdated(); + } + + /** + * Stops the DB sync. + */ + public void stop() { + mProgramIdQueue.clear(); + if (mUpdateProgramTask != null) { + mUpdateProgramTask.cancel(true); + } + mDataManager.removeScheduledRecordingListener(mScheduleListener); + mContext.getContentResolver().unregisterContentObserver(mProgramsContentObserver); + } + + private void onProgramsUpdated() { + for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { + addProgramIdToCheckIfNeeded(schedule); + } + } + + private void addProgramIdToCheckIfNeeded(ScheduledRecording schedule) { + if (schedule == null) { + return; + } + long programId = schedule.getProgramId(); + if (programId != ScheduledRecording.ID_NOT_SET + && !mProgramIdQueue.contains(programId) + && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) { + mProgramIdQueue.offer(programId); + startNextUpdateIfNeeded(); + } + } + + private void startNextUpdateIfNeeded() { + if (mProgramIdQueue.isEmpty()) { + return; + } + if (mUpdateProgramTask == null || mUpdateProgramTask.isCancelled()) { + mUpdateProgramTask = new UpdateProgramTask(mProgramIdQueue.poll()); + mUpdateProgramTask.executeOnDbThread(); + } + } + + @VisibleForTesting + void handleUpdateProgram(Program program, long programId) { + ScheduledRecording schedule = mDataManager.getScheduledRecordingForProgramId(programId); + if (schedule != null + && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) { + if (program == null) { + mDataManager.removeScheduledRecording(schedule); + } else { + long currentTimeMs = System.currentTimeMillis(); + // Change start time only when the recording start time has not passed. + boolean needToChangeStartTime = schedule.getStartTimeMs() > currentTimeMs + && program.getStartTimeUtcMillis() != schedule.getStartTimeMs(); + ScheduledRecording.Builder builder = ScheduledRecording.buildFrom(schedule) + .setEndTimeMs(program.getEndTimeUtcMillis()) + .setSeasonNumber(program.getSeasonNumber()) + .setEpisodeNumber(program.getEpisodeNumber()) + .setEpisodeTitle(program.getEpisodeTitle()) + .setProgramDescription(program.getDescription()) + .setProgramLongDescription(program.getLongDescription()) + .setProgramPosterArtUri(program.getPosterArtUri()) + .setProgramThumbnailUri(program.getThumbnailUri()); + if (needToChangeStartTime) { + mDataManager.updateScheduledRecording( + builder.setStartTimeMs(program.getStartTimeUtcMillis()).build()); + } else if (schedule.getEndTimeMs() != program.getEndTimeUtcMillis() + || !Objects.equals(schedule.getSeasonNumber(), program.getSeasonNumber()) + || !Objects.equals(schedule.getEpisodeNumber(), program.getEpisodeNumber()) + || !Objects.equals(schedule.getEpisodeTitle(), program.getEpisodeTitle()) + || !Objects.equals(schedule.getProgramDescription(), + program.getDescription()) + || !Objects.equals(schedule.getProgramLongDescription(), + program.getLongDescription()) + || !Objects.equals(schedule.getProgramPosterArtUri(), + program.getPosterArtUri()) + || !Objects.equals(schedule.getProgramThumbnailUri(), + program.getThumbnailUri())) { + mDataManager.updateScheduledRecording(builder.build()); + } + } + } + } + + private class UpdateProgramTask extends AsyncQueryProgramTask { + private final long mProgramId; + + public UpdateProgramTask(long programId) { + super(mContext.getContentResolver(), programId); + mProgramId = programId; + } + + @Override + protected void onCancelled(Program program) { + mUpdateProgramTask = null; + startNextUpdateIfNeeded(); + } + + @Override + protected void onPostExecute(Program program) { + mUpdateProgramTask = null; + handleUpdateProgram(program, mProgramId); + startNextUpdateIfNeeded(); + } + } +} diff --git a/src/com/android/tv/dvr/DvrManager.java b/src/com/android/tv/dvr/DvrManager.java index e3dc622e..48ca6eee 100644 --- a/src/com/android/tv/dvr/DvrManager.java +++ b/src/com/android/tv/dvr/DvrManager.java @@ -16,28 +16,36 @@ package com.android.tv.dvr; +import android.annotation.TargetApi; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; +import android.media.tv.TvContract; import android.media.tv.TvInputInfo; +import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.support.annotation.MainThread; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.util.Log; -import android.util.Range; -import android.widget.Toast; import com.android.tv.ApplicationSingletons; import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.recording.RecordedProgram; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; +import com.android.tv.dvr.SeriesRecordingScheduler.ProgramLoadCallback; import com.android.tv.util.AsyncDbTask; import com.android.tv.util.Utils; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -49,11 +57,14 @@ import java.util.Map.Entry; * instead of modifying them directly through {@link DvrDataManager}. */ @MainThread +@TargetApi(Build.VERSION_CODES.N) public class DvrManager { - private final static String TAG = "DvrManager"; + private static final String TAG = "DvrManager"; + private static final boolean DEBUG = false; + private final WritableDvrDataManager mDataManager; private final ChannelDataManager mChannelDataManager; - private final DvrSessionManager mDvrSessionManager; + private final DvrScheduleManager mScheduleManager; // @GuardedBy("mListener") private final Map<Listener, Handler> mListener = new HashMap<>(); private final Context mAppContext; @@ -64,24 +75,58 @@ public class DvrManager { mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager(); mAppContext = context.getApplicationContext(); mChannelDataManager = appSingletons.getChannelDataManager(); - mDvrSessionManager = appSingletons.getDvrSessionManger(); + mScheduleManager = appSingletons.getDvrScheduleManager(); + } + + /** + * Schedules a recording for {@code program}. + */ + public void addSchedule(Program program) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, program); + if (input == null) { + Log.e(TAG, "Can't find input for program: " + program); + return; + } + ScheduledRecording schedule; + SeriesRecording seriesRecording = getSeriesRecording(program); + if (seriesRecording == null) { + schedule = createScheduledRecordingBuilder(input.getId(), program) + .setPriority(mScheduleManager.suggestNewPriority()) + .build(); + } else { + schedule = createScheduledRecordingBuilder(input.getId(), program) + .setPriority(seriesRecording.getPriority()) + .setSeriesRecordingId(seriesRecording.getId()) + .build(); + } + mDataManager.addScheduledRecording(schedule); } /** * Schedules a recording for {@code program} instead of the list of recording that conflict. + * * @param program the program to record * @param recordingsToOverride the possible empty list of recordings that will not be recorded */ public void addSchedule(Program program, List<ScheduledRecording> recordingsToOverride) { - Log.i(TAG, - "Adding scheduled recording of " + program + " instead of " + recordingsToOverride); + Log.i(TAG, "Adding scheduled recording of " + program + " instead of " + + recordingsToOverride); + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, program); + if (input == null) { + Log.e(TAG, "Can't find input for program: " + program); + return; + } Collections.sort(recordingsToOverride, ScheduledRecording.PRIORITY_COMPARATOR); - Channel c = mChannelDataManager.getChannel(program.getChannelId()); long priority = recordingsToOverride.isEmpty() ? Long.MAX_VALUE - : recordingsToOverride.get(0).getPriority() - 1; - ScheduledRecording r = ScheduledRecording.builder(program) + : recordingsToOverride.get(0).getPriority() + 1; + ScheduledRecording r = createScheduledRecordingBuilder(input.getId(), program) .setPriority(priority) - .setChannelId(c.getId()) .build(); mDataManager.addScheduledRecording(r); } @@ -90,27 +135,242 @@ public class DvrManager { * Adds a recording schedule with a time range. */ public void addSchedule(Channel channel, long startTime, long endTime) { - Log.i(TAG, "Adding scheduled recording of channel" + channel + " starting at " + + Log.i(TAG, "Adding scheduled recording of channel " + channel + " starting at " + Utils.toTimeString(startTime) + " and ending at " + Utils.toTimeString(endTime)); - //TODO: handle error cases - ScheduledRecording r = ScheduledRecording.builder(startTime, endTime) - .setChannelId(channel.getId()) + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + TvInputInfo input = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId()); + if (input == null) { + Log.e(TAG, "Can't find input for channel: " + channel); + return; + } + addScheduleInternal(input.getId(), channel.getId(), startTime, endTime); + } + + private void addScheduleInternal(String inputId, long channelId, long startTime, long endTime) { + mDataManager.addScheduledRecording(ScheduledRecording + .builder(inputId, channelId, startTime, endTime) + .setPriority(mScheduleManager.suggestNewPriority()) + .build()); + } + + /** + * Adds a new series recording and schedules for the programs. + */ + public SeriesRecording addSeriesRecording(Program selectedProgram, + List<Program> programsToSchedule) { + Log.i(TAG, "Adding series recording for program " + selectedProgram + ", and schedules: " + + programsToSchedule); + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + return null; + } + TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, selectedProgram); + if (input == null) { + Log.e(TAG, "Can't find input for program: " + selectedProgram); + return null; + } + SeriesRecording seriesRecording = SeriesRecording.builder(input.getId(), selectedProgram) + .setPriority(mScheduleManager.suggestNewSeriesPriority()) .build(); - mDataManager.addScheduledRecording(r); + mDataManager.addSeriesRecording(seriesRecording); + // The schedules for the recorded programs should be added not to create the schedule the + // duplicate episodes. + addRecordedProgramToSeriesRecording(seriesRecording); + addScheduleToSeriesRecording(seriesRecording, programsToSchedule); + return seriesRecording; + } + + private void addRecordedProgramToSeriesRecording(SeriesRecording series) { + List<ScheduledRecording> toAdd = new ArrayList<>(); + for (RecordedProgram recordedProgram : mDataManager.getRecordedPrograms()) { + if (series.getSeriesId().equals(recordedProgram.getSeriesId()) + && !recordedProgram.isClipped()) { + // Duplicate schedules can exist, but they will be deleted in a few days. And it's + // also guaranteed that the schedules don't belong to any series recordings because + // there are no more than one series recordings which have the same program title. + toAdd.add(ScheduledRecording.builder(recordedProgram) + .setPriority(series.getPriority()) + .setSeriesRecordingId(series.getId()).build()); + } + } + if (!toAdd.isEmpty()) { + mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd)); + } + } + + /** + * Adds {@link ScheduledRecording}s for the series recording. + * <p> + * This method doesn't add the series recording. + */ + public void addScheduleToSeriesRecording(SeriesRecording series, + List<Program> programsToSchedule) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + TvInputInfo input = Utils.getTvInputInfoForInputId(mAppContext, series.getInputId()); + if (input == null) { + Log.e(TAG, "Can't find input with ID: " + series.getInputId()); + return; + } + List<ScheduledRecording> toAdd = new ArrayList<>(); + List<ScheduledRecording> toUpdate = new ArrayList<>(); + for (Program program : programsToSchedule) { + ScheduledRecording scheduleWithSameProgram = + mDataManager.getScheduledRecordingForProgramId(program.getId()); + if (scheduleWithSameProgram != null) { + if (scheduleWithSameProgram.getState() + == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || scheduleWithSameProgram.getState() + == ScheduledRecording.STATE_RECORDING_CANCELED) { + ScheduledRecording r = ScheduledRecording.buildFrom(scheduleWithSameProgram) + .setPriority(series.getPriority()) + .setSeriesRecordingId(series.getId()) + .build(); + if (!r.equals(scheduleWithSameProgram)) { + toUpdate.add(r); + } + } + } else { + ScheduledRecording.Builder scheduledRecordingBuilder = + createScheduledRecordingBuilder(input.getId(), program) + .setPriority(series.getPriority()) + .setSeriesRecordingId(series.getId()); + if (series.getState() == SeriesRecording.STATE_SERIES_CANCELED) { + scheduledRecordingBuilder.setState( + ScheduledRecording.STATE_RECORDING_CANCELED); + } + toAdd.add(scheduledRecordingBuilder.build()); + } + } + if (!toAdd.isEmpty()) { + mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd)); + } + if (!toUpdate.isEmpty()) { + mDataManager.updateScheduledRecording(ScheduledRecording.toArray(toUpdate)); + } + } + + /** + * Updates the series recording. + */ + public void updateSeriesRecording(SeriesRecording series) { + if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + // TODO: revise this method. b/30946239 + boolean isPreviousCanceled = false; + long oldPriority = 0; + SeriesRecording previousSeries = mDataManager.getSeriesRecording(series.getId()); + if (previousSeries != null) { + isPreviousCanceled = previousSeries.getState() + == SeriesRecording.STATE_SERIES_CANCELED; + oldPriority = previousSeries.getPriority(); + } + mDataManager.updateSeriesRecording(series); + if (!isPreviousCanceled && series.getState() == SeriesRecording.STATE_SERIES_CANCELED) { + cancelScheduleToSeriesRecording(series); + } else if (isPreviousCanceled + && series.getState() == SeriesRecording.STATE_SERIES_NORMAL) { + resumeScheduleToSeriesRecording(series); + } + if (oldPriority != series.getPriority()) { + long priority = series.getPriority(); + List<ScheduledRecording> schedulesToUpdate = new ArrayList<>(); + for (ScheduledRecording schedule + : mDataManager.getScheduledRecordings(series.getId())) { + if (schedule.getState() != ScheduledRecording.STATE_RECORDING_IN_PROGRESS + && schedule.getStartTimeMs() > System.currentTimeMillis()) { + schedulesToUpdate.add(ScheduledRecording.buildFrom(schedule) + .setPriority(priority).build()); + } + } + if (!schedulesToUpdate.isEmpty()) { + mDataManager.updateScheduledRecording( + ScheduledRecording.toArray(schedulesToUpdate)); + } + } + } + } + + private void cancelScheduleToSeriesRecording(SeriesRecording series) { + List<ScheduledRecording> allRecordings = mDataManager.getAvailableScheduledRecordings(); + for (ScheduledRecording recording : allRecordings) { + if (recording.getSeriesRecordingId() == series.getId()) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + stopRecording(recording); + continue; + } + updateScheduledRecording(ScheduledRecording.buildFrom(recording).setState + (ScheduledRecording.STATE_RECORDING_CANCELED).build()); + } + } + } + + private void resumeScheduleToSeriesRecording(SeriesRecording series) { + List<ScheduledRecording> allRecording = mDataManager + .getAvailableAndCanceledScheduledRecordings(); + for (ScheduledRecording recording : allRecording) { + if (recording.getSeriesRecordingId() == series.getId()) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_CANCELED && + recording.getEndTimeMs() > System.currentTimeMillis()) { + updateScheduledRecording(ScheduledRecording.buildFrom(recording) + .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED).build()); + } + } + } } /** - * Adds a season recording schedule based on {@code program}. + * Queries the programs which belong to the same series as {@code seriesProgram}. + * <p> + * It's done in the background because it needs the DB access, and the callback will be called + * when it finishes. */ - public void addSeasonSchedule(Program program) { - Log.i(TAG, "Adding season recording of " + program); - // TODO: implement + public void queryProgramsForSeries(Program seriesProgram, ProgramLoadCallback callback) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + callback.onProgramLoadFinished(Collections.emptyList()); + return; + } + TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, seriesProgram); + if (input == null) { + Log.e(TAG, "Can't find input for program: " + seriesProgram); + return; + } + SeriesRecordingScheduler.getInstance(mAppContext).queryPrograms( + SeriesRecording.builder(input.getId(), seriesProgram) + .setPriority(mScheduleManager.suggestNewPriority()) + .build(), callback); + } + + /** + * Removes the series recording and all the corresponding schedules which are not started yet. + */ + public void removeSeriesRecording(long seriesRecordingId) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + SeriesRecording series = mDataManager.getSeriesRecording(seriesRecordingId); + if (series == null) { + return; + } + for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { + if (schedule.getSeriesRecordingId() == seriesRecordingId) { + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + stopRecording(schedule); + break; + } + } + } + mDataManager.removeSeriesRecording(series); } /** * Stops the currently recorded program */ public void stopRecording(final ScheduledRecording recording) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } synchronized (mListener) { for (final Entry<Listener, Handler> entry : mListener.entrySet()) { entry.getValue().post(new Runnable() { @@ -124,86 +384,258 @@ public class DvrManager { } /** - * Removes a scheduled recording or an existing recording. + * Removes scheduled recordings or an existing recordings. */ - public void removeScheduledRecording(ScheduledRecording scheduledRecording) { - Log.i(TAG, "Removing " + scheduledRecording); - mDataManager.removeScheduledRecording(scheduledRecording); + public void removeScheduledRecording(ScheduledRecording... schedules) { + Log.i(TAG, "Removing " + Arrays.asList(schedules)); + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return; + } + for (ScheduledRecording r : schedules) { + if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + stopRecording(r); + } else { + mDataManager.removeScheduledRecording(r); + } + } + } + + /** + * Removes the recorded program. It deletes the file if possible. + */ + public void removeRecordedProgram(Uri recordedProgramUri) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + return; + } + removeRecordedProgram(ContentUris.parseId(recordedProgramUri)); + } + + /** + * Removes the recorded program. It deletes the file if possible. + */ + public void removeRecordedProgram(long recordedProgramId) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + return; + } + RecordedProgram recordedProgram = mDataManager.getRecordedProgram(recordedProgramId); + if (recordedProgram != null) { + removeRecordedProgram(recordedProgram); + } } + /** + * Removes the recorded program. It deletes the file if possible. + */ public void removeRecordedProgram(final RecordedProgram recordedProgram) { - // TODO(dvr): implement - Log.i(TAG, "To delete " + recordedProgram - + "\nyou should manually delete video data at" - + "\nadb shell rm -rf " + recordedProgram.getDataUri() - ); - Toast.makeText(mAppContext, "Deleting recorded programs is not fully implemented yet", - Toast.LENGTH_SHORT).show(); + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + return; + } new AsyncDbTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { ContentResolver resolver = mAppContext.getContentResolver(); resolver.delete(recordedProgram.getUri(), null, null); + try { + Uri dataUri = recordedProgram.getDataUri(); + if (dataUri != null && ContentResolver.SCHEME_FILE.equals(dataUri.getScheme()) + && dataUri.getPath() != null) { + File recordedProgramPath = new File(dataUri.getPath()); + if (!recordedProgramPath.exists()) { + if (DEBUG) Log.d(TAG, "File to delete not exist: " + + recordedProgramPath); + } else { + Utils.deleteDirOrFile(recordedProgramPath); + if (DEBUG) { + Log.d(TAG, "Sucessfully deleted files of the recorded program: " + + recordedProgram.getDataUri()); + } + } + } + } catch (SecurityException e) { + if (DEBUG) { + Log.d(TAG, "To delete " + recordedProgram + + "\nyou should manually delete video data at" + + "\nadb shell rm -rf " + recordedProgram.getDataUri()); + } + } return null; } - }.execute(); + }.executeOnDbThread(); } /** - * Returns priority ordered list of all scheduled recording that will not be recorded if + * Remove all recorded programs due to missing storage. + * + * @param inputId for the recorded programs to remove + */ + public void removeRecordedProgramByMissingStorage(final String inputId) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { + return; + } + new AsyncDbTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContentResolver resolver = mAppContext.getContentResolver(); + String args[] = { inputId }; + resolver.delete(TvContract.RecordedPrograms.CONTENT_URI, + TvContract.RecordedPrograms.COLUMN_INPUT_ID + " = ?", args); + return null; + } + }.executeOnDbThread(); + } + + /** + * Updates the scheduled recording. + */ + public void updateScheduledRecording(ScheduledRecording recording) { + if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + mDataManager.updateScheduledRecording(recording); + } + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if * this program is. * - * <p>Any empty list means there is no conflicts. If there is conflict the program must be - * scheduled to record with a Priority lower than the first Recording in the list returned. - */ - public List<ScheduledRecording> getScheduledRecordingsThatConflict(Program program) { - //TODO(DVR): move to scheduler. - //TODO(DVR): deal with more than one DvrInputService - List<ScheduledRecording> overLap = mDataManager.getRecordingsThatOverlapWith(getPeriod(program)); - if (!overLap.isEmpty()) { - // TODO(DVR): ignore shows that already won't record. - Channel channel = mChannelDataManager.getChannel(program.getChannelId()); - if (channel != null) { - TvInputInfo info = mDvrSessionManager.getTvInputInfo(channel.getInputId()); - if (info == null) { - Log.w(TAG, - "Could not find a recording TvInputInfo for " + channel.getInputId()); - return overLap; - } - int remove = Math.max(0, info.getTunerCount() - 1); - if (remove >= overLap.size()) { - return Collections.EMPTY_LIST; - } - overLap = overLap.subList(remove, overLap.size() - 1); + * @see DvrScheduleManager#getConflictingSchedules(Program) + */ + public List<ScheduledRecording> getConflictingSchedules(Program program) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return Collections.emptyList(); + } + return mScheduleManager.getConflictingSchedules(program); + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if + * this channel is. + * + * @see DvrScheduleManager#getConflictingSchedules(long, long, long) + */ + public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs, + long endTimeMs) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return Collections.emptyList(); + } + return mScheduleManager.getConflictingSchedules(channelId, startTimeMs, endTimeMs); + } + + /** + * Checks if the schedule is conflicting. + * + * <p>Note that the {@code schedule} should be the existing one. If not, this returns + * {@code false}. + */ + public boolean isConflicting(ScheduledRecording schedule) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return false; + } + return mScheduleManager.isConflicting(schedule); + } + + /** + * Returns priority ordered list of all scheduled recording that will not be recorded if + * this channel is tuned to. + * + * @see DvrScheduleManager#getConflictingSchedulesForTune + */ + public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return Collections.emptyList(); + } + return mScheduleManager.getConflictingSchedulesForTune(channelId); + } + + /** + * Returns the earliest end time of the current recording for the TV input. If there are no + * recordings, Long.MAX_VALUE is returned. + */ + public long getEarliestRecordingEndTime(String inputId) { + long result = Long.MAX_VALUE; + for (ScheduledRecording schedule : mDataManager.getStartedRecordings()) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mAppContext, + schedule.getChannelId()); + if (input != null && input.getId().equals(inputId) + && schedule.getEndTimeMs() < result) { + result = schedule.getEndTimeMs(); } } - return overLap; + return result; } - @NonNull - private static Range getPeriod(Program program) { - return new Range(program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis()); + /** + * Returns {@code true} if the channel can be recorded. + * <p> + * Note that this method doesn't check the conflict of the schedule or available tuners. + * This can be called from the UI before the schedules are loaded. + */ + public boolean isChannelRecordable(Channel channel) { + if (!mDataManager.isDvrScheduleLoadFinished() || channel == null) { + return false; + } + TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId()); + if (info == null) { + Log.w(TAG, "Could not find TvInputInfo for " + channel); + return false; + } + if (!info.canRecord()) { + return false; + } + Program program = TvApplication.getSingletons(mAppContext).getProgramDataManager() + .getCurrentProgram(channel.getId()); + return program == null || !program.isRecordingProhibited(); } /** - * Checks whether {@code channel} can be tuned without any conflict with existing recordings - * in progress. If there is any conflict, {@code outConflictRecordings} will be filled. + * Returns {@code true} if the program can be recorded. + * <p> + * Note that this method doesn't check the conflict of the schedule or available tuners. + * This can be called from the UI before the schedules are loaded. */ - public boolean canTuneTo(Channel channel, List<ScheduledRecording> outConflictScheduledRecordings) { - // TODO: implement - return true; + public boolean isProgramRecordable(Program program) { + if (!mDataManager.isInitialized()) { + return false; + } + TvInputInfo info = Utils.getTvInputInfoForProgram(mAppContext, program); + if (info == null) { + Log.w(TAG, "Could not find TvInputInfo for " + program); + return false; + } + return info.canRecord() && !program.isRecordingProhibited(); } /** - * Returns true is the inputId supports recording. + * Returns the current recording for the channel. + * <p> + * This can be called from the UI before the schedules are loaded. */ - public boolean canRecord(String inputId) { - TvInputInfo info = mDvrSessionManager.getTvInputInfo(inputId); - return info != null && info.getTunerCount() > 0; + public ScheduledRecording getCurrentRecording(long channelId) { + if (!mDataManager.isDvrScheduleLoadFinished()) { + return null; + } + for (ScheduledRecording recording : mDataManager.getStartedRecordings()) { + if (recording.getChannelId() == channelId) { + return recording; + } + } + return null; + } + + /** + * Returns the series recording related to the program. + */ + @Nullable + public SeriesRecording getSeriesRecording(Program program) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return null; + } + return mDataManager.getSeriesRecording(program.getSeriesId()); } @WorkerThread - void addListener(Listener listener, @NonNull Handler handler) { + @VisibleForTesting + // Should be public to use mock DvrManager object. + public void addListener(Listener listener, @NonNull Handler handler) { SoftPreconditions.checkNotNull(handler); synchronized (mListener) { mListener.put(listener, handler); @@ -211,13 +643,68 @@ public class DvrManager { } @WorkerThread - void removeListener(Listener listener) { + @VisibleForTesting + // Should be public to use mock DvrManager object. + public void removeListener(Listener listener) { synchronized (mListener) { mListener.remove(listener); } } /** + * Returns ScheduledRecording.builder based on {@code program}. If program is already started, + * recording started time is clipped to the current time. + */ + private ScheduledRecording.Builder createScheduledRecordingBuilder(String inputId, + Program program) { + ScheduledRecording.Builder builder = ScheduledRecording.builder(inputId, program); + long time = System.currentTimeMillis(); + if (program.getStartTimeUtcMillis() < time && time < program.getEndTimeUtcMillis()) { + builder.setStartTimeMs(time); + } + return builder; + } + + /** + * Returns a schedule which matches to the given episode. + */ + public ScheduledRecording getScheduledRecording(String title, String seasonNumber, + String episodeNumber) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized()) || title == null + || seasonNumber == null || episodeNumber == null) { + return null; + } + for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) { + if (title.equals(r.getProgramTitle()) + && seasonNumber.equals(r.getSeasonNumber()) + && episodeNumber.equals(r.getEpisodeNumber())) { + return r; + } + } + return null; + } + + /** + * Returns a recorded program which is the same episode as the given {@code program}. + */ + public RecordedProgram getRecordedProgram(String title, String seasonNumber, + String episodeNumber) { + if (!SoftPreconditions.checkState(mDataManager.isInitialized()) || title == null + || seasonNumber == null || episodeNumber == null) { + return null; + } + for (RecordedProgram r : mDataManager.getRecordedPrograms()) { + if (title.equals(r.getTitle()) + && seasonNumber.equals(r.getSeasonNumber()) + && episodeNumber.equals(r.getEpisodeNumber()) + && !r.isClipped()) { + return r; + } + } + return null; + } + + /** * Listener internally used inside dvr package. */ interface Listener { diff --git a/src/com/android/tv/dvr/DvrPlayActivity.java b/src/com/android/tv/dvr/DvrPlayActivity.java deleted file mode 100644 index b117a7cf..00000000 --- a/src/com/android/tv/dvr/DvrPlayActivity.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.tv.dvr; - -import android.app.Activity; -import android.os.Bundle; -import android.widget.TextView; - -import com.android.tv.R; -import com.android.tv.TvApplication; - -/** - * Simple Activity to play a {@link ScheduledRecording}. - */ -public class DvrPlayActivity extends Activity { - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.dvr_play); - - DvrDataManager dvrDataManager = TvApplication.getSingletons(this).getDvrDataManager(); - // TODO(DVR) handle errors. - long recordingId = getIntent().getLongExtra(ScheduledRecording.RECORDING_ID_EXTRA, 0); - ScheduledRecording scheduledRecording = dvrDataManager.getScheduledRecording(recordingId); - TextView textView = (TextView) findViewById(R.id.placeHolderText); - if (scheduledRecording != null) { - textView.setText(scheduledRecording.toString()); - } else { - textView.setText(R.string.ut_result_not_found_title); // TODO(DVR) update error text - } - } -}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/DvrPlaybackActivity.java b/src/com/android/tv/dvr/DvrPlaybackActivity.java new file mode 100644 index 00000000..3320e0fd --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlaybackActivity.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +import android.app.Activity; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.util.Log; + +import com.android.tv.R; +import com.android.tv.dvr.ui.DvrPlaybackOverlayFragment; + +/** + * Activity to play a {@link RecordedProgram}. + */ +public class DvrPlaybackActivity extends Activity { + private static final String TAG = "DvrPlaybackActivity"; + private static final boolean DEBUG = false; + + private DvrPlaybackOverlayFragment mOverlayFragment; + + @Override + public void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dvr_playback); + mOverlayFragment = (DvrPlaybackOverlayFragment) getFragmentManager() + .findFragmentById(R.id.dvr_playback_controls_fragment); + } + + @Override + public void onVisibleBehindCanceled() { + if (DEBUG) Log.d(TAG, "onVisibleBehindCanceled"); + super.onVisibleBehindCanceled(); + finish(); + } + + @Override + protected void onNewIntent(Intent intent) { + mOverlayFragment.onNewIntent(intent); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + float density = getResources().getDisplayMetrics().density; + mOverlayFragment.onWindowSizeChanged((int) (newConfig.screenWidthDp * density), + (int) (newConfig.screenHeightDp * density)); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java b/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java new file mode 100644 index 00000000..da815712 --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.media.tv.TvContract; +import android.os.AsyncTask; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.util.ImageLoader; +import com.android.tv.util.TimeShiftUtils; + +public class DvrPlaybackMediaSessionHelper { + private static final String TAG = "DvrPlaybackMediaSessionHelper"; + private static final boolean DEBUG = false; + + private int mNowPlayingCardWidth; + private int mNowPlayingCardHeight; + private int mSpeedLevel; + private long mProgramDurationMs; + + private Activity mActivity; + private DvrPlayer mDvrPlayer; + private MediaSession mMediaSession; + private final DvrWatchedPositionManager mDvrWatchedPositionManager; + private final ChannelDataManager mChannelDataManager; + + public DvrPlaybackMediaSessionHelper(Activity activity, + String mediaSessionTag, DvrPlayer dvrPlayer) { + mActivity = activity; + mDvrPlayer = dvrPlayer; + mDvrWatchedPositionManager = + TvApplication.getSingletons(activity).getDvrWatchedPositionManager(); + mChannelDataManager = TvApplication.getSingletons(activity).getChannelDataManager(); + mDvrPlayer.setCallback(new DvrPlayer.DvrPlayerCallback() { + @Override + public void onPlaybackStateChanged(int playbackState, int playbackSpeed) { + updateMediaSessionPlaybackState(); + } + + @Override + public void onPlaybackPositionChanged(long positionMs) { + updateMediaSessionPlaybackState(); + if (mDvrPlayer.isPlaybackPrepared()) { + mDvrWatchedPositionManager + .setWatchedPosition(mDvrPlayer.getProgram().getId(), positionMs); + } + } + }); + initializeMediaSession(mediaSessionTag); + } + + /** + * Stops DVR player and release media session. + */ + public void release() { + if (mDvrPlayer != null) { + mDvrPlayer.reset(); + } + if (mMediaSession != null) { + mMediaSession.release(); + } + } + + /** + * Updates media session's playback state and speed. + */ + public void updateMediaSessionPlaybackState() { + mMediaSession.setPlaybackState(new PlaybackState.Builder() + .setState(mDvrPlayer.getPlaybackState(), mDvrPlayer.getPlaybackPosition(), + mSpeedLevel).build()); + } + + /** + * Sets the recorded program for playback. + * + * @param program The recorded program to play. {@code null} to reset the DVR player. + */ + public void setupPlayback(RecordedProgram program, long seekPositionMs) { + if (program != null) { + mDvrPlayer.setProgram(program, seekPositionMs); + setupMediaSession(program); + } else { + mDvrPlayer.reset(); + mMediaSession.setActive(false); + } + } + + /** + * Returns the recorded program now playing. + */ + public RecordedProgram getProgram() { + return mDvrPlayer.getProgram(); + } + + /** + * Checks if the recorded program is the same as now playing one. + */ + public boolean isCurrentProgram(RecordedProgram program) { + return program == null ? false : program.equals(getProgram()); + } + + /** + * Returns playback state. + */ + public int getPlaybackState() { + return mDvrPlayer.getPlaybackState(); + } + + /** + * Returns the underlying DVR player. + */ + public DvrPlayer getDvrPlayer() { + return mDvrPlayer; + } + + private void initializeMediaSession(String mediaSessionTag) { + mMediaSession = new MediaSession(mActivity, mediaSessionTag); + mMediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS + | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + mNowPlayingCardWidth = mActivity.getResources() + .getDimensionPixelSize(R.dimen.notif_card_img_max_width); + mNowPlayingCardHeight = mActivity.getResources() + .getDimensionPixelSize(R.dimen.notif_card_img_height); + mMediaSession.setCallback(new MediaSessionCallback()); + mActivity.setMediaController( + new MediaController(mActivity, mMediaSession.getSessionToken())); + updateMediaSessionPlaybackState(); + } + + private void setupMediaSession(RecordedProgram program) { + mProgramDurationMs = program.getDurationMillis(); + String cardTitleText = program.getTitle(); + if (TextUtils.isEmpty(cardTitleText)) { + Channel channel = mChannelDataManager.getChannel(program.getChannelId()); + cardTitleText = (channel != null) ? channel.getDisplayName() + : mActivity.getString(R.string.no_program_information); + } + updateMediaMetadata(program.getId(), cardTitleText, program.getDescription(), + mProgramDurationMs, null, 0); + String posterArtUri = program.getPosterArtUri(); + if (posterArtUri == null) { + posterArtUri = TvContract.buildChannelLogoUri(program.getChannelId()).toString(); + } + updatePosterArt(program, cardTitleText, program.getDescription(), + mProgramDurationMs, null, posterArtUri); + mMediaSession.setActive(true); + } + + private void updatePosterArt(RecordedProgram program, String cardTitleText, + String cardSubtitleText, long duration, + @Nullable Bitmap posterArt, @Nullable String posterArtUri) { + if (posterArt != null) { + updateMediaMetadata(program.getId(), cardTitleText, + cardSubtitleText, duration, posterArt, 0); + } else if (posterArtUri != null) { + ImageLoader.loadBitmap(mActivity, posterArtUri, mNowPlayingCardWidth, + mNowPlayingCardHeight, new ProgramPosterArtCallback( + mActivity, program, cardTitleText, cardSubtitleText, duration)); + } else { + updateMediaMetadata(program.getId(), cardTitleText, + cardSubtitleText, duration, null, R.drawable.default_now_card); + } + } + + private class ProgramPosterArtCallback extends + ImageLoader.ImageLoaderCallback<Activity> { + private RecordedProgram mRecordedProgram; + private String mCardTitleText; + private String mCardSubtitleText; + private long mDuration; + + public ProgramPosterArtCallback(Activity activity, RecordedProgram program, + String cardTitleText, String cardSubtitleText, long duration) { + super(activity); + mRecordedProgram = program; + mCardTitleText = cardTitleText; + mCardSubtitleText = cardSubtitleText; + mDuration = duration; + } + + @Override + public void onBitmapLoaded(Activity activity, @Nullable Bitmap posterArt) { + if (isCurrentProgram(mRecordedProgram)) { + updatePosterArt(mRecordedProgram, mCardTitleText, + mCardSubtitleText, mDuration, posterArt, null); + } + } + } + + private void updateMediaMetadata(final long programId, final String title, + final String subtitle, final long duration, + final Bitmap posterArt, final int imageResId) { + new AsyncTask<Void, Void, Void> () { + @Override + protected Void doInBackground(Void... arg0) { + MediaMetadata.Builder builder = new MediaMetadata.Builder(); + builder.putLong(MediaMetadata.METADATA_KEY_MEDIA_ID, programId) + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putLong(MediaMetadata.METADATA_KEY_DURATION, duration); + if (subtitle != null) { + builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle); + } + Bitmap programPosterArt = posterArt; + if (programPosterArt == null && imageResId != 0) { + programPosterArt = + BitmapFactory.decodeResource(mActivity.getResources(), imageResId); + } + if (programPosterArt != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, programPosterArt); + } + mMediaSession.setMetadata(builder.build()); + return null; + } + }.execute(); + } + + // An event was triggered by MediaController.TransportControls and must be handled here. + // Here we update the media itself to act on the event that was triggered. + private class MediaSessionCallback extends MediaSession.Callback { + @Override + public void onPrepare() { + if (!mDvrPlayer.isPlaybackPrepared()) { + mDvrPlayer.prepare(true); + } + } + + @Override + public void onPlay() { + mDvrPlayer.play(); + } + + @Override + public void onPause() { + mDvrPlayer.pause(); + } + + @Override + public void onFastForward() { + if (mDvrPlayer.getPlaybackState() == PlaybackState.STATE_FAST_FORWARDING) { + if (mSpeedLevel < TimeShiftUtils.MAX_SPEED_LEVEL) { + mSpeedLevel++; + } else { + return; + } + } else { + mSpeedLevel = 0; + } + mDvrPlayer.fastForward( + TimeShiftUtils.getPlaybackSpeed(mSpeedLevel, mProgramDurationMs)); + } + + @Override + public void onRewind() { + if (mDvrPlayer.getPlaybackState() == PlaybackState.STATE_REWINDING) { + if (mSpeedLevel < TimeShiftUtils.MAX_SPEED_LEVEL) { + mSpeedLevel++; + } else { + return; + } + } else { + mSpeedLevel = 0; + } + mDvrPlayer.rewind(TimeShiftUtils.getPlaybackSpeed(mSpeedLevel, mProgramDurationMs)); + } + + @Override + public void onSeekTo(long positionMs) { + mDvrPlayer.seekTo(positionMs); + } + } +} diff --git a/src/com/android/tv/dvr/DvrPlayer.java b/src/com/android/tv/dvr/DvrPlayer.java new file mode 100644 index 00000000..027d99f4 --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlayer.java @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +import android.media.PlaybackParams; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputManager; +import android.media.tv.TvTrackInfo; +import android.media.tv.TvView; +import android.media.session.PlaybackState; +import android.util.Log; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class DvrPlayer { + private static final String TAG = "DvrPlayer"; + private static final boolean DEBUG = false; + + /** + * The max rewinding speed supported by DVR player. + */ + public static final int MAX_REWIND_SPEED = 256; + /** + * The max fast-forwarding speed supported by DVR player. + */ + public static final int MAX_FAST_FORWARD_SPEED = 256; + + private static final long SEEK_POSITION_MARGIN_MS = TimeUnit.SECONDS.toMillis(2); + private static final long REWIND_POSITION_MARGIN_MS = 32; // Workaround value. b/29994826 + + private RecordedProgram mProgram; + private long mInitialSeekPositionMs; + private final TvView mTvView; + private DvrPlayerCallback mCallback; + private AspectRatioChangedListener mAspectRatioChangedListener; + private ContentBlockedListener mContentBlockedListener; + private float mAspectRatio = Float.NaN; + private int mPlaybackState = PlaybackState.STATE_NONE; + private long mTimeShiftCurrentPositionMs; + private boolean mPauseOnPrepared; + private final PlaybackParams mPlaybackParams = new PlaybackParams(); + private final DvrPlayerCallback mEmptyCallback = new DvrPlayerCallback(); + + public static class DvrPlayerCallback { + /** + * Called when the playback position is changed. The normal updating frequency is + * around 1 sec., which is restricted to the implementation of + * {@link android.media.tv.TvInputService}. + */ + public void onPlaybackPositionChanged(long positionMs) { } + /** + * Called when the playback state or the playback speed is changed. + */ + public void onPlaybackStateChanged(int playbackState, int playbackSpeed) { } + } + + public interface AspectRatioChangedListener { + /** + * Called when the Video's aspect ratio is changed. + */ + void onAspectRatioChanged(float videoAspectRatio); + } + + public interface ContentBlockedListener { + /** + * Called when the Video's aspect ratio is changed. + */ + void onContentBlocked(TvContentRating rating); + } + + public DvrPlayer(TvView tvView) { + mTvView = tvView; + mPlaybackParams.setSpeed(1.0f); + setTvViewCallbacks(); + setCallback(null); + } + + /** + * Prepares playback. + * + * @param doPlay indicates DVR player do or do not start playback after media is prepared. + */ + public void prepare(boolean doPlay) throws IllegalStateException { + if (DEBUG) Log.d(TAG, "prepare()"); + if (mProgram == null) { + throw new IllegalStateException("Recorded program not set"); + } else if (mPlaybackState != PlaybackState.STATE_NONE) { + throw new IllegalStateException("Playback is already prepared"); + } + mTvView.timeShiftPlay(mProgram.getInputId(), mProgram.getUri()); + mPlaybackState = PlaybackState.STATE_CONNECTING; + mPauseOnPrepared = !doPlay; + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + + /** + * Resumes playback. + */ + public void play() throws IllegalStateException { + if (DEBUG) Log.d(TAG, "play()"); + if (!isPlaybackPrepared()) { + throw new IllegalStateException("Recorded program not set or video not ready yet"); + } + switch (mPlaybackState) { + case PlaybackState.STATE_FAST_FORWARDING: + case PlaybackState.STATE_REWINDING: + setPlaybackSpeed(1); + break; + default: + mTvView.timeShiftResume(); + } + mPlaybackState = PlaybackState.STATE_PLAYING; + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + + /** + * Pauses playback. + */ + public void pause() throws IllegalStateException { + if (DEBUG) Log.d(TAG, "pause()"); + if (!isPlaybackPrepared()) { + throw new IllegalStateException("Recorded program not set or playback not started yet"); + } + switch (mPlaybackState) { + case PlaybackState.STATE_FAST_FORWARDING: + case PlaybackState.STATE_REWINDING: + setPlaybackSpeed(1); + // falls through + case PlaybackState.STATE_PLAYING: + mTvView.timeShiftPause(); + mPlaybackState = PlaybackState.STATE_PAUSED; + break; + default: + break; + } + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + + /** + * Fast-forwards playback with the given speed. If the given speed is larger than + * {@value #MAX_FAST_FORWARD_SPEED}, uses {@value #MAX_FAST_FORWARD_SPEED}. + */ + public void fastForward(int speed) throws IllegalStateException { + if (DEBUG) Log.d(TAG, "fastForward()"); + if (!isPlaybackPrepared()) { + throw new IllegalStateException("Recorded program not set or playback not started yet"); + } + if (speed <= 0) { + throw new IllegalArgumentException("Speed cannot be negative or 0"); + } + if (mTimeShiftCurrentPositionMs >= mProgram.getDurationMillis() - SEEK_POSITION_MARGIN_MS) { + return; + } + speed = Math.min(speed, MAX_FAST_FORWARD_SPEED); + if (DEBUG) Log.d(TAG, "Let's play with speed: " + speed); + setPlaybackSpeed(speed); + mPlaybackState = PlaybackState.STATE_FAST_FORWARDING; + mCallback.onPlaybackStateChanged(mPlaybackState, speed); + } + + /** + * Rewinds playback with the given speed. If the given speed is larger than + * {@value #MAX_REWIND_SPEED}, uses {@value #MAX_REWIND_SPEED}. + */ + public void rewind(int speed) throws IllegalStateException { + if (DEBUG) Log.d(TAG, "rewind()"); + if (!isPlaybackPrepared()) { + throw new IllegalStateException("Recorded program not set or playback not started yet"); + } + if (speed <= 0) { + throw new IllegalArgumentException("Speed cannot be negative or 0"); + } + if (mTimeShiftCurrentPositionMs <= REWIND_POSITION_MARGIN_MS) { + return; + } + speed = Math.min(speed, MAX_REWIND_SPEED); + if (DEBUG) Log.d(TAG, "Let's play with speed: " + speed); + setPlaybackSpeed(-speed); + mPlaybackState = PlaybackState.STATE_REWINDING; + mCallback.onPlaybackStateChanged(mPlaybackState, speed); + } + + /** + * Seeks playback to the specified position. + */ + public void seekTo(long positionMs) throws IllegalStateException { + if (DEBUG) Log.d(TAG, "seekTo()"); + if (!isPlaybackPrepared()) { + throw new IllegalStateException("Recorded program not set or playback not started yet"); + } + if (mProgram == null || mPlaybackState == PlaybackState.STATE_NONE) { + return; + } + positionMs = getRealSeekPosition(positionMs, SEEK_POSITION_MARGIN_MS); + if (DEBUG) Log.d(TAG, "Now: " + getPlaybackPosition() + ", shift to: " + positionMs); + mTvView.timeShiftSeekTo(positionMs); + if (mPlaybackState == PlaybackState.STATE_FAST_FORWARDING || + mPlaybackState == PlaybackState.STATE_REWINDING) { + mPlaybackState = PlaybackState.STATE_PLAYING; + mTvView.timeShiftResume(); + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + } + + /** + * Resets playback. + */ + public void reset() { + if (DEBUG) Log.d(TAG, "reset()"); + mTvView.reset(); + mPlaybackState = PlaybackState.STATE_NONE; + mTimeShiftCurrentPositionMs = 0; + mPlaybackParams.setSpeed(1.0f); + mProgram = null; + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + + /** + * Sets callbacks for playback. + */ + public void setCallback(DvrPlayerCallback callback) { + if (callback != null) { + mCallback = callback; + } else { + mCallback = mEmptyCallback; + } + } + + /** + * Sets listener to aspect ratio changing. + */ + public void setAspectRatioChangedListener(AspectRatioChangedListener listener) { + mAspectRatioChangedListener = listener; + } + + /** + * Sets listener to content blocking. + */ + public void setContentBlockedListener(ContentBlockedListener listener) { + mContentBlockedListener = listener; + } + + /** + * Sets recorded programs for playback. If the player is playing another program, stops it. + */ + public void setProgram(RecordedProgram program, long initialSeekPositionMs) { + if (mProgram != null && mProgram.equals(program)) { + return; + } + if (mPlaybackState != PlaybackState.STATE_NONE) { + reset(); + } + mInitialSeekPositionMs = initialSeekPositionMs; + mProgram = program; + } + + /** + * Returns the recorded program now playing. + */ + public RecordedProgram getProgram() { + return mProgram; + } + + /** + * Returns the currrent playback posistion in msecs. + */ + public long getPlaybackPosition() { + return mTimeShiftCurrentPositionMs; + } + + /** + * Returns the playback speed currently used. + */ + public int getPlaybackSpeed() { + return (int) mPlaybackParams.getSpeed(); + } + + /** + * Returns the playback state defined in {@link android.media.session.PlaybackState}. + */ + public int getPlaybackState() { + return mPlaybackState; + } + + /** + * Returns if playback of the recorded program is started. + */ + public boolean isPlaybackPrepared() { + return mPlaybackState != PlaybackState.STATE_NONE + && mPlaybackState != PlaybackState.STATE_CONNECTING; + } + + private void setPlaybackSpeed(int speed) { + mPlaybackParams.setSpeed(speed); + mTvView.timeShiftSetPlaybackParams(mPlaybackParams); + } + + private long getRealSeekPosition(long seekPositionMs, long endMarginMs) { + return Math.max(0, Math.min(seekPositionMs, mProgram.getDurationMillis() - endMarginMs)); + } + + private void setTvViewCallbacks() { + mTvView.setTimeShiftPositionCallback(new TvView.TimeShiftPositionCallback() { + @Override + public void onTimeShiftCurrentPositionChanged(String inputId, long timeMs) { + // Workaround solution for b/29994826: + // prevents rewinding and fast-forwarding over the ends. + if (mPlaybackState == PlaybackState.STATE_REWINDING + && timeMs <= REWIND_POSITION_MARGIN_MS) { + play(); + } else if (mPlaybackState == PlaybackState.STATE_FAST_FORWARDING + && timeMs >= mProgram.getDurationMillis() - SEEK_POSITION_MARGIN_MS) { + mTvView.timeShiftSeekTo(mProgram.getDurationMillis() - SEEK_POSITION_MARGIN_MS); + pause(); + } + else { + mTimeShiftCurrentPositionMs = getRealSeekPosition(timeMs, 0); + mCallback.onPlaybackPositionChanged(mTimeShiftCurrentPositionMs); + } + } + }); + mTvView.setCallback(new TvView.TvInputCallback() { + @Override + public void onTimeShiftStatusChanged(String inputId, int status) { + if (DEBUG) Log.d(TAG, "onTimeShiftStatusChanged"); + if (status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE + && mPlaybackState == PlaybackState.STATE_CONNECTING) { + if (mInitialSeekPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + mTvView.timeShiftSeekTo(getRealSeekPosition( + mInitialSeekPositionMs, SEEK_POSITION_MARGIN_MS)); + mInitialSeekPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; + } + if (mPauseOnPrepared) { + mTvView.timeShiftPause(); + mPlaybackState = PlaybackState.STATE_PAUSED; + mPauseOnPrepared = false; + } else { + mTvView.timeShiftResume(); + mPlaybackState = PlaybackState.STATE_PLAYING; + } + mCallback.onPlaybackStateChanged(mPlaybackState, 1); + } + } + + @Override + public void onTrackSelected(String inputId, int type, String trackId) { + if (trackId == null || type != TvTrackInfo.TYPE_VIDEO + || mAspectRatioChangedListener == null) { + return; + } + List<TvTrackInfo> trackInfos = mTvView.getTracks(TvTrackInfo.TYPE_VIDEO); + if (trackInfos != null) { + for (TvTrackInfo trackInfo : trackInfos) { + if (trackInfo.getId().equals(trackId)) { + float videoAspectRatio = trackInfo.getVideoPixelAspectRatio() + * trackInfo.getVideoWidth() / trackInfo.getVideoHeight(); + if (DEBUG) Log.d(TAG, "Aspect Ratio: " + videoAspectRatio); + if (!Float.isNaN(videoAspectRatio) + && mAspectRatio != videoAspectRatio) { + mAspectRatioChangedListener + .onAspectRatioChanged(videoAspectRatio); + mAspectRatio = videoAspectRatio; + return; + } + } + } + } + } + + @Override + public void onContentBlocked(String inputId, TvContentRating rating) { + if (mContentBlockedListener != null) { + mContentBlockedListener.onContentBlocked(rating); + } + } + }); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/DvrRecordingService.java b/src/com/android/tv/dvr/DvrRecordingService.java index 2f3abccf..39be7961 100644 --- a/src/com/android/tv/dvr/DvrRecordingService.java +++ b/src/com/android/tv/dvr/DvrRecordingService.java @@ -20,7 +20,6 @@ import android.app.AlarmManager; import android.app.Service; import android.content.Context; import android.content.Intent; -import android.os.Binder; import android.os.HandlerThread; import android.os.IBinder; import android.support.annotation.Nullable; @@ -29,10 +28,10 @@ import android.util.Log; import com.android.tv.ApplicationSingletons; import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.util.Clock; import com.android.tv.util.RecurringRunner; -import com.android.tv.common.SoftPreconditions; /** * DVR Scheduler service. @@ -60,31 +59,19 @@ public class DvrRecordingService extends Service { private final Clock mClock = Clock.SYSTEM; private RecurringRunner mReaperRunner; - private WritableDvrDataManager mDataManager; - - /** - * Class for clients to access. Because we know this service always - * runs in the same process as its clients, we don't need to deal with - * IPC. - */ - public class SchedulerBinder extends Binder { - Scheduler getScheduler() { - return mScheduler; - } - } - - private final IBinder mBinder = new SchedulerBinder(); private Scheduler mScheduler; private HandlerThread mHandlerThread; @Override public void onCreate() { + TvApplication.setCurrentRunningProcess(this, true); if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(); SoftPreconditions.checkFeatureEnabled(this, CommonFeatures.DVR, TAG); ApplicationSingletons singletons = TvApplication.getSingletons(this); - mDataManager = (WritableDvrDataManager) singletons.getDvrDataManager(); + DvrManager dvrManager = singletons.getDvrManager(); + WritableDvrDataManager dataManager = (WritableDvrDataManager) singletons.getDvrDataManager(); AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); // mScheduler may have been set for testing. @@ -92,12 +79,13 @@ public class DvrRecordingService extends Service { mHandlerThread = new HandlerThread(HANDLER_THREAD_NAME); mHandlerThread.start(); mScheduler = new Scheduler(mHandlerThread.getLooper(), singletons.getDvrManager(), - singletons.getDvrSessionManger(), mDataManager, - singletons.getChannelDataManager(), this, mClock, alarmManager); + singletons.getInputSessionManager(), dataManager, + singletons.getChannelDataManager(), singletons.getTvInputManagerHelper(), this, + mClock, alarmManager); + mScheduler.start(); } - mDataManager.addScheduledRecordingListener(mScheduler); mReaperRunner = new RecurringRunner(this, java.util.concurrent.TimeUnit.DAYS.toMillis(1), - new ScheduledProgramReaper(mDataManager, mClock), null); + new ScheduledProgramReaper(dataManager, mClock), null); mReaperRunner.start(); } @@ -112,7 +100,7 @@ public class DvrRecordingService extends Service { public void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy"); mReaperRunner.stop(); - mDataManager.removeScheduledRecordingListener(mScheduler); + mScheduler.stop(); mScheduler = null; if (mHandlerThread != null) { mHandlerThread.quit(); @@ -124,7 +112,7 @@ public class DvrRecordingService extends Service { @Nullable @Override public IBinder onBind(Intent intent) { - return mBinder; + return null; } @VisibleForTesting diff --git a/src/com/android/tv/dvr/DvrScheduleManager.java b/src/com/android/tv/dvr/DvrScheduleManager.java new file mode 100644 index 00000000..aa77c400 --- /dev/null +++ b/src/com/android/tv/dvr/DvrScheduleManager.java @@ -0,0 +1,717 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.tv.TvInputInfo; +import android.os.Build; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.util.ArraySet; +import android.util.Range; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A class to manage the schedules. + */ +@TargetApi(Build.VERSION_CODES.N) +@MainThread +public class DvrScheduleManager { + private static final String TAG = "DvrScheduleManager"; + + /** + * The default priority of scheduled recording. + */ + public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1; + /** + * The default priority of series recording. + */ + public static final long DEFAULT_SERIES_PRIORITY = DEFAULT_PRIORITY >> 1; + // The new priority will have the offset from the existing one. + private static final long PRIORITY_OFFSET = 1024; + + private final Context mContext; + private final DvrDataManagerImpl mDataManager; + private final ChannelDataManager mChannelDataManager; + + private final Map<String, List<ScheduledRecording>> mInputScheduleMap = new HashMap<>(); + private final Map<String, List<ScheduledRecording>> mInputConflictMap = new HashMap<>(); + + private boolean mInitialized; + + private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>(); + private final Set<OnConflictStateChangeListener> mOnConflictStateChangeListeners = + new ArraySet<>(); + + public DvrScheduleManager(Context context) { + mContext = context; + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager(); + mChannelDataManager = appSingletons.getChannelDataManager(); + if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { + buildData(); + } else { + mDataManager.addDvrScheduleLoadFinishedListener( + new OnDvrScheduleLoadFinishedListener() { + @Override + public void onDvrScheduleLoadFinished() { + mDataManager.removeDvrScheduleLoadFinishedListener(this); + if (mChannelDataManager.isDbLoadFinished() && !mInitialized) { + buildData(); + } + } + }); + } + ScheduledRecordingListener scheduledRecordingListener = new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + if (!mInitialized) { + return; + } + for (ScheduledRecording schedule : scheduledRecordings) { + if (!schedule.isNotStarted() && !schedule.isInProgress()) { + continue; + } + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, + schedule.getChannelId()); + if (input == null) { + // Input removed. + continue; + } + String inputId = input.getId(); + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules == null) { + schedules = new ArrayList<>(); + mInputScheduleMap.put(inputId, schedules); + } + schedules.add(schedule); + } + onSchedulesChanged(); + notifyScheduledRecordingAdded(scheduledRecordings); + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + if (!mInitialized) { + return; + } + for (ScheduledRecording schedule : scheduledRecordings) { + TvInputInfo input = Utils + .getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + if (input == null) { + // Input removed. + continue; + } + String inputId = input.getId(); + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules != null) { + schedules.remove(schedule); + if (schedules.isEmpty()) { + mInputScheduleMap.remove(inputId); + } + } + } + onSchedulesChanged(); + notifyScheduledRecordingRemoved(scheduledRecordings); + } + + @Override + public void onScheduledRecordingStatusChanged( + ScheduledRecording... scheduledRecordings) { + if (!mInitialized) { + return; + } + for (ScheduledRecording schedule : scheduledRecordings) { + TvInputInfo input = Utils + .getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + if (input == null) { + // Input removed. + continue; + } + String inputId = input.getId(); + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules == null) { + schedules = new ArrayList<>(); + mInputScheduleMap.put(inputId, schedules); + } + // Compare ID because ScheduledRecording.equals() doesn't work if the state + // is changed. + Iterator<ScheduledRecording> i = schedules.iterator(); + while (i.hasNext()) { + if (i.next().getId() == schedule.getId()) { + i.remove(); + break; + } + } + if (schedule.isNotStarted() || schedule.isInProgress()) { + schedules.add(schedule); + } + if (schedules.isEmpty()) { + mInputScheduleMap.remove(inputId); + } + } + onSchedulesChanged(); + notifyScheduledRecordingStatusChanged(scheduledRecordings); + } + }; + mDataManager.addScheduledRecordingListener(scheduledRecordingListener); + ChannelDataManager.Listener channelDataManagerListener = new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + if (mDataManager.isDvrScheduleLoadFinished() && !mInitialized) { + buildData(); + } + } + + @Override + public void onChannelListUpdated() { + if (mDataManager.isDvrScheduleLoadFinished()) { + buildData(); + } + } + + @Override + public void onChannelBrowsableChanged() { + } + }; + mChannelDataManager.addListener(channelDataManagerListener); + } + + /** + * Returns the started recordings for the given input. + */ + private List<ScheduledRecording> getStartedRecordings(String inputId) { + if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { + return Collections.emptyList(); + } + List<ScheduledRecording> result = new ArrayList<>(); + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules != null) { + for (ScheduledRecording schedule : schedules) { + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + result.add(schedule); + } + } + } + return result; + } + + private void buildData() { + mInputScheduleMap.clear(); + for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { + if (!schedule.isNotStarted() && !schedule.isInProgress()) { + continue; + } + Channel channel = mChannelDataManager.getChannel(schedule.getChannelId()); + if (channel != null) { + String inputId = channel.getInputId(); + // Do not check whether the input is valid or not. The input might be temporarily + // invalid. + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules == null) { + schedules = new ArrayList<>(); + mInputScheduleMap.put(inputId, schedules); + } + schedules.add(schedule); + } + } + mInitialized = true; + onSchedulesChanged(); + } + + private void onSchedulesChanged() { + List<ScheduledRecording> addedConflicts = new ArrayList<>(); + List<ScheduledRecording> removedConflicts = new ArrayList<>(); + for (String inputId : mInputScheduleMap.keySet()) { + List<ScheduledRecording> oldConflicts = mInputConflictMap.get(inputId); + Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>(); + if (oldConflicts != null) { + for (ScheduledRecording r : oldConflicts) { + oldConflictMap.put(r.getId(), r); + } + } + List<ScheduledRecording> conflicts = getConflictingSchedules(inputId); + for (ScheduledRecording r : conflicts) { + if (oldConflictMap.remove(r.getId()) == null) { + addedConflicts.add(r); + } + } + removedConflicts.addAll(oldConflictMap.values()); + if (conflicts.isEmpty()) { + mInputConflictMap.remove(inputId); + } else { + mInputConflictMap.put(inputId, conflicts); + } + } + if (!removedConflicts.isEmpty()) { + notifyConflictStateChange(false, ScheduledRecording.toArray(removedConflicts)); + } + if (!addedConflicts.isEmpty()) { + notifyConflictStateChange(true, ScheduledRecording.toArray(addedConflicts)); + } + } + + /** + * Returns {@code true} if this class has been initialized. + */ + public boolean isInitialized() { + return mInitialized; + } + + /** + * Adds a {@link ScheduledRecordingListener}. + */ + public final void addScheduledRecordingListener(ScheduledRecordingListener listener) { + mScheduledRecordingListeners.add(listener); + } + + /** + * Removes a {@link ScheduledRecordingListener}. + */ + public final void removeScheduledRecordingListener(ScheduledRecordingListener listener) { + mScheduledRecordingListeners.remove(listener); + } + + /** + * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} for each listener. + */ + private void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecordingListener l : mScheduledRecordingListeners) { + l.onScheduledRecordingAdded(scheduledRecordings); + } + } + + /** + * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} for each listener. + */ + private void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecordingListener l : mScheduledRecordingListeners) { + l.onScheduledRecordingRemoved(scheduledRecordings); + } + } + + /** + * Calls {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} for each listener. + */ + private void notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecordingListener l : mScheduledRecordingListeners) { + l.onScheduledRecordingStatusChanged(scheduledRecordings); + } + } + + /** + * Adds a {@link OnConflictStateChangeListener}. + */ + public final void addOnConflictStateChangeListener(OnConflictStateChangeListener listener) { + mOnConflictStateChangeListeners.add(listener); + } + + /** + * Removes a {@link OnConflictStateChangeListener}. + */ + public final void removeOnConflictStateChangeListener(OnConflictStateChangeListener listener) { + mOnConflictStateChangeListeners.remove(listener); + } + + /** + * Calls {@link OnConflictStateChangeListener#onConflictStateChange} for each listener. + */ + private void notifyConflictStateChange(boolean conflict, + ScheduledRecording... scheduledRecordings) { + for (OnConflictStateChangeListener l : mOnConflictStateChangeListeners) { + l.onConflictStateChange(conflict, scheduledRecordings); + } + } + + /** + * Returns the priority for the program if it is recorded. + * <p> + * The recording will have the higher priority than the existing ones. + */ + public long suggestNewPriority() { + if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { + return DEFAULT_PRIORITY; + } + return suggestHighestPriority(); + } + + private long suggestHighestPriority() { + long highestPriority = DEFAULT_PRIORITY - PRIORITY_OFFSET; + for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) { + if (schedule.getPriority() > highestPriority) { + highestPriority = schedule.getPriority(); + } + } + return highestPriority + PRIORITY_OFFSET; + } + + /** + * Returns the priority for a series recording. + * <p> + * The recording will have the higher priority than the existing series. + */ + public long suggestNewSeriesPriority() { + if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) { + return DEFAULT_SERIES_PRIORITY; + } + return suggestHighestSeriesPriority(); + } + + /** + * Returns the priority for a series recording by order of series recording priority. + * + * Higher order will have higher priority. + */ + public static long suggestSeriesPriority(int order) { + return DEFAULT_SERIES_PRIORITY + order * PRIORITY_OFFSET; + } + + private long suggestHighestSeriesPriority() { + long highestPriority = DEFAULT_SERIES_PRIORITY - PRIORITY_OFFSET; + for (SeriesRecording schedule : mDataManager.getSeriesRecordings()) { + if (schedule.getPriority() > highestPriority) { + highestPriority = schedule.getPriority(); + } + } + return highestPriority + PRIORITY_OFFSET; + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if + * this program is. + * <p> + * Any empty list means there is no conflicts. If there is conflict the program must be + * scheduled to record with a priority higher than the first recording in the list returned. + */ + public List<ScheduledRecording> getConflictingSchedules(Program program) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + SoftPreconditions.checkState(Program.isValid(program), TAG, + "Program is invalid: " + program); + SoftPreconditions.checkState( + program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(), TAG, + "Program duration is empty: " + program); + if (!mInitialized || !Program.isValid(program) + || program.getStartTimeUtcMillis() >= program.getEndTimeUtcMillis()) { + return Collections.emptyList(); + } + TvInputInfo input = Utils.getTvInputInfoForProgram(mContext, program); + if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { + return Collections.emptyList(); + } + return getConflictingSchedules(input, Collections.singletonList( + ScheduledRecording.builder(input.getId(), program) + .setPriority(suggestHighestPriority()) + .build())); + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if + * this channel is. + * <p> + * Any empty list means there is no conflicts. If there is conflict the channel must be + * scheduled to record with a priority higher than the first recording in the list returned. + */ + public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs, + long endTimeMs) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); + SoftPreconditions.checkState(startTimeMs < endTimeMs, TAG, "Recording duration is empty."); + if (!mInitialized || channelId == Channel.INVALID_ID || startTimeMs >= endTimeMs) { + return Collections.emptyList(); + } + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); + if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { + return Collections.emptyList(); + } + return getConflictingSchedules(input, Collections.singletonList( + ScheduledRecording.builder(input.getId(), channelId, startTimeMs, endTimeMs) + .setPriority(suggestHighestPriority()) + .build())); + } + + /** + * Returns all the scheduled recordings that conflicts and will not be recorded or clipped for + * the given input. + */ + @NonNull + private List<ScheduledRecording> getConflictingSchedules(String inputId) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for : " + inputId); + if (!mInitialized || input == null) { + return Collections.emptyList(); + } + List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); + if (schedules == null || schedules.isEmpty()) { + return Collections.emptyList(); + } + return getConflictingSchedules(schedules, input.getTunerCount()); + } + + /** + * Checks if the schedule is conflicting. + * + * <p>Note that the {@code schedule} should be the existing one. If not, this returns + * {@code false}. + */ + public boolean isConflicting(ScheduledRecording schedule) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " + + schedule.getChannelId()); + if (!mInitialized || input == null) { + return false; + } + List<ScheduledRecording> conflicts = mInputConflictMap.get(input.getId()); + return conflicts != null && conflicts.contains(schedule); + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if + * this channel is tuned to. + */ + public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " + + channelId); + if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { + return Collections.emptyList(); + } + return getConflictingSchedulesForTune(input.getId(), channelId, System.currentTimeMillis(), + suggestHighestPriority(), getStartedRecordings(input.getId()), + input.getTunerCount()); + } + + @VisibleForTesting + public static List<ScheduledRecording> getConflictingSchedulesForTune(String inputId, + long channelId, long currentTimeMs, long newPriority, + List<ScheduledRecording> startedRecordings, int tunerCount) { + boolean channelFound = false; + for (ScheduledRecording schedule : startedRecordings) { + if (schedule.getChannelId() == channelId) { + channelFound = true; + break; + } + } + List<ScheduledRecording> schedules; + if (!channelFound) { + // The current channel is not being recorded. + schedules = new ArrayList<>(startedRecordings); + schedules.add(ScheduledRecording + .builder(inputId, channelId, currentTimeMs, currentTimeMs + 1) + .setPriority(newPriority) + .build()); + } else { + schedules = startedRecordings; + } + return getConflictingSchedules(schedules, tunerCount); + } + + /** + * Returns priority ordered list of all scheduled recordings that will not be recorded if + * the user keeps watching this channel. + * <p> + * Note that if the user keeps watching the channel, the channel can be recorded. + */ + public List<ScheduledRecording> getConflictingSchedulesForWatching(long channelId) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID"); + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: " + + channelId); + if (!mInitialized || channelId == Channel.INVALID_ID || input == null) { + return Collections.emptyList(); + } + List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); + if (schedules == null || schedules.isEmpty()) { + return Collections.emptyList(); + } + return getConflictingSchedulesForWatching(input.getId(), channelId, + System.currentTimeMillis(), suggestNewPriority(), schedules, input.getTunerCount()); + } + + private List<ScheduledRecording> getConflictingSchedules(TvInputInfo input, + List<ScheduledRecording> schedulesToAdd) { + SoftPreconditions.checkNotNull(input); + if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { + return Collections.emptyList(); + } + List<ScheduledRecording> currentSchedules = mInputScheduleMap.get(input.getId()); + if (currentSchedules == null || currentSchedules.isEmpty()) { + return Collections.emptyList(); + } + return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount()); + } + + @VisibleForTesting + static List<ScheduledRecording> getConflictingSchedulesForWatching(String inputId, + long channelId, long currentTimeMs, long newPriority, + @NonNull List<ScheduledRecording> schedules, int tunerCount) { + List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules); + List<ScheduledRecording> schedulesSameChannel = new ArrayList<>(); + for (ScheduledRecording schedule : schedules) { + if (schedule.getChannelId() == channelId) { + schedulesSameChannel.add(schedule); + schedulesToCheck.remove(schedule); + } + } + // Assume that the user will watch the current channel forever. + schedulesToCheck.add(ScheduledRecording + .builder(inputId, channelId, currentTimeMs, Long.MAX_VALUE) + .setPriority(newPriority) + .build()); + List<ScheduledRecording> result = new ArrayList<>(); + result.addAll(getConflictingSchedules(schedulesSameChannel, 1)); + result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount)); + Collections.sort(result, ScheduledRecording.PRIORITY_COMPARATOR); + return result; + } + + @VisibleForTesting + static List<ScheduledRecording> getConflictingSchedules(List<ScheduledRecording> schedulesToAdd, + List<ScheduledRecording> currentSchedules, int tunerCount) { + List<ScheduledRecording> schedulesToCheck = new ArrayList<>(currentSchedules); + // When the duplicate schedule is to be added, remove the current duplicate recording. + for (Iterator<ScheduledRecording> iter = schedulesToCheck.iterator(); iter.hasNext(); ) { + ScheduledRecording schedule = iter.next(); + for (ScheduledRecording toAdd : schedulesToAdd) { + if (schedule.getType() == ScheduledRecording.TYPE_PROGRAM) { + if (toAdd.getProgramId() == schedule.getProgramId()) { + iter.remove(); + break; + } + } else { + if (toAdd.getChannelId() == schedule.getChannelId() + && toAdd.getStartTimeMs() == schedule.getStartTimeMs() + && toAdd.getEndTimeMs() == schedule.getEndTimeMs()) { + iter.remove(); + break; + } + } + } + } + schedulesToCheck.addAll(schedulesToAdd); + List<Range<Long>> ranges = new ArrayList<>(); + for (ScheduledRecording schedule : schedulesToAdd) { + ranges.add(new Range<>(schedule.getStartTimeMs(), schedule.getEndTimeMs())); + } + return getConflictingSchedules(schedulesToCheck, tunerCount, ranges); + } + + /** + * Returns all conflicting scheduled recordings for the given schedules and count of tuner. + */ + public static List<ScheduledRecording> getConflictingSchedules( + List<ScheduledRecording> schedules, int tunerCount) { + return getConflictingSchedules(schedules, tunerCount, + Collections.singletonList(new Range<>(Long.MIN_VALUE, Long.MAX_VALUE))); + } + + @VisibleForTesting + static List<ScheduledRecording> getConflictingSchedules(List<ScheduledRecording> schedules, + int tunerCount, List<Range<Long>> periods) { + List<ScheduledRecording> schedulesToCheck = new ArrayList<>(); + // Filter out non-overlapping or empty duration of schedules. + for (ScheduledRecording schedule : schedules) { + for (Range<Long> period : periods) { + if (schedule.isOverLapping(period) + && schedule.getStartTimeMs() < schedule.getEndTimeMs()) { + schedulesToCheck.add(schedule); + break; + } + } + } + // Sort by the end time. + // If a.end <= b.end <= c.end and a overlaps with b and c, then b overlaps with c. + // Likewise, if a1.end <= a2.end <= ... , all the schedules which overlap with a1 overlap + // with each other. + Collections.sort(schedulesToCheck, ScheduledRecording.END_TIME_COMPARATOR); + Set<ScheduledRecording> conflicts = new ArraySet<>(); + List<ScheduledRecording> overlaps = new ArrayList<>(); + for (int i = 0; i < schedulesToCheck.size(); ++i) { + ScheduledRecording r1 = schedulesToCheck.get(i); + if (conflicts.contains(r1)) { + // No need to check r1 because it's a conflicting schedule already. + continue; + } + overlaps.clear(); + overlaps.add(r1); + // Find schedules which overlap with r1. + for (int j = i + 1; j < schedulesToCheck.size(); ++j) { + ScheduledRecording r2 = schedulesToCheck.get(j); + if (!conflicts.contains(r2) && r1.getEndTimeMs() > r2.getStartTimeMs()) { + overlaps.add(r2); + } + } + Collections.sort(overlaps, ScheduledRecording.PRIORITY_COMPARATOR); + // If there are more than one overlapping schedules for the same channel, only one + // schedule will be recorded. + HashSet<Long> channelIds = new HashSet<>(); + for (Iterator<ScheduledRecording> iter = overlaps.iterator(); iter.hasNext(); ) { + ScheduledRecording schedule = iter.next(); + if (channelIds.contains(schedule.getChannelId())) { + conflicts.add(schedule); + iter.remove(); + } else { + channelIds.add(schedule.getChannelId()); + } + } + if (overlaps.size() > tunerCount) { + conflicts.addAll(overlaps.subList(tunerCount, overlaps.size())); + } + } + List<ScheduledRecording> result = new ArrayList<>(conflicts); + Collections.sort(result, ScheduledRecording.PRIORITY_COMPARATOR); + return result; + } + + /** + * A listener which is notified the conflict state change of the schedules. + */ + public interface OnConflictStateChangeListener { + /** + * Called when the conflicting schedules change. + * + * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise + * {@code false}. + * @param schedules the schedules + */ + void onConflictStateChange(boolean conflict, ScheduledRecording... schedules); + } +} diff --git a/src/com/android/tv/dvr/DvrSessionManager.java b/src/com/android/tv/dvr/DvrSessionManager.java deleted file mode 100644 index fba05cb6..00000000 --- a/src/com/android/tv/dvr/DvrSessionManager.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.tv.dvr; - -import android.annotation.TargetApi; -import android.content.Context; -import android.media.tv.TvInputInfo; -import android.media.tv.TvInputManager; -import android.media.tv.TvRecordingClient; -import android.os.Build; -import android.os.Handler; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; -import android.support.v4.util.ArrayMap; -import android.util.Log; - -import com.android.tv.common.SoftPreconditions; -import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.data.Channel; - -/** - * Manages Dvr Sessions. - * Responsible for: - * <ul> - * <li>Manage DvrSession</li> - * <li>Manage capabilities (conflict)</li> - * </ul> - */ -@TargetApi(Build.VERSION_CODES.N) -public class DvrSessionManager extends TvInputManager.TvInputCallback { - //consider moving all of this to TvInputManagerHelper - private final static String TAG = "DvrSessionManager"; - private static final boolean DEBUG = false; - - private final Context mContext; - private final TvInputManager mTvInputManager; - private final ArrayMap<String, TvInputInfo> mRecordingTvInputs = new ArrayMap<>(); - - public DvrSessionManager(Context context) { - this(context, (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE), - new Handler()); - } - - @VisibleForTesting - DvrSessionManager(Context context, TvInputManager tvInputManager, Handler handler) { - SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG); - mTvInputManager = tvInputManager; - mContext = context.getApplicationContext(); - for (TvInputInfo info : tvInputManager.getTvInputList()) { - if (DEBUG) { - Log.d(TAG, info + " canRecord=" + info.canRecord() + " tunerCount=" + info - .getTunerCount()); - } - if (info.canRecord()) { - mRecordingTvInputs.put(info.getId(), info); - } - } - tvInputManager.registerCallback(this, handler); - - } - - public TvRecordingClient createTvRecordingClient(String tag, - TvRecordingClient.RecordingCallback callback, Handler handler) { - return new TvRecordingClient(mContext, tag, callback, handler); - } - - public boolean canAcquireDvrSession(String inputId, Channel channel) { - // TODO(DVR): implement checking tuner count etc. - TvInputInfo info = mRecordingTvInputs.get(inputId); - return info != null; - } - - public void releaseTvRecordingClient(TvRecordingClient recordingClient) { - recordingClient.release(); - } - - @Override - public void onInputAdded(String inputId) { - super.onInputAdded(inputId); - TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); - if (DEBUG) { - Log.d(TAG, "onInputAdded " + info.toString() + " canRecord=" + info.canRecord() - + " tunerCount=" + info.getTunerCount()); - } - if (info.canRecord()) { - mRecordingTvInputs.put(inputId, info); - } - } - - @Override - public void onInputRemoved(String inputId) { - super.onInputRemoved(inputId); - if (DEBUG) Log.d(TAG, "onInputRemoved " + inputId); - mRecordingTvInputs.remove(inputId); - } - - @Override - public void onInputUpdated(String inputId) { - super.onInputUpdated(inputId); - TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); - if (DEBUG) { - Log.d(TAG, "onInputUpdated " + info.toString() + " canRecord=" + info.canRecord() - + " tunerCount=" + info.getTunerCount()); - } - if (info.canRecord()) { - mRecordingTvInputs.put(inputId, info); - } else { - mRecordingTvInputs.remove(inputId); - } - } - - @Nullable - public TvInputInfo getTvInputInfo(String inputId) { - return mRecordingTvInputs.get(inputId); - } -} diff --git a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java b/src/com/android/tv/dvr/DvrStartRecordingReceiver.java index 3649ad1e..6d2f0d43 100644 --- a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java +++ b/src/com/android/tv/dvr/DvrStartRecordingReceiver.java @@ -16,6 +16,8 @@ package com.android.tv.dvr; +import com.android.tv.TvApplication; + import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -26,6 +28,7 @@ import android.content.Intent; public class DvrStartRecordingReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + TvApplication.setCurrentRunningProcess(context, true); DvrRecordingService.startService(context); } } diff --git a/src/com/android/tv/dvr/DvrUiHelper.java b/src/com/android/tv/dvr/DvrUiHelper.java new file mode 100644 index 00000000..be934fd4 --- /dev/null +++ b/src/com/android/tv/dvr/DvrUiHelper.java @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.tv.TvInputManager; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.v4.app.ActivityOptionsCompat; +import android.text.TextUtils; +import android.widget.ImageView; +import android.widget.Toast; + +import com.android.tv.MainActivity; +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.data.Program; +import com.android.tv.dvr.ui.DvrCancelAllSeriesRecordingDialogFragment; +import com.android.tv.dvr.ui.DvrDetailsActivity; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrAlreadyRecordedDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrAlreadyScheduledDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrChannelRecordDurationOptionDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrChannelWatchConflictDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrInsufficientSpaceErrorDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrProgramConflictDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrScheduleDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrStopRecordingDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrMissingStorageErrorDialogFragment; +import com.android.tv.dvr.ui.DvrSchedulesActivity; +import com.android.tv.dvr.ui.DvrSeriesDeletionActivity; +import com.android.tv.dvr.ui.DvrSeriesSettingsActivity; +import com.android.tv.dvr.ui.list.DvrSchedulesFragment; +import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment; +import com.android.tv.util.Utils; + +import java.util.Collections; +import java.util.List; + +/** + * A helper class for DVR UI. + */ +@MainThread +@TargetApi(Build.VERSION_CODES.N) +public class DvrUiHelper { + /** + * Handles the action to create the new schedule. It returns {@code true} if the schedule is + * added and there's no additional UI, otherwise {@code false}. + */ + public static boolean handleCreateSchedule(MainActivity activity, Program program) { + if (program == null) { + return false; + } + DvrManager dvrManager = TvApplication.getSingletons(activity).getDvrManager(); + if (!program.isEpisodic()) { + // One time recording. + dvrManager.addSchedule(program); + if (!dvrManager.getConflictingSchedules(program).isEmpty()) { + DvrUiHelper.showScheduleConflictDialog(activity, program); + return false; + } + } else { + SeriesRecording seriesRecording = dvrManager.getSeriesRecording(program); + if (seriesRecording == null) { + DvrUiHelper.showScheduleDialog(activity, program); + return false; + } else { + // Show recorded program rather than the schedule. + RecordedProgram recordedProgram = dvrManager.getRecordedProgram(program.getTitle(), + program.getSeasonNumber(), program.getEpisodeNumber()); + if (recordedProgram != null) { + DvrUiHelper.showAlreadyRecordedDialog(activity, program); + return false; + } + ScheduledRecording duplicate = dvrManager.getScheduledRecording(program.getTitle(), + program.getSeasonNumber(), program.getEpisodeNumber()); + if (duplicate != null + && (duplicate.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || duplicate.getState() + == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) { + DvrUiHelper.showAlreadyScheduleDialog(activity, program); + return false; + } + // Just add the schedule. + dvrManager.addSchedule(program); + } + } + return true; + + } + + /** + * Shows the schedule dialog. + */ + public static void showScheduleDialog(MainActivity activity, Program program) { + if (SoftPreconditions.checkNotNull(program) == null) { + return; + } + Bundle args = new Bundle(); + args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program); + showDialogFragment(activity, new DvrScheduleDialogFragment(), args, true, true); + } + + /** + * Shows the recording duration options dialog. + */ + public static void showChannelRecordDurationOptions(MainActivity activity, Channel channel) { + if (SoftPreconditions.checkNotNull(channel) == null) { + return; + } + Bundle args = new Bundle(); + args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channel.getId()); + showDialogFragment(activity, new DvrChannelRecordDurationOptionDialogFragment(), args); + } + + /** + * Shows the dialog which says that the new schedule conflicts with others. + */ + public static void showScheduleConflictDialog(MainActivity activity, Program program) { + if (program == null) { + return; + } + Bundle args = new Bundle(); + args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program); + showDialogFragment(activity, new DvrProgramConflictDialogFragment(), args, false, true); + } + + /** + * Shows the conflict dialog for the channel watching. + */ + public static void showChannelWatchConflictDialog(MainActivity activity, Channel channel) { + if (channel == null) { + return; + } + Bundle args = new Bundle(); + args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channel.getId()); + showDialogFragment(activity, new DvrChannelWatchConflictDialogFragment(), args); + } + + /** + * Shows DVR insufficient space error dialog. + */ + public static void showDvrInsufficientSpaceErrorDialog(MainActivity activity) { + showDialogFragment(activity, new DvrInsufficientSpaceErrorDialogFragment(), null); + Utils.clearRecordingFailedReason(activity, + TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + } + + /** + * Shows DVR missing storage error dialog. + */ + public static void showDvrMissingStorageErrorDialog(Activity activity, String inputId) { + SoftPreconditions.checkArgument(!TextUtils.isEmpty(inputId)); + Bundle args = new Bundle(); + args.putString(DvrHalfSizedDialogFragment.KEY_INPUT_ID, inputId); + showDialogFragment(activity, new DvrMissingStorageErrorDialogFragment(), args); + } + + /** + * Shows stop recording dialog. + */ + public static void showStopRecordingDialog(MainActivity activity, Channel channel) { + if (channel == null) { + return; + } + Bundle args = new Bundle(); + args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channel.getId()); + showDialogFragment(activity, new DvrStopRecordingDialogFragment(), args); + } + + /** + * Shows "already scheduled" dialog. + */ + public static void showAlreadyScheduleDialog(MainActivity activity, Program program) { + if (program == null) { + return; + } + Bundle args = new Bundle(); + args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program); + showDialogFragment(activity, new DvrAlreadyScheduledDialogFragment(), args, false, true); + } + + /** + * Shows "already recorded" dialog. + */ + public static void showAlreadyRecordedDialog(MainActivity activity, Program program) { + if (program == null) { + return; + } + Bundle args = new Bundle(); + args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program); + showDialogFragment(activity, new DvrAlreadyRecordedDialogFragment(), args, false, true); + } + + private static void showDialogFragment(Activity activity, + DvrHalfSizedDialogFragment dialogFragment, Bundle args) { + showDialogFragment(activity, dialogFragment, args, false, false); + } + + private static void showDialogFragment(Activity activity, + DvrHalfSizedDialogFragment dialogFragment, Bundle args, boolean keepSidePanelHistory, + boolean keepProgramGuide) { + dialogFragment.setArguments(args); + if (activity instanceof MainActivity) { + ((MainActivity) activity).getOverlayManager() + .showDialogFragment(DvrHalfSizedDialogFragment.DIALOG_TAG, dialogFragment, + keepSidePanelHistory, keepProgramGuide); + } else { + dialogFragment.show(activity.getFragmentManager(), + DvrHalfSizedDialogFragment.DIALOG_TAG); + } + } + + /** + * Checks whether channel watch conflict dialog is open or not. + */ + public static boolean isChannelWatchConflictDialogShown(MainActivity activity) { + return activity.getOverlayManager().getCurrentDialog() instanceof + DvrChannelWatchConflictDialogFragment; + } + + private static ScheduledRecording getEarliestScheduledRecording(List<ScheduledRecording> + recordings) { + ScheduledRecording earlistScheduledRecording = null; + if (!recordings.isEmpty()) { + Collections.sort(recordings, ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR); + earlistScheduledRecording = recordings.get(0); + } + return earlistScheduledRecording; + } + + /** + * Shows the schedules activity to resolve the tune conflict. + */ + public static void startSchedulesActivityForTuneConflict(Context context, Channel channel) { + if (channel == null) { + return; + } + List<ScheduledRecording> conflicts = TvApplication.getSingletons(context).getDvrManager() + .getConflictingSchedulesForTune(channel.getId()); + startSchedulesActivity(context, getEarliestScheduledRecording(conflicts)); + } + + /** + * Shows the schedules activity to resolve the one time recording conflict. + */ + public static void startSchedulesActivityForOneTimeRecordingConflict(Context context, + List<ScheduledRecording> conflicts) { + startSchedulesActivity(context, getEarliestScheduledRecording(conflicts)); + } + + /** + * Shows the schedules activity with full schedule. + */ + public static void startSchedulesActivity(Context context, ScheduledRecording + focusedScheduledRecording) { + Intent intent = new Intent(context, DvrSchedulesActivity.class); + intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE, + DvrSchedulesActivity.TYPE_FULL_SCHEDULE); + if (focusedScheduledRecording != null) { + intent.putExtra(DvrSchedulesFragment.SCHEDULES_KEY_SCHEDULED_RECORDING, + focusedScheduledRecording); + } + context.startActivity(intent); + } + + /** + * Shows the schedules activity for series recording. + */ + public static void startSchedulesActivityForSeries(Context context, + SeriesRecording seriesRecording) { + Intent intent = new Intent(context, DvrSchedulesActivity.class); + intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE, + DvrSchedulesActivity.TYPE_SERIES_SCHEDULE); + intent.putExtra(DvrSeriesSchedulesFragment.SERIES_SCHEDULES_KEY_SERIES_RECORDING, + seriesRecording); + context.startActivity(intent); + } + + /** + * Shows the series settings activity. + */ + public static void startSeriesSettingsActivity(Context context, long seriesRecordingId) { + Intent intent = new Intent(context, DvrSeriesSettingsActivity.class); + intent.putExtra(DvrSeriesSettingsActivity.SERIES_RECORDING_ID, seriesRecordingId); + context.startActivity(intent); + } + + /** + * Shows the details activity for the schedule. + */ + public static void startDetailsActivity(Activity activity, ScheduledRecording schedule, + @Nullable ImageView imageView, boolean hideViewSchedule) { + if (schedule == null) { + return; + } + int viewType; + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + viewType = DvrDetailsActivity.SCHEDULED_RECORDING_VIEW; + } else if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + viewType = DvrDetailsActivity.CURRENT_RECORDING_VIEW; + } else { + return; + } + Intent intent = new Intent(activity, DvrDetailsActivity.class); + intent.putExtra(DvrDetailsActivity.DETAILS_VIEW_TYPE, viewType); + intent.putExtra(DvrDetailsActivity.RECORDING_ID, schedule.getId()); + intent.putExtra(DvrDetailsActivity.HIDE_VIEW_SCHEDULE, hideViewSchedule); + Bundle bundle = null; + if (imageView != null) { + bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageView, + DvrDetailsActivity.SHARED_ELEMENT_NAME).toBundle(); + } + activity.startActivity(intent, bundle); + } + + /** + * Shows the details activity for the recorded program. + */ + public static void startDetailsActivity(Activity activity, RecordedProgram recordedProgram, + @Nullable ImageView imageView) { + Intent intent = new Intent(activity, DvrDetailsActivity.class); + intent.putExtra(DvrDetailsActivity.RECORDING_ID, recordedProgram.getId()); + intent.putExtra(DvrDetailsActivity.DETAILS_VIEW_TYPE, + DvrDetailsActivity.RECORDED_PROGRAM_VIEW); + Bundle bundle = null; + if (imageView != null) { + bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageView, + DvrDetailsActivity.SHARED_ELEMENT_NAME).toBundle(); + } + activity.startActivity(intent, bundle); + } + + /** + * Shows the cancel all dialog for series schedules list. + */ + public static void showCancelAllSeriesRecordingDialog(DvrSchedulesActivity activity) { + DvrCancelAllSeriesRecordingDialogFragment dvrCancelAllSeriesRecordingDialogFragment = + new DvrCancelAllSeriesRecordingDialogFragment(); + dvrCancelAllSeriesRecordingDialogFragment.show(activity.getFragmentManager(), + DvrCancelAllSeriesRecordingDialogFragment.DIALOG_TAG); + } + + /** + * Shows the series deletion activity. + */ + public static void startSeriesDeletionActivity(Context context, long seriesRecordingId) { + Intent intent = new Intent(context, DvrSeriesDeletionActivity.class); + intent.putExtra(DvrSeriesDeletionActivity.SERIES_RECORDING_ID, seriesRecordingId); + context.startActivity(intent); + } + + public static void showAddScheduleToast(Context context, + String title, long startTimeMs, long endTimeMs) { + String msg = (startTimeMs > System.currentTimeMillis()) ? + context.getString(R.string.dvr_msg_program_scheduled, title) + : context.getString(R.string.dvr_msg_current_program_scheduled, title, + Utils.toTimeString(endTimeMs, false)); + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); + } +} diff --git a/src/com/android/tv/dvr/DvrWatchedPositionManager.java b/src/com/android/tv/dvr/DvrWatchedPositionManager.java new file mode 100644 index 00000000..cb723f83 --- /dev/null +++ b/src/com/android/tv/dvr/DvrWatchedPositionManager.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +import android.content.Context; +import android.content.SharedPreferences; +import android.media.tv.TvInputManager; + +import com.android.tv.common.SharedPreferencesUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * A class to manage DVR watched state. + * It will remember and provides previous watched position of DVR playback. + */ +public class DvrWatchedPositionManager { + private final static String TAG = "DvrWatchedPositionManager"; + private final boolean DEBUG = false; + + private SharedPreferences mWatchedPositions; + private final Context mContext; + private final Map<Long, Set> mListeners = new HashMap<>(); + + public DvrWatchedPositionManager(Context context) { + mContext = context.getApplicationContext(); + mWatchedPositions = mContext.getSharedPreferences(SharedPreferencesUtils + .SHARED_PREF_DVR_WATCHED_POSITION, Context.MODE_PRIVATE); + } + + /** + * Sets the watched position of the give program. + */ + public void setWatchedPosition(long recordedProgramId, long positionMs) { + mWatchedPositions.edit().putLong(Long.toString(recordedProgramId), positionMs).apply(); + notifyWatchedPositionChanged(recordedProgramId, positionMs); + } + + /** + * Gets the watched position of the give program. + */ + public long getWatchedPosition(long recordedProgramId) { + return mWatchedPositions.getLong(Long.toString(recordedProgramId), + TvInputManager.TIME_SHIFT_INVALID_TIME); + } + + /** + * Adds {@link WatchedPositionChangedListener}. + */ + public void addListener(WatchedPositionChangedListener listener, long recordedProgramId) { + if (recordedProgramId == RecordedProgram.ID_NOT_SET) { + return; + } + Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId); + if (listenerSet == null) { + listenerSet = new CopyOnWriteArraySet<>(); + mListeners.put(recordedProgramId, listenerSet); + } + listenerSet.add(listener); + } + + /** + * Removes {@link WatchedPositionChangedListener}. + */ + public void removeListener(WatchedPositionChangedListener listener) { + for (long recordedProgramId : new ArrayList<>(mListeners.keySet())) { + removeListener(listener, recordedProgramId); + } + } + + /** + * Removes {@link WatchedPositionChangedListener}. + */ + public void removeListener(WatchedPositionChangedListener listener, long recordedProgramId) { + Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId); + if (listenerSet == null) { + return; + } + listenerSet.remove(listener); + if (listenerSet.isEmpty()) { + mListeners.remove(recordedProgramId); + } + } + + private void notifyWatchedPositionChanged(long recordedProgramId, long positionMs) { + Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId); + if (listenerSet == null) { + return; + } + for (WatchedPositionChangedListener listener : listenerSet) { + listener.onWatchedPositionChanged(recordedProgramId, positionMs); + } + } + + public interface WatchedPositionChangedListener { + /** + * Called when the watched position of some program is changed. + */ + void onWatchedPositionChanged(long recordedProgramId, long positionMs); + } +} diff --git a/src/com/android/tv/dvr/IdGenerator.java b/src/com/android/tv/dvr/IdGenerator.java new file mode 100644 index 00000000..0ed6362c --- /dev/null +++ b/src/com/android/tv/dvr/IdGenerator.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * A class which generate the ID which increases sequentially. + */ +public class IdGenerator { + /** + * ID generator for the scheduled recording. + */ + public static final IdGenerator SCHEDULED_RECORDING = new IdGenerator(); + + /** + * ID generator for the series recording. + */ + public static final IdGenerator SERIES_RECORDING = new IdGenerator(); + + private final AtomicLong mMaxId = new AtomicLong(0); + + /** + * Sets the new maximum ID. + */ + public void setMaxId(long maxId) { + mMaxId.set(maxId); + } + + /** + * Returns the new ID which is greater than the existing maximum ID by 1. + */ + public long newId() { + return mMaxId.incrementAndGet(); + } +} diff --git a/src/com/android/tv/dvr/InputTaskScheduler.java b/src/com/android/tv/dvr/InputTaskScheduler.java new file mode 100644 index 00000000..23eacb73 --- /dev/null +++ b/src/com/android/tv/dvr/InputTaskScheduler.java @@ -0,0 +1,378 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.content.Context; +import android.media.tv.TvInputInfo; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; +import android.util.Log; +import android.util.LongSparseArray; + +import com.android.tv.InputSessionManager; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.util.Clock; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * The scheduler for a TV input. + */ +@MainThread +public class InputTaskScheduler { + private static final String TAG = "InputTaskScheduler"; + private static final boolean DEBUG = false; + + private static final int MSG_ADD_SCHEDULED_RECORDING = 1; + private static final int MSG_REMOVE_SCHEDULED_RECORDING = 2; + private static final int MSG_UPDATE_SCHEDULED_RECORDING = 3; + private static final int MSG_BUILD_SCHEDULE = 4; + + /** + * Wraps a {@link RecordingTask} removing it from {@link #mPendingRecordings} when it is done. + */ + public final class HandlerWrapper extends Handler { + public static final int MESSAGE_REMOVE = 999; + private final long mId; + private final RecordingTask mTask; + + HandlerWrapper(Looper looper, ScheduledRecording scheduledRecording, + RecordingTask recordingTask) { + super(looper, recordingTask); + mId = scheduledRecording.getId(); + mTask = recordingTask; + mTask.setHandler(this); + } + + @Override + public void handleMessage(Message msg) { + // The RecordingTask gets a chance first. + // It must return false to pass this message to here. + if (msg.what == MESSAGE_REMOVE) { + if (DEBUG) Log.d(TAG, "done " + mId); + mPendingRecordings.remove(mId); + } + removeCallbacksAndMessages(null); + mHandler.removeMessages(MSG_BUILD_SCHEDULE); + mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE); + super.handleMessage(msg); + } + } + + private TvInputInfo mInput; + private final Looper mLooper; + private final ChannelDataManager mChannelDataManager; + private final DvrManager mDvrManager; + private final WritableDvrDataManager mDataManager; + private final InputSessionManager mSessionManager; + private final Clock mClock; + private final Context mContext; + + private final LongSparseArray<HandlerWrapper> mPendingRecordings = new LongSparseArray<>(); + private final Map<Long, ScheduledRecording> mWaitingSchedules = new ArrayMap<>(); + private final Handler mMainThreadHandler; + private final Handler mHandler; + private final Object mInputLock = new Object(); + private final RecordingTaskFactory mRecordingTaskFactory; + + public InputTaskScheduler(Context context, TvInputInfo input, Looper looper, + ChannelDataManager channelDataManager, DvrManager dvrManager, + DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock) { + this(context, input, looper, channelDataManager, dvrManager, dataManager, sessionManager, + clock, new Handler(Looper.getMainLooper()), null, null); + } + + @VisibleForTesting + InputTaskScheduler(Context context, TvInputInfo input, Looper looper, + ChannelDataManager channelDataManager, DvrManager dvrManager, + DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock, + Handler mainThreadHandler, @Nullable Handler workerThreadHandler, + RecordingTaskFactory recordingTaskFactory) { + if (DEBUG) Log.d(TAG, "Creating scheduler for " + input); + mContext = context; + mInput = input; + mLooper = looper; + mChannelDataManager = channelDataManager; + mDvrManager = dvrManager; + mDataManager = (WritableDvrDataManager) dataManager; + mSessionManager = sessionManager; + mClock = clock; + mMainThreadHandler = mainThreadHandler; + mRecordingTaskFactory = recordingTaskFactory != null ? recordingTaskFactory + : new RecordingTaskFactory() { + @Override + public RecordingTask createRecordingTask(ScheduledRecording schedule, Channel channel, + DvrManager dvrManager, InputSessionManager sessionManager, + WritableDvrDataManager dataManager, Clock clock) { + return new RecordingTask(mContext, schedule, channel, mDvrManager, mSessionManager, + mDataManager, mClock); + } + }; + if (workerThreadHandler == null) { + mHandler = new WorkerThreadHandler(looper); + } else { + mHandler = workerThreadHandler; + } + } + + /** + * Adds a {@link ScheduledRecording}. + */ + public void addSchedule(ScheduledRecording schedule) { + mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_SCHEDULED_RECORDING, schedule)); + } + + @VisibleForTesting + void handleAddSchedule(ScheduledRecording schedule) { + if (mPendingRecordings.get(schedule.getId()) != null + || mWaitingSchedules.containsKey(schedule.getId())) { + return; + } + mWaitingSchedules.put(schedule.getId(), schedule); + mHandler.removeMessages(MSG_BUILD_SCHEDULE); + mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE); + } + + /** + * Removes the {@link ScheduledRecording}. + */ + public void removeSchedule(ScheduledRecording schedule) { + mHandler.sendMessage(mHandler.obtainMessage(MSG_REMOVE_SCHEDULED_RECORDING, schedule)); + } + + @VisibleForTesting + void handleRemoveSchedule(ScheduledRecording schedule) { + HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId()); + if (wrapper != null) { + wrapper.mTask.cancel(); + return; + } + if (mWaitingSchedules.containsKey(schedule.getId())) { + mWaitingSchedules.remove(schedule.getId()); + mHandler.removeMessages(MSG_BUILD_SCHEDULE); + mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE); + } + } + + /** + * Updates the {@link ScheduledRecording}. + */ + public void updateSchedule(ScheduledRecording schedule) { + mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_SCHEDULED_RECORDING, schedule)); + } + + @VisibleForTesting + void handleUpdateSchedule(ScheduledRecording schedule) { + HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId()); + if (wrapper != null) { + if (schedule.getStartTimeMs() > mClock.currentTimeMillis() + && schedule.getStartTimeMs() > wrapper.mTask.getStartTimeMs()) { + // It shouldn't have started. Cancel and put to the waiting list. + // The schedules will be rebuilt when the task is removed. + // The reschedule is called in Scheduler. + wrapper.mTask.cancel(); + mWaitingSchedules.put(schedule.getId(), schedule); + return; + } + wrapper.sendMessage(wrapper.obtainMessage(RecordingTask.MSG_UDPATE_SCHEDULE, schedule)); + return; + } + if (mWaitingSchedules.containsKey(schedule.getId())) { + mWaitingSchedules.put(schedule.getId(), schedule); + mHandler.removeMessages(MSG_BUILD_SCHEDULE); + mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE); + } + } + + /** + * Updates the TV input. + */ + public void updateTvInputInfo(TvInputInfo input) { + synchronized (mInputLock) { + mInput = input; + } + } + + @VisibleForTesting + void handleBuildSchedule() { + if (mWaitingSchedules.isEmpty()) { + return; + } + long currentTimeMs = mClock.currentTimeMillis(); + // Remove past schedules. + for (Iterator<ScheduledRecording> iter = mWaitingSchedules.values().iterator(); + iter.hasNext(); ) { + ScheduledRecording schedule = iter.next(); + if (schedule.getEndTimeMs() <= currentTimeMs) { + fail(schedule); + iter.remove(); + } + } + if (mWaitingSchedules.isEmpty()) { + return; + } + // Record the schedules which should start now. + List<ScheduledRecording> schedulesToStart = new ArrayList<>(); + for (ScheduledRecording schedule : mWaitingSchedules.values()) { + if (schedule.getState() != ScheduledRecording.STATE_RECORDING_CANCELED + && schedule.getStartTimeMs() - RecordingTask.RECORDING_EARLY_START_OFFSET_MS + <= currentTimeMs && schedule.getEndTimeMs() > currentTimeMs) { + schedulesToStart.add(schedule); + } + } + Collections.sort(schedulesToStart, ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR); + int tunerCount; + synchronized (mInputLock) { + tunerCount = mInput.canRecord() ? mInput.getTunerCount() : 0; + } + for (ScheduledRecording schedule : schedulesToStart) { + if (hasTaskWhichFinishEarlier(schedule)) { + // If there is a schedule which finishes earlier than the new schedule, rebuild the + // schedules after it finishes. + return; + } + if (mPendingRecordings.size() < tunerCount) { + // Tuners available. + createRecordingTask(schedule).start(); + mWaitingSchedules.remove(schedule.getId()); + } else { + // No available tuners. + RecordingTask task = getReplacableTask(schedule); + if (task != null) { + task.stop(); + // Just return. The schedules will be rebuilt after the task is stopped. + return; + } else { + // TODO: Do not fail immediately. Start the recording later when available. + // There are no replaceable task. Remove it. + fail(schedule); + mWaitingSchedules.remove(schedule.getId()); + } + } + } + if (mWaitingSchedules.isEmpty()) { + return; + } + // Set next scheduling. + long earliest = Long.MAX_VALUE; + for (ScheduledRecording schedule : mWaitingSchedules.values()) { + if (earliest > schedule.getStartTimeMs()) { + earliest = schedule.getStartTimeMs(); + } + } + mHandler.sendEmptyMessageDelayed(MSG_BUILD_SCHEDULE, earliest + - RecordingTask.RECORDING_EARLY_START_OFFSET_MS - currentTimeMs); + } + + private RecordingTask createRecordingTask(ScheduledRecording schedule) { + Channel channel = mChannelDataManager.getChannel(schedule.getChannelId()); + RecordingTask recordingTask = mRecordingTaskFactory.createRecordingTask(schedule, channel, + mDvrManager, mSessionManager, mDataManager, mClock); + HandlerWrapper handlerWrapper = new HandlerWrapper(mLooper, schedule, recordingTask); + mPendingRecordings.put(schedule.getId(), handlerWrapper); + return recordingTask; + } + + private boolean hasTaskWhichFinishEarlier(ScheduledRecording schedule) { + int size = mPendingRecordings.size(); + for (int i = 0; i < size; ++i) { + RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask; + if (task.getEndTimeMs() <= schedule.getStartTimeMs()) { + return true; + } + } + return false; + } + + private RecordingTask getReplacableTask(ScheduledRecording schedule) { + int size = mPendingRecordings.size(); + RecordingTask candidate = null; + for (int i = 0; i < size; ++i) { + RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask; + if (schedule.getPriority() > task.getPriority() + && (candidate == null || candidate.getPriority() > task.getPriority())) { + candidate = task; + } + } + return candidate; + } + + private void fail(ScheduledRecording schedule) { + // It's called when the scheduling has been failed without creating RecordingTask. + runOnMainHandler(new Runnable() { + @Override + public void run() { + ScheduledRecording scheduleInManager = + mDataManager.getScheduledRecording(schedule.getId()); + if (scheduleInManager != null) { + // The schedule should be updated based on the object from DataManager in case + // when it has been updated. + mDataManager.changeState(scheduleInManager, + ScheduledRecording.STATE_RECORDING_FAILED); + } + } + }); + } + + private void runOnMainHandler(Runnable runnable) { + if (Looper.myLooper() == mMainThreadHandler.getLooper()) { + runnable.run(); + } else { + mMainThreadHandler.post(runnable); + } + } + + @VisibleForTesting + interface RecordingTaskFactory { + RecordingTask createRecordingTask(ScheduledRecording scheduledRecording, Channel channel, + DvrManager dvrManager, InputSessionManager sessionManager, + WritableDvrDataManager dataManager, Clock clock); + } + + private class WorkerThreadHandler extends Handler { + public WorkerThreadHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_ADD_SCHEDULED_RECORDING: + handleAddSchedule((ScheduledRecording) msg.obj); + break; + case MSG_REMOVE_SCHEDULED_RECORDING: + handleRemoveSchedule((ScheduledRecording) msg.obj); + break; + case MSG_UPDATE_SCHEDULED_RECORDING: + handleUpdateSchedule((ScheduledRecording) msg.obj); + case MSG_BUILD_SCHEDULE: + handleBuildSchedule(); + break; + } + } + } +} diff --git a/src/com/android/tv/dvr/RecordedProgram.java b/src/com/android/tv/dvr/RecordedProgram.java new file mode 100644 index 00000000..085402a4 --- /dev/null +++ b/src/com/android/tv/dvr/RecordedProgram.java @@ -0,0 +1,825 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +import static android.media.tv.TvContract.RecordedPrograms; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.common.R; +import com.android.tv.data.BaseProgram; +import com.android.tv.data.InternalDataUtils; +import com.android.tv.util.Utils; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Immutable instance of {@link android.media.tv.TvContract.RecordedPrograms}. + */ +public class RecordedProgram extends BaseProgram { + public static final int ID_NOT_SET = -1; + + public final static String[] PROJECTION = { + // These are in exactly the order listed in RecordedPrograms + RecordedPrograms._ID, + RecordedPrograms.COLUMN_PACKAGE_NAME, + RecordedPrograms.COLUMN_INPUT_ID, + RecordedPrograms.COLUMN_CHANNEL_ID, + RecordedPrograms.COLUMN_TITLE, + RecordedPrograms.COLUMN_SEASON_DISPLAY_NUMBER, + RecordedPrograms.COLUMN_SEASON_TITLE, + RecordedPrograms.COLUMN_EPISODE_DISPLAY_NUMBER, + RecordedPrograms.COLUMN_EPISODE_TITLE, + RecordedPrograms.COLUMN_START_TIME_UTC_MILLIS, + RecordedPrograms.COLUMN_END_TIME_UTC_MILLIS, + RecordedPrograms.COLUMN_BROADCAST_GENRE, + RecordedPrograms.COLUMN_CANONICAL_GENRE, + RecordedPrograms.COLUMN_SHORT_DESCRIPTION, + RecordedPrograms.COLUMN_LONG_DESCRIPTION, + RecordedPrograms.COLUMN_VIDEO_WIDTH, + RecordedPrograms.COLUMN_VIDEO_HEIGHT, + RecordedPrograms.COLUMN_AUDIO_LANGUAGE, + RecordedPrograms.COLUMN_CONTENT_RATING, + RecordedPrograms.COLUMN_POSTER_ART_URI, + RecordedPrograms.COLUMN_THUMBNAIL_URI, + RecordedPrograms.COLUMN_SEARCHABLE, + RecordedPrograms.COLUMN_RECORDING_DATA_URI, + RecordedPrograms.COLUMN_RECORDING_DATA_BYTES, + RecordedPrograms.COLUMN_RECORDING_DURATION_MILLIS, + RecordedPrograms.COLUMN_RECORDING_EXPIRE_TIME_UTC_MILLIS, + RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG1, + RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG2, + RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG3, + RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG4, + RecordedPrograms.COLUMN_VERSION_NUMBER, + RecordedPrograms.COLUMN_INTERNAL_PROVIDER_DATA, + }; + + public static RecordedProgram fromCursor(Cursor cursor) { + int index = 0; + Builder builder = builder() + .setId(cursor.getLong(index++)) + .setPackageName(cursor.getString(index++)) + .setInputId(cursor.getString(index++)) + .setChannelId(cursor.getLong(index++)) + .setTitle(cursor.getString(index++)) + .setSeasonNumber(cursor.getString(index++)) + .setSeasonTitle(cursor.getString(index++)) + .setEpisodeNumber(cursor.getString(index++)) + .setEpisodeTitle(cursor.getString(index++)) + .setStartTimeUtcMillis(cursor.getLong(index++)) + .setEndTimeUtcMillis(cursor.getLong(index++)) + .setBroadcastGenres(cursor.getString(index++)) + .setCanonicalGenres(cursor.getString(index++)) + .setShortDescription(cursor.getString(index++)) + .setLongDescription(cursor.getString(index++)) + .setVideoWidth(cursor.getInt(index++)) + .setVideoHeight(cursor.getInt(index++)) + .setAudioLanguage(cursor.getString(index++)) + .setContentRating(cursor.getString(index++)) + .setPosterArtUri(cursor.getString(index++)) + .setThumbnailUri(cursor.getString(index++)) + .setSearchable(cursor.getInt(index++) == 1) + .setDataUri(cursor.getString(index++)) + .setDataBytes(cursor.getLong(index++)) + .setDurationMillis(cursor.getLong(index++)) + .setExpireTimeUtcMillis(cursor.getLong(index++)) + .setInternalProviderFlag1(cursor.getInt(index++)) + .setInternalProviderFlag2(cursor.getInt(index++)) + .setInternalProviderFlag3(cursor.getInt(index++)) + .setInternalProviderFlag4(cursor.getInt(index++)) + .setVersionNumber(cursor.getInt(index++)); + if (Utils.isInBundledPackageSet(builder.mPackageName)) { + InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); + } + return builder.build(); + } + + public static ContentValues toValues(RecordedProgram recordedProgram) { + ContentValues values = new ContentValues(); + if (recordedProgram.mId != ID_NOT_SET) { + values.put(RecordedPrograms._ID, recordedProgram.mId); + } + values.put(RecordedPrograms.COLUMN_INPUT_ID, recordedProgram.mInputId); + values.put(RecordedPrograms.COLUMN_CHANNEL_ID, recordedProgram.mChannelId); + values.put(RecordedPrograms.COLUMN_TITLE, recordedProgram.mTitle); + values.put(RecordedPrograms.COLUMN_SEASON_DISPLAY_NUMBER, recordedProgram.mSeasonNumber); + values.put(RecordedPrograms.COLUMN_SEASON_TITLE, recordedProgram.mSeasonTitle); + values.put(RecordedPrograms.COLUMN_EPISODE_DISPLAY_NUMBER, recordedProgram.mEpisodeNumber); + values.put(RecordedPrograms.COLUMN_EPISODE_TITLE, recordedProgram.mTitle); + values.put(RecordedPrograms.COLUMN_START_TIME_UTC_MILLIS, + recordedProgram.mStartTimeUtcMillis); + values.put(RecordedPrograms.COLUMN_END_TIME_UTC_MILLIS, recordedProgram.mEndTimeUtcMillis); + values.put(RecordedPrograms.COLUMN_BROADCAST_GENRE, + safeEncode(recordedProgram.mBroadcastGenres)); + values.put(RecordedPrograms.COLUMN_CANONICAL_GENRE, + safeEncode(recordedProgram.mCanonicalGenres)); + values.put(RecordedPrograms.COLUMN_SHORT_DESCRIPTION, recordedProgram.mShortDescription); + values.put(RecordedPrograms.COLUMN_LONG_DESCRIPTION, recordedProgram.mLongDescription); + if (recordedProgram.mVideoWidth == 0) { + values.putNull(RecordedPrograms.COLUMN_VIDEO_WIDTH); + } else { + values.put(RecordedPrograms.COLUMN_VIDEO_WIDTH, recordedProgram.mVideoWidth); + } + if (recordedProgram.mVideoHeight == 0) { + values.putNull(RecordedPrograms.COLUMN_VIDEO_HEIGHT); + } else { + values.put(RecordedPrograms.COLUMN_VIDEO_HEIGHT, recordedProgram.mVideoHeight); + } + values.put(RecordedPrograms.COLUMN_AUDIO_LANGUAGE, recordedProgram.mAudioLanguage); + values.put(RecordedPrograms.COLUMN_CONTENT_RATING, recordedProgram.mContentRating); + values.put(RecordedPrograms.COLUMN_POSTER_ART_URI, recordedProgram.mPosterArtUri); + values.put(RecordedPrograms.COLUMN_THUMBNAIL_URI, recordedProgram.mThumbnailUri); + values.put(RecordedPrograms.COLUMN_SEARCHABLE, recordedProgram.mSearchable ? 1 : 0); + values.put(RecordedPrograms.COLUMN_RECORDING_DATA_URI, + safeToString(recordedProgram.mDataUri)); + values.put(RecordedPrograms.COLUMN_RECORDING_DATA_BYTES, recordedProgram.mDataBytes); + values.put(RecordedPrograms.COLUMN_RECORDING_DURATION_MILLIS, + recordedProgram.mDurationMillis); + values.put(RecordedPrograms.COLUMN_RECORDING_EXPIRE_TIME_UTC_MILLIS, + recordedProgram.mExpireTimeUtcMillis); + values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_DATA, + InternalDataUtils.serializeInternalProviderData(recordedProgram)); + values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG1, + recordedProgram.mInternalProviderFlag1); + values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG2, + recordedProgram.mInternalProviderFlag2); + values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG3, + recordedProgram.mInternalProviderFlag3); + values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG4, + recordedProgram.mInternalProviderFlag4); + values.put(RecordedPrograms.COLUMN_VERSION_NUMBER, recordedProgram.mVersionNumber); + return values; + } + + public static class Builder{ + private long mId = ID_NOT_SET; + private String mPackageName; + private String mInputId; + private long mChannelId; + private String mTitle; + private String mSeriesId; + private String mSeasonNumber; + private String mSeasonTitle; + private String mEpisodeNumber; + private String mEpisodeTitle; + private long mStartTimeUtcMillis; + private long mEndTimeUtcMillis; + private String[] mBroadcastGenres; + private String[] mCanonicalGenres; + private String mShortDescription; + private String mLongDescription; + private int mVideoWidth; + private int mVideoHeight; + private String mAudioLanguage; + private String mContentRating; + private String mPosterArtUri; + private String mThumbnailUri; + private boolean mSearchable = true; + private Uri mDataUri; + private long mDataBytes; + private long mDurationMillis; + private long mExpireTimeUtcMillis; + private int mInternalProviderFlag1; + private int mInternalProviderFlag2; + private int mInternalProviderFlag3; + private int mInternalProviderFlag4; + private int mVersionNumber; + + public Builder setId(long id) { + mId = id; + return this; + } + + public Builder setPackageName(String packageName) { + mPackageName = packageName; + return this; + } + + public Builder setInputId(String inputId) { + mInputId = inputId; + return this; + } + + public Builder setChannelId(long channelId) { + mChannelId = channelId; + return this; + } + + public Builder setTitle(String title) { + mTitle = title; + return this; + } + + public Builder setSeriesId(String seriesId) { + mSeriesId = seriesId; + return this; + } + + public Builder setSeasonNumber(String seasonNumber) { + mSeasonNumber = seasonNumber; + return this; + } + + public Builder setSeasonTitle(String seasonTitle) { + mSeasonTitle = seasonTitle; + return this; + } + + public Builder setEpisodeNumber(String episodeNumber) { + mEpisodeNumber = episodeNumber; + return this; + } + + public Builder setEpisodeTitle(String episodeTitle) { + mEpisodeTitle = episodeTitle; + return this; + } + + public Builder setStartTimeUtcMillis(long startTimeUtcMillis) { + mStartTimeUtcMillis = startTimeUtcMillis; + return this; + } + + public Builder setEndTimeUtcMillis(long endTimeUtcMillis) { + mEndTimeUtcMillis = endTimeUtcMillis; + return this; + } + + public Builder setBroadcastGenres(String broadcastGenres) { + if (TextUtils.isEmpty(broadcastGenres)) { + mBroadcastGenres = null; + return this; + } + return setBroadcastGenres(TvContract.Programs.Genres.decode(broadcastGenres)); + } + + private Builder setBroadcastGenres(String[] broadcastGenres) { + mBroadcastGenres = broadcastGenres; + return this; + } + + public Builder setCanonicalGenres(String canonicalGenres) { + if (TextUtils.isEmpty(canonicalGenres)) { + mCanonicalGenres = null; + return this; + } + return setCanonicalGenres(TvContract.Programs.Genres.decode(canonicalGenres)); + } + + private Builder setCanonicalGenres(String[] canonicalGenres) { + mCanonicalGenres = canonicalGenres; + return this; + } + + public Builder setShortDescription(String shortDescription) { + mShortDescription = shortDescription; + return this; + } + + public Builder setLongDescription(String longDescription) { + mLongDescription = longDescription; + return this; + } + + public Builder setVideoWidth(int videoWidth) { + mVideoWidth = videoWidth; + return this; + } + + public Builder setVideoHeight(int videoHeight) { + mVideoHeight = videoHeight; + return this; + } + + public Builder setAudioLanguage(String audioLanguage) { + mAudioLanguage = audioLanguage; + return this; + } + + public Builder setContentRating(String contentRating) { + mContentRating = contentRating; + return this; + } + + private Uri toUri(String uriString) { + try { + return uriString == null ? null : Uri.parse(uriString); + } catch (Exception e) { + return null; + } + } + + public Builder setPosterArtUri(String posterArtUri) { + mPosterArtUri = posterArtUri; + return this; + } + + public Builder setThumbnailUri(String thumbnailUri) { + mThumbnailUri = thumbnailUri; + return this; + } + + public Builder setSearchable(boolean searchable) { + mSearchable = searchable; + return this; + } + + public Builder setDataUri(String dataUri) { + return setDataUri(toUri(dataUri)); + } + + public Builder setDataUri(Uri dataUri) { + mDataUri = dataUri; + return this; + } + + public Builder setDataBytes(long dataBytes) { + mDataBytes = dataBytes; + return this; + } + + public Builder setDurationMillis(long durationMillis) { + mDurationMillis = durationMillis; + return this; + } + + public Builder setExpireTimeUtcMillis(long expireTimeUtcMillis) { + mExpireTimeUtcMillis = expireTimeUtcMillis; + return this; + } + + public Builder setInternalProviderFlag1(int internalProviderFlag1) { + mInternalProviderFlag1 = internalProviderFlag1; + return this; + } + + public Builder setInternalProviderFlag2(int internalProviderFlag2) { + mInternalProviderFlag2 = internalProviderFlag2; + return this; + } + + public Builder setInternalProviderFlag3(int internalProviderFlag3) { + mInternalProviderFlag3 = internalProviderFlag3; + return this; + } + + public Builder setInternalProviderFlag4(int internalProviderFlag4) { + mInternalProviderFlag4 = internalProviderFlag4; + return this; + } + + public Builder setVersionNumber(int versionNumber) { + mVersionNumber = versionNumber; + return this; + } + + public RecordedProgram build() { + // Generate the series ID for the episodic program of other TV input. + if (TextUtils.isEmpty(mSeriesId) + && !TextUtils.isEmpty(mEpisodeNumber)) { + setSeriesId(BaseProgram.generateSeriesId(mPackageName, mTitle)); + } + return new RecordedProgram(mId, mPackageName, mInputId, mChannelId, mTitle, mSeriesId, + mSeasonNumber, mSeasonTitle, mEpisodeNumber, mEpisodeTitle, mStartTimeUtcMillis, + mEndTimeUtcMillis, mBroadcastGenres, mCanonicalGenres, mShortDescription, + mLongDescription, mVideoWidth, mVideoHeight, mAudioLanguage, mContentRating, + mPosterArtUri, mThumbnailUri, mSearchable, mDataUri, mDataBytes, + mDurationMillis, mExpireTimeUtcMillis, mInternalProviderFlag1, + mInternalProviderFlag2, mInternalProviderFlag3, mInternalProviderFlag4, + mVersionNumber); + } + } + + public static Builder builder() { return new Builder(); } + + public static Builder buildFrom(RecordedProgram orig) { + return builder() + .setId(orig.getId()) + .setPackageName(orig.getPackageName()) + .setInputId(orig.getInputId()) + .setChannelId(orig.getChannelId()) + .setTitle(orig.getTitle()) + .setSeriesId(orig.getSeriesId()) + .setSeasonNumber(orig.getSeasonNumber()) + .setSeasonTitle(orig.getSeasonTitle()) + .setEpisodeNumber(orig.getEpisodeNumber()) + .setEpisodeTitle(orig.getEpisodeTitle()) + .setStartTimeUtcMillis(orig.getStartTimeUtcMillis()) + .setEndTimeUtcMillis(orig.getEndTimeUtcMillis()) + .setBroadcastGenres(orig.getBroadcastGenres()) + .setCanonicalGenres(orig.getCanonicalGenres()) + .setShortDescription(orig.getDescription()) + .setLongDescription(orig.getLongDescription()) + .setVideoWidth(orig.getVideoWidth()) + .setVideoHeight(orig.getVideoHeight()) + .setAudioLanguage(orig.getAudioLanguage()) + .setContentRating(orig.getContentRating()) + .setPosterArtUri(orig.getPosterArtUri()) + .setThumbnailUri(orig.getThumbnailUri()) + .setSearchable(orig.isSearchable()) + .setInternalProviderFlag1(orig.getInternalProviderFlag1()) + .setInternalProviderFlag2(orig.getInternalProviderFlag2()) + .setInternalProviderFlag3(orig.getInternalProviderFlag3()) + .setInternalProviderFlag4(orig.getInternalProviderFlag4()) + .setVersionNumber(orig.getVersionNumber()); + } + + public static final Comparator<RecordedProgram> START_TIME_THEN_ID_COMPARATOR = + new Comparator<RecordedProgram>() { + @Override + public int compare(RecordedProgram lhs, RecordedProgram rhs) { + int res = + Long.compare(lhs.getStartTimeUtcMillis(), rhs.getStartTimeUtcMillis()); + if (res != 0) { + return res; + } + return Long.compare(lhs.mId, rhs.mId); + } + }; + + private static final long CLIPPED_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5); + + private final long mId; + private final String mPackageName; + private final String mInputId; + private final long mChannelId; + private final String mTitle; + private final String mSeriesId; + private final String mSeasonNumber; + private final String mSeasonTitle; + private final String mEpisodeNumber; + private final String mEpisodeTitle; + private final long mStartTimeUtcMillis; + private final long mEndTimeUtcMillis; + private final String[] mBroadcastGenres; + private final String[] mCanonicalGenres; + private final String mShortDescription; + private final String mLongDescription; + private final int mVideoWidth; + private final int mVideoHeight; + private final String mAudioLanguage; + private final String mContentRating; + private final String mPosterArtUri; + private final String mThumbnailUri; + private final boolean mSearchable; + private final Uri mDataUri; + private final long mDataBytes; + private final long mDurationMillis; + private final long mExpireTimeUtcMillis; + private final int mInternalProviderFlag1; + private final int mInternalProviderFlag2; + private final int mInternalProviderFlag3; + private final int mInternalProviderFlag4; + private final int mVersionNumber; + + private RecordedProgram(long id, String packageName, String inputId, long channelId, + String title, String seriesId, String seasonNumber, String seasonTitle, + String episodeNumber, String episodeTitle, long startTimeUtcMillis, + long endTimeUtcMillis, String[] broadcastGenres, String[] canonicalGenres, + String shortDescription, String longDescription, int videoWidth, int videoHeight, + String audioLanguage, String contentRating, String posterArtUri, String thumbnailUri, + boolean searchable, Uri dataUri, long dataBytes, long durationMillis, + long expireTimeUtcMillis, int internalProviderFlag1, int internalProviderFlag2, + int internalProviderFlag3, int internalProviderFlag4, int versionNumber) { + mId = id; + mPackageName = packageName; + mInputId = inputId; + mChannelId = channelId; + mTitle = title; + mSeriesId = seriesId; + mSeasonNumber = seasonNumber; + mSeasonTitle = seasonTitle; + mEpisodeNumber = episodeNumber; + mEpisodeTitle = episodeTitle; + mStartTimeUtcMillis = startTimeUtcMillis; + mEndTimeUtcMillis = endTimeUtcMillis; + mBroadcastGenres = broadcastGenres; + mCanonicalGenres = canonicalGenres; + mShortDescription = shortDescription; + mLongDescription = longDescription; + mVideoWidth = videoWidth; + mVideoHeight = videoHeight; + + mAudioLanguage = audioLanguage; + mContentRating = contentRating; + mPosterArtUri = posterArtUri; + mThumbnailUri = thumbnailUri; + mSearchable = searchable; + mDataUri = dataUri; + mDataBytes = dataBytes; + mDurationMillis = durationMillis; + mExpireTimeUtcMillis = expireTimeUtcMillis; + mInternalProviderFlag1 = internalProviderFlag1; + mInternalProviderFlag2 = internalProviderFlag2; + mInternalProviderFlag3 = internalProviderFlag3; + mInternalProviderFlag4 = internalProviderFlag4; + mVersionNumber = versionNumber; + } + + public String getAudioLanguage() { + return mAudioLanguage; + } + + public String[] getBroadcastGenres() { + return mBroadcastGenres; + } + + public String[] getCanonicalGenres() { + return mCanonicalGenres; + } + + @Override + public long getChannelId() { + return mChannelId; + } + + public String getContentRating() { + return mContentRating; + } + + public Uri getDataUri() { + return mDataUri; + } + + public long getDataBytes() { + return mDataBytes; + } + + @Override + public long getDurationMillis() { + return mDurationMillis; + } + + @Override + public long getEndTimeUtcMillis() { + return mEndTimeUtcMillis; + } + + @Override + public String getEpisodeNumber() { + return mEpisodeNumber; + } + + public String getEpisodeTitle() { + return mEpisodeTitle; + } + + @Override + public String getEpisodeDisplayTitle(Context context) { + if (!TextUtils.isEmpty(mEpisodeNumber)) { + String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle; + if (TextUtils.equals(mSeasonNumber, "0")) { + // Do not show "S0: ". + return String.format(context.getResources().getString( + R.string.display_episode_title_format_no_season_number), + mEpisodeNumber, episodeTitle); + } else { + return String.format(context.getResources().getString( + R.string.display_episode_title_format), + mSeasonNumber, mEpisodeNumber, episodeTitle); + } + } + return mEpisodeTitle; + } + + @Nullable + @Override + public String getTitleWithEpisodeNumber(Context context) { + if (TextUtils.isEmpty(mTitle)) { + return mTitle; + } + if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) { + return TextUtils.isEmpty(mEpisodeNumber) ? mTitle : context.getString( + R.string.program_title_with_episode_number_no_season, mTitle, mEpisodeNumber); + } else { + return context.getString(R.string.program_title_with_episode_number, mTitle, + mSeasonNumber, mEpisodeNumber); + } + } + + public long getExpireTimeUtcMillis() { + return mExpireTimeUtcMillis; + } + + public long getId() { + return mId; + } + + public String getPackageName() { + return mPackageName; + } + + public String getInputId() { + return mInputId; + } + + public int getInternalProviderFlag1() { + return mInternalProviderFlag1; + } + + public int getInternalProviderFlag2() { + return mInternalProviderFlag2; + } + + public int getInternalProviderFlag3() { + return mInternalProviderFlag3; + } + + public int getInternalProviderFlag4() { + return mInternalProviderFlag4; + } + + @Override + public String getDescription() { + return mShortDescription; + } + + @Override + public String getLongDescription() { + return mLongDescription; + } + + @Override + public String getPosterArtUri() { + return mPosterArtUri; + } + + @Override + public boolean isValid() { + return true; + } + + public boolean isSearchable() { + return mSearchable; + } + + public String getSeriesId() { + return mSeriesId; + } + + @Override + public String getSeasonNumber() { + return mSeasonNumber; + } + + public String getSeasonTitle() { + return mSeasonTitle; + } + + @Override + public long getStartTimeUtcMillis() { + return mStartTimeUtcMillis; + } + + @Override + public String getThumbnailUri() { + return mThumbnailUri; + } + + @Override + public String getTitle() { + return mTitle; + } + + public Uri getUri() { + return ContentUris.withAppendedId(RecordedPrograms.CONTENT_URI, mId); + } + + public int getVersionNumber() { + return mVersionNumber; + } + + public int getVideoHeight() { + return mVideoHeight; + } + + public int getVideoWidth() { + return mVideoWidth; + } + + /** + * Checks whether the recording has been clipped or not. + */ + public boolean isClipped() { + return mEndTimeUtcMillis - mStartTimeUtcMillis - mDurationMillis > CLIPPED_THRESHOLD_MS; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RecordedProgram that = (RecordedProgram) o; + return Objects.equals(mId, that.mId) && + Objects.equals(mChannelId, that.mChannelId) && + Objects.equals(mSeriesId, that.mSeriesId) && + Objects.equals(mSeasonNumber, that.mSeasonNumber) && + Objects.equals(mSeasonTitle, that.mSeasonTitle) && + Objects.equals(mEpisodeNumber, that.mEpisodeNumber) && + Objects.equals(mStartTimeUtcMillis, that.mStartTimeUtcMillis) && + Objects.equals(mEndTimeUtcMillis, that.mEndTimeUtcMillis) && + Objects.equals(mVideoWidth, that.mVideoWidth) && + Objects.equals(mVideoHeight, that.mVideoHeight) && + Objects.equals(mSearchable, that.mSearchable) && + Objects.equals(mDataBytes, that.mDataBytes) && + Objects.equals(mDurationMillis, that.mDurationMillis) && + Objects.equals(mExpireTimeUtcMillis, that.mExpireTimeUtcMillis) && + Objects.equals(mInternalProviderFlag1, that.mInternalProviderFlag1) && + Objects.equals(mInternalProviderFlag2, that.mInternalProviderFlag2) && + Objects.equals(mInternalProviderFlag3, that.mInternalProviderFlag3) && + Objects.equals(mInternalProviderFlag4, that.mInternalProviderFlag4) && + Objects.equals(mVersionNumber, that.mVersionNumber) && + Objects.equals(mTitle, that.mTitle) && + Objects.equals(mEpisodeTitle, that.mEpisodeTitle) && + Arrays.equals(mBroadcastGenres, that.mBroadcastGenres) && + Arrays.equals(mCanonicalGenres, that.mCanonicalGenres) && + Objects.equals(mShortDescription, that.mShortDescription) && + Objects.equals(mLongDescription, that.mLongDescription) && + Objects.equals(mAudioLanguage, that.mAudioLanguage) && + Objects.equals(mContentRating, that.mContentRating) && + Objects.equals(mPosterArtUri, that.mPosterArtUri) && + Objects.equals(mThumbnailUri, that.mThumbnailUri); + } + + /** + * Hashes based on the ID. + */ + @Override + public int hashCode() { + return Objects.hash(mId); + } + + @Override + public String toString() { + return "RecordedProgram" + + "[" + mId + + "]{ mPackageName=" + mPackageName + + ", mInputId='" + mInputId + '\'' + + ", mChannelId='" + mChannelId + '\'' + + ", mTitle='" + mTitle + '\'' + + ", mSeriesId='" + mSeriesId + '\'' + + ", mEpisodeNumber=" + mEpisodeNumber + + ", mEpisodeTitle='" + mEpisodeTitle + '\'' + + ", mStartTimeUtcMillis=" + mStartTimeUtcMillis + + ", mEndTimeUtcMillis=" + mEndTimeUtcMillis + + ", mBroadcastGenres=" + + (mBroadcastGenres != null ? Arrays.toString(mBroadcastGenres) : "null") + + ", mCanonicalGenres=" + + (mCanonicalGenres != null ? Arrays.toString(mCanonicalGenres) : "null") + + ", mShortDescription='" + mShortDescription + '\'' + + ", mLongDescription='" + mLongDescription + '\'' + + ", mVideoHeight=" + mVideoHeight + + ", mVideoWidth=" + mVideoWidth + + ", mAudioLanguage='" + mAudioLanguage + '\'' + + ", mContentRating='" + mContentRating + '\'' + + ", mPosterArtUri=" + mPosterArtUri + + ", mThumbnailUri=" + mThumbnailUri + + ", mSearchable=" + mSearchable + + ", mDataUri=" + mDataUri + + ", mDataBytes=" + mDataBytes + + ", mDurationMillis=" + mDurationMillis + + ", mExpireTimeUtcMillis=" + mExpireTimeUtcMillis + + ", mInternalProviderFlag1=" + mInternalProviderFlag1 + + ", mInternalProviderFlag2=" + mInternalProviderFlag2 + + ", mInternalProviderFlag3=" + mInternalProviderFlag3 + + ", mInternalProviderFlag4=" + mInternalProviderFlag4 + + ", mSeasonNumber=" + mSeasonNumber + + ", mSeasonTitle=" + mSeasonTitle + + ", mVersionNumber=" + mVersionNumber + + '}'; + } + + @Nullable + private static String safeToString(@Nullable Object o) { + return o == null ? null : o.toString(); + } + + @Nullable + private static String safeEncode(@Nullable String[] genres) { + return genres == null ? null : TvContract.Programs.Genres.encode(genres); + } +} diff --git a/src/com/android/tv/dvr/RecordingTask.java b/src/com/android/tv/dvr/RecordingTask.java index 804485b3..2373f15c 100644 --- a/src/com/android/tv/dvr/RecordingTask.java +++ b/src/com/android/tv/dvr/RecordingTask.java @@ -16,18 +16,28 @@ package com.android.tv.dvr; +import android.annotation.TargetApi; +import android.content.Context; import android.media.tv.TvContract; -import android.media.tv.TvRecordingClient; +import android.media.tv.TvInputManager; +import android.media.tv.TvRecordingClient.RecordingCallback; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.util.Log; +import android.widget.Toast; +import com.android.tv.InputSessionManager; +import com.android.tv.InputSessionManager.RecordingSession; +import com.android.tv.R; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; +import com.android.tv.dvr.InputTaskScheduler.HandlerWrapper; import com.android.tv.util.Clock; import com.android.tv.util.Utils; @@ -40,22 +50,33 @@ import java.util.concurrent.TimeUnit; * There is only one looper so messages must be handled quickly or start a separate thread. */ @WorkerThread -class RecordingTask extends TvRecordingClient.RecordingCallback - implements Handler.Callback, DvrManager.Listener { +@VisibleForTesting +@TargetApi(Build.VERSION_CODES.N) +public class RecordingTask extends RecordingCallback implements Handler.Callback, + DvrManager.Listener { private static final String TAG = "RecordingTask"; private static final boolean DEBUG = false; @VisibleForTesting - static final int MESSAGE_INIT = 1; + static final int MSG_INITIALIZE = 1; @VisibleForTesting - static final int MESSAGE_START_RECORDING = 2; + static final int MSG_START_RECORDING = 2; @VisibleForTesting - static final int MESSAGE_STOP_RECORDING = 3; - - @VisibleForTesting - static final long MS_BEFORE_START = TimeUnit.SECONDS.toMillis(5); - @VisibleForTesting - static final long MS_AFTER_END = TimeUnit.SECONDS.toMillis(5); + static final int MSG_STOP_RECORDING = 3; + /** + * Message to update schedule. + */ + public static final int MSG_UDPATE_SCHEDULE = 4; + + /** + * The time when the start command will be sent before the recording starts. + */ + public static final long RECORDING_EARLY_START_OFFSET_MS = TimeUnit.SECONDS.toMillis(3); + /** + * If the recording starts later than the scheduled start time or ends before the scheduled end + * time, it's considered as clipped. + */ + private static final long CLIPPED_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5); @VisibleForTesting enum State { @@ -63,27 +84,32 @@ class RecordingTask extends TvRecordingClient.RecordingCallback SESSION_ACQUIRED, CONNECTION_PENDING, CONNECTED, - RECORDING_START_REQUESTED, RECORDING_STARTED, RECORDING_STOP_REQUESTED, + FINISHED, ERROR, RELEASED, } - private final DvrSessionManager mSessionManager; + private final InputSessionManager mSessionManager; private final DvrManager mDvrManager; + private final Context mContext; private final WritableDvrDataManager mDataManager; private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); - private TvRecordingClient mTvRecordingClient; + private RecordingSession mRecordingSession; private Handler mHandler; private ScheduledRecording mScheduledRecording; private final Channel mChannel; private State mState = State.NOT_STARTED; private final Clock mClock; + private boolean mStartedWithClipping; + private Uri mRecordedProgramUri; + private boolean mCanceled; - RecordingTask(ScheduledRecording scheduledRecording, Channel channel, - DvrManager dvrManager, DvrSessionManager sessionManager, + RecordingTask(Context context, ScheduledRecording scheduledRecording, Channel channel, + DvrManager dvrManager, InputSessionManager sessionManager, WritableDvrDataManager dataManager, Clock clock) { + mContext = context; mScheduledRecording = scheduledRecording; mChannel = channel; mSessionManager = sessionManager; @@ -101,27 +127,30 @@ class RecordingTask extends TvRecordingClient.RecordingCallback @Override public boolean handleMessage(Message msg) { if (DEBUG) Log.d(TAG, "handleMessage " + msg); - SoftPreconditions - .checkState(msg.what == Scheduler.HandlerWrapper.MESSAGE_REMOVE || mHandler != null, - TAG, "Null handler trying to handle " + msg); + SoftPreconditions.checkState(msg.what == HandlerWrapper.MESSAGE_REMOVE || mHandler != null, + TAG, "Null handler trying to handle " + msg); try { switch (msg.what) { - case MESSAGE_INIT: + case MSG_INITIALIZE: handleInit(); break; - case MESSAGE_START_RECORDING: + case MSG_START_RECORDING: handleStartRecording(); break; - case MESSAGE_STOP_RECORDING: + case MSG_STOP_RECORDING: handleStopRecording(); break; - case Scheduler.HandlerWrapper.MESSAGE_REMOVE: - // Clear the handler + case MSG_UDPATE_SCHEDULE: + handleUpdateSchedule((ScheduledRecording) msg.obj); + break; + case HandlerWrapper.MESSAGE_REMOVE: + mHandler.removeCallbacksAndMessages(null); mHandler = null; release(); return false; default: SoftPreconditions.checkArgument(false, TAG, "unexpected message type " + msg); + break; } return true; } catch (Exception e) { @@ -132,54 +161,83 @@ class RecordingTask extends TvRecordingClient.RecordingCallback } @Override + public void onDisconnected(String inputId) { + if (DEBUG) Log.d(TAG, "onDisconnected(" + inputId + ")"); + if (mRecordingSession != null && mState != State.FINISHED) { + failAndQuit(); + } + } + + @Override public void onTuned(Uri channelUri) { - if (DEBUG) { - Log.d(TAG, "onTuned"); + if (DEBUG) Log.d(TAG, "onTuned"); + if (mRecordingSession == null) { + return; } - super.onTuned(channelUri); mState = State.CONNECTED; - if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MESSAGE_START_RECORDING, - mScheduledRecording.getStartTimeMs() - MS_BEFORE_START)) { - mState = State.ERROR; - return; + if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MSG_START_RECORDING, + mScheduledRecording.getStartTimeMs() - RECORDING_EARLY_START_OFFSET_MS)) { + failAndQuit(); } } - @Override public void onRecordingStopped(Uri recordedProgramUri) { - super.onRecordingStopped(recordedProgramUri); - mState = State.CONNECTED; - updateRecording(ScheduledRecording.buildFrom(mScheduledRecording) - .setState(ScheduledRecording.STATE_RECORDING_FINISHED).build()); + if (DEBUG) Log.d(TAG, "onRecordingStopped"); + if (mRecordingSession == null) { + return; + } + mRecordedProgramUri = recordedProgramUri; + mState = State.FINISHED; + int state = ScheduledRecording.STATE_RECORDING_FINISHED; + if (mStartedWithClipping || mScheduledRecording.getEndTimeMs() - CLIPPED_THRESHOLD_MS + > mClock.currentTimeMillis()) { + state = ScheduledRecording.STATE_RECORDING_CLIPPED; + } + updateRecordingState(state); sendRemove(); + if (mCanceled) { + removeRecordedProgram(); + } } @Override public void onError(int reason) { if (DEBUG) Log.d(TAG, "onError reason " + reason); - super.onError(reason); - // TODO(dvr) handle success + if (mRecordingSession == null) { + return; + } switch (reason) { + case TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE: + mMainThreadHandler.post(new Runnable() { + @Override + public void run() { + if (TvApplication.getSingletons(mContext).getMainActivityWrapper() + .isResumed()) { + Toast.makeText(mContext.getApplicationContext(), + R.string.dvr_error_insufficient_space_description, + Toast.LENGTH_LONG) + .show(); + } else { + Utils.setRecordingFailedReason(mContext.getApplicationContext(), + TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + } + } + }); + // Pass through default: - updateRecording(ScheduledRecording.buildFrom(mScheduledRecording) - .setState(ScheduledRecording.STATE_RECORDING_FAILED) - .build()); + failAndQuit(); + break; } - release(); - sendRemove(); } private void handleInit() { if (DEBUG) Log.d(TAG, "handleInit " + mScheduledRecording); - //TODO check recording preconditions - if (mScheduledRecording.getEndTimeMs() < mClock.currentTimeMillis()) { Log.w(TAG, "End time already past, not recording " + mScheduledRecording); failAndQuit(); return; } - if (mChannel == null) { Log.w(TAG, "Null channel for " + mScheduledRecording); failAndQuit(); @@ -193,18 +251,11 @@ class RecordingTask extends TvRecordingClient.RecordingCallback } String inputId = mChannel.getInputId(); - if (mSessionManager.canAcquireDvrSession(inputId, mChannel)) { - mTvRecordingClient = mSessionManager - .createTvRecordingClient("recordingTask-" + mScheduledRecording.getId(), this, - mHandler); - mState = State.SESSION_ACQUIRED; - } else { - Log.w(TAG, "Unable to acquire a session for " + mScheduledRecording); - failAndQuit(); - return; - } + mRecordingSession = mSessionManager.createRecordingSession(inputId, + "recordingTask-" + mScheduledRecording.getId(), this, mHandler); + mState = State.SESSION_ACQUIRED; mDvrManager.addListener(this, mHandler); - mTvRecordingClient.tune(inputId, mChannel.getUri()); + mRecordingSession.tune(inputId, mChannel.getUri()); mState = State.CONNECTION_PENDING; } @@ -218,41 +269,78 @@ class RecordingTask extends TvRecordingClient.RecordingCallback private void sendRemove() { if (DEBUG) Log.d(TAG, "sendRemove"); if (mHandler != null) { - mHandler.sendEmptyMessage(Scheduler.HandlerWrapper.MESSAGE_REMOVE); + mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage( + HandlerWrapper.MESSAGE_REMOVE)); } } private void handleStartRecording() { if (DEBUG) Log.d(TAG, "handleStartRecording " + mScheduledRecording); - // TODO(DVR) handle errors long programId = mScheduledRecording.getProgramId(); - mTvRecordingClient.startRecording(programId == ScheduledRecording.ID_NOT_SET ? null + mRecordingSession.startRecording(programId == ScheduledRecording.ID_NOT_SET ? null : TvContract.buildProgramUri(programId)); - updateRecording(ScheduledRecording.buildFrom(mScheduledRecording) - .setState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS).build()); + updateRecordingState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS); + // If it starts late, it's clipped. + if (mScheduledRecording.getStartTimeMs() + CLIPPED_THRESHOLD_MS + < mClock.currentTimeMillis()) { + mStartedWithClipping = true; + } mState = State.RECORDING_STARTED; - if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MESSAGE_STOP_RECORDING, - mScheduledRecording.getEndTimeMs() + MS_AFTER_END)) { - mState = State.ERROR; - return; + if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING, + mScheduledRecording.getEndTimeMs())) { + failAndQuit(); } } private void handleStopRecording() { if (DEBUG) Log.d(TAG, "handleStopRecording " + mScheduledRecording); - mTvRecordingClient.stopRecording(); + mRecordingSession.stopRecording(); mState = State.RECORDING_STOP_REQUESTED; } + private void handleUpdateSchedule(ScheduledRecording schedule) { + mScheduledRecording = schedule; + // Check end time only. The start time is checked in InputTaskScheduler. + if (schedule.getEndTimeMs() != mScheduledRecording.getEndTimeMs() + && mState == State.RECORDING_STARTED) { + mHandler.removeMessages(MSG_STOP_RECORDING); + if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING, schedule.getEndTimeMs())) { + failAndQuit(); + } + } + } + @VisibleForTesting State getState() { return mState; } + /** + * Returns the priority. + */ + public long getPriority() { + return mScheduledRecording.getPriority(); + } + + /** + * Returns the start time of the recording. + */ + public long getStartTimeMs() { + return mScheduledRecording.getStartTimeMs(); + } + + /** + * Returns the end time of the recording. + */ + public long getEndTimeMs() { + return mScheduledRecording.getEndTimeMs(); + } + private void release() { - if (mTvRecordingClient != null) { - mSessionManager.releaseTvRecordingClient(mTvRecordingClient); + if (mRecordingSession != null) { + mSessionManager.releaseRecordingSession(mRecordingSession); + mRecordingSession = null; } mDvrManager.removeListener(this); } @@ -268,22 +356,24 @@ class RecordingTask extends TvRecordingClient.RecordingCallback } private void updateRecordingState(@ScheduledRecording.RecordingState int state) { - updateRecording(ScheduledRecording.buildFrom(mScheduledRecording).setState(state).build()); - } - - @VisibleForTesting - static Uri getIdAsMediaUri(ScheduledRecording scheduledRecording) { - // TODO define the URI format - return new Uri.Builder().appendPath(String.valueOf(scheduledRecording.getId())).build(); - } - - private void updateRecording(ScheduledRecording updatedScheduledRecording) { - if (DEBUG) Log.d(TAG, "updateScheduledRecording " + updatedScheduledRecording); - mScheduledRecording = updatedScheduledRecording; + if (DEBUG) Log.d(TAG, "Updating the state of " + mScheduledRecording + " to " + state); + mScheduledRecording = ScheduledRecording.buildFrom(mScheduledRecording).setState(state) + .build(); mMainThreadHandler.post(new Runnable() { @Override public void run() { - mDataManager.updateScheduledRecording(mScheduledRecording); + ScheduledRecording schedule = mDataManager.getScheduledRecording( + mScheduledRecording.getId()); + if (schedule == null) { + // Schedule has been deleted. Delete the recorded program. + removeRecordedProgram(); + } else { + // Update the state based on the object in DataManager in case when it has been + // updated. mScheduledRecording will be updated from + // onScheduledRecordingStateChanged. + mDataManager.updateScheduledRecording(ScheduledRecording.buildFrom(schedule) + .setState(state).build()); + } } }); } @@ -293,9 +383,24 @@ class RecordingTask extends TvRecordingClient.RecordingCallback if (recording.getId() != mScheduledRecording.getId()) { return; } + stop(); + } + + /** + * Starts the task. + */ + public void start() { + mHandler.sendEmptyMessage(MSG_INITIALIZE); + } + + /** + * Stops the task. + */ + public void stop() { + if (DEBUG) Log.d(TAG, "stop"); switch (mState) { case RECORDING_STARTED: - mHandler.removeMessages(MESSAGE_STOP_RECORDING); + mHandler.removeMessages(MSG_STOP_RECORDING); handleStopRecording(); break; case RECORDING_STOP_REQUESTED: @@ -305,7 +410,7 @@ class RecordingTask extends TvRecordingClient.RecordingCallback case SESSION_ACQUIRED: case CONNECTION_PENDING: case CONNECTED: - case RECORDING_START_REQUESTED: + case FINISHED: case ERROR: case RELEASED: default: @@ -314,8 +419,37 @@ class RecordingTask extends TvRecordingClient.RecordingCallback } } + /** + * Cancels the task + */ + public void cancel() { + if (DEBUG) Log.d(TAG, "cancel"); + mCanceled = true; + stop(); + removeRecordedProgram(); + } + @Override public String toString() { return getClass().getName() + "(" + mScheduledRecording + ")"; } + + private void removeRecordedProgram() { + runOnMainThread(new Runnable() { + @Override + public void run() { + if (mRecordedProgramUri != null) { + mDvrManager.removeRecordedProgram(mRecordedProgramUri); + } + } + }); + } + + private void runOnMainThread(Runnable runnable) { + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable.run(); + } else { + mMainThreadHandler.post(runnable); + } + } } diff --git a/src/com/android/tv/dvr/ScheduledProgramReaper.java b/src/com/android/tv/dvr/ScheduledProgramReaper.java index 9053eaec..cd79a631 100644 --- a/src/com/android/tv/dvr/ScheduledProgramReaper.java +++ b/src/com/android/tv/dvr/ScheduledProgramReaper.java @@ -21,6 +21,7 @@ import android.support.annotation.VisibleForTesting; import com.android.tv.util.Clock; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -42,12 +43,25 @@ class ScheduledProgramReaper implements Runnable { @Override @MainThread public void run() { - List<ScheduledRecording> recordings = mDvrDataManager.getAllScheduledRecordings(); long cutoff = mClock.currentTimeMillis() - TimeUnit.DAYS.toMillis(DAYS); - for (ScheduledRecording r : recordings) { + List<ScheduledRecording> toRemove = new ArrayList<>(); + for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) { + // Do not remove the schedules if it belongs to the series recording and was finished + // successfully. The schedule is necessary for checking the scheduled episode of the + // series recording. + if (r.getEndTimeMs() < cutoff + && (r.getSeriesRecordingId() == SeriesRecording.ID_NOT_SET + || r.getState() != ScheduledRecording.STATE_RECORDING_FINISHED)) { + toRemove.add(r); + } + } + for (ScheduledRecording r : mDvrDataManager.getDeletedSchedules()) { if (r.getEndTimeMs() < cutoff) { - mDvrDataManager.removeScheduledRecording(r); + toRemove.add(r); } } + if (!toRemove.isEmpty()) { + mDvrDataManager.removeScheduledRecording(ScheduledRecording.toArray(toRemove)); + } } } diff --git a/src/com/android/tv/dvr/ScheduledRecording.java b/src/com/android/tv/dvr/ScheduledRecording.java index 01b00459..a9673b40 100644 --- a/src/com/android/tv/dvr/ScheduledRecording.java +++ b/src/com/android/tv/dvr/ScheduledRecording.java @@ -17,46 +17,75 @@ package com.android.tv.dvr; import android.content.ContentValues; +import android.content.Context; import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; import android.support.annotation.IntDef; import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; import android.util.Range; +import com.android.tv.R; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.Program; -import com.android.tv.dvr.provider.DvrContract; +import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.util.Utils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.util.Collection; import java.util.Comparator; +import java.util.Objects; /** * A data class for one recording contents. */ @VisibleForTesting -public final class ScheduledRecording { - private static final String TAG = "Recording"; +public final class ScheduledRecording implements Parcelable { + private static final String TAG = "ScheduledRecording"; - public static final String RECORDING_ID_EXTRA = "extra.dvr.recording.id"; //TODO(DVR) move - public static final String PARAM_INPUT_ID = "input_id"; + /** + * Indicates that the ID is not assigned yet. + */ + public static final long ID_NOT_SET = 0; - public static final long ID_NOT_SET = -1; + /** + * The default priority of the recording. + */ + public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1; - public static final Comparator<ScheduledRecording> START_TIME_COMPARATOR = new Comparator<ScheduledRecording>() { + public static final Comparator<ScheduledRecording> START_TIME_COMPARATOR + = new Comparator<ScheduledRecording>() { @Override public int compare(ScheduledRecording lhs, ScheduledRecording rhs) { return Long.compare(lhs.mStartTimeMs, rhs.mStartTimeMs); } }; - public static final Comparator<ScheduledRecording> PRIORITY_COMPARATOR = new Comparator<ScheduledRecording>() { + /** + * Compare the end time in ascending order. + */ + public static final Comparator<ScheduledRecording> END_TIME_COMPARATOR + = new Comparator<ScheduledRecording>() { + @Override + public int compare(ScheduledRecording lhs, ScheduledRecording rhs) { + return Long.compare(lhs.mEndTimeMs, rhs.mEndTimeMs); + } + }; + + /** + * Compare priority in descending order. + */ + public static final Comparator<ScheduledRecording> PRIORITY_COMPARATOR + = new Comparator<ScheduledRecording>() { @Override public int compare(ScheduledRecording lhs, ScheduledRecording rhs) { - int value = Long.compare(lhs.mPriority, rhs.mPriority); + int value = Long.compare(rhs.mPriority, lhs.mPriority); if (value == 0) { - value = Long.compare(lhs.mId, rhs.mId); + // New recording has the higher priority. + value = Long.compare(rhs.mId, lhs.mId); } return value; } @@ -74,30 +103,77 @@ public final class ScheduledRecording { } }; - public static Builder builder(Program p) { + /** + * Builds scheduled recordings from programs. + */ + public static Builder builder(String inputId, Program p) { return new Builder() - .setStartTime(p.getStartTimeUtcMillis()).setEndTime(p.getEndTimeUtcMillis()) + .setInputId(inputId) + .setChannelId(p.getChannelId()) + .setStartTimeMs(p.getStartTimeUtcMillis()).setEndTimeMs(p.getEndTimeUtcMillis()) .setProgramId(p.getId()) + .setProgramTitle(p.getTitle()) + .setSeasonNumber(p.getSeasonNumber()) + .setEpisodeNumber(p.getEpisodeNumber()) + .setEpisodeTitle(p.getEpisodeTitle()) + .setProgramDescription(p.getDescription()) + .setProgramLongDescription(p.getLongDescription()) + .setProgramPosterArtUri(p.getPosterArtUri()) + .setProgramThumbnailUri(p.getThumbnailUri()) .setType(TYPE_PROGRAM); } - public static Builder builder(long startTime, long endTime) { + public static Builder builder(String inputId, long channelId, long startTime, long endTime) { return new Builder() - .setStartTime(startTime) - .setEndTime(endTime) + .setInputId(inputId) + .setChannelId(channelId) + .setStartTimeMs(startTime) + .setEndTimeMs(endTime) .setType(TYPE_TIMED); } + /** + * Creates a new Builder with the values set from the {@link RecordedProgram}. + */ + @VisibleForTesting + public static Builder builder(RecordedProgram p) { + boolean isProgramRecording = !TextUtils.isEmpty(p.getTitle()); + return new Builder() + .setInputId(p.getInputId()) + .setChannelId(p.getChannelId()) + .setType(isProgramRecording ? TYPE_PROGRAM : TYPE_TIMED) + .setStartTimeMs(p.getStartTimeUtcMillis()) + .setEndTimeMs(p.getEndTimeUtcMillis()) + .setProgramTitle(p.getTitle()) + .setSeasonNumber(p.getSeasonNumber()) + .setEpisodeNumber(p.getEpisodeNumber()) + .setEpisodeTitle(p.getEpisodeTitle()) + .setProgramDescription(p.getDescription()) + .setProgramLongDescription(p.getLongDescription()) + .setProgramPosterArtUri(p.getPosterArtUri()) + .setProgramThumbnailUri(p.getThumbnailUri()) + .setState(STATE_RECORDING_FINISHED); + } + public static final class Builder { private long mId = ID_NOT_SET; - private long mPriority = Long.MAX_VALUE; + private long mPriority = DvrScheduleManager.DEFAULT_PRIORITY; + private String mInputId; private long mChannelId; private long mProgramId = ID_NOT_SET; + private String mProgramTitle; private @RecordingType int mType; - private long mStartTime; - private long mEndTime; + private long mStartTimeMs; + private long mEndTimeMs; + private String mSeasonNumber; + private String mEpisodeNumber; + private String mEpisodeTitle; + private String mProgramDescription; + private String mProgramLongDescription; + private String mProgramPosterArtUri; + private String mProgramThumbnailUri; private @RecordingState int mState; - private SeasonRecording mParentSeasonRecording; + private long mSeriesRecordingId = ID_NOT_SET; private Builder() { } @@ -111,6 +187,11 @@ public final class ScheduledRecording { return this; } + public Builder setInputId(String inputId) { + mInputId = inputId; + return this; + } + public Builder setChannelId(long channelId) { mChannelId = channelId; return this; @@ -121,18 +202,58 @@ public final class ScheduledRecording { return this; } + public Builder setProgramTitle(String programTitle) { + mProgramTitle = programTitle; + return this; + } + private Builder setType(@RecordingType int type) { mType = type; return this; } - public Builder setStartTime(long startTime) { - mStartTime = startTime; + public Builder setStartTimeMs(long startTimeMs) { + mStartTimeMs = startTimeMs; + return this; + } + + public Builder setEndTimeMs(long endTimeMs) { + mEndTimeMs = endTimeMs; + return this; + } + + public Builder setSeasonNumber(String seasonNumber) { + mSeasonNumber = seasonNumber; + return this; + } + + public Builder setEpisodeNumber(String episodeNumber) { + mEpisodeNumber = episodeNumber; + return this; + } + + public Builder setEpisodeTitle(String episodeTitle) { + mEpisodeTitle = episodeTitle; + return this; + } + + public Builder setProgramDescription(String description) { + mProgramDescription = description; + return this; + } + + public Builder setProgramLongDescription(String longDescription) { + mProgramLongDescription = longDescription; return this; } - public Builder setEndTime(long endTime) { - mEndTime = endTime; + public Builder setProgramPosterArtUri(String programPosterArtUri) { + mProgramPosterArtUri = programPosterArtUri; + return this; + } + + public Builder setProgramThumbnailUri(String programThumbnailUri) { + mProgramThumbnailUri = programThumbnailUri; return this; } @@ -141,14 +262,16 @@ public final class ScheduledRecording { return this; } - public Builder setParentSeasonRecording(SeasonRecording parentSeasonRecording) { - mParentSeasonRecording = parentSeasonRecording; + public Builder setSeriesRecordingId(long seriesRecordingId) { + mSeriesRecordingId = seriesRecordingId; return this; } public ScheduledRecording build() { - return new ScheduledRecording(mId, mPriority, mChannelId, mProgramId, mType, mStartTime, - mEndTime, mState, mParentSeasonRecording); + return new ScheduledRecording(mId, mPriority, mInputId, mChannelId, mProgramId, + mProgramTitle, mType, mStartTimeMs, mEndTimeMs, mSeasonNumber, mEpisodeNumber, + mEpisodeTitle, mProgramDescription, mProgramLongDescription, + mProgramPosterArtUri, mProgramThumbnailUri, mState, mSeriesRecordingId); } } @@ -157,22 +280,36 @@ public final class ScheduledRecording { */ public static Builder buildFrom(ScheduledRecording orig) { return new Builder() - .setId(orig.mId).setChannelId(orig.mChannelId) - .setEndTime(orig.mEndTimeMs).setParentSeasonRecording(orig.mParentSeasonRecording) + .setId(orig.mId) + .setInputId(orig.mInputId) + .setChannelId(orig.mChannelId) + .setEndTimeMs(orig.mEndTimeMs) + .setSeriesRecordingId(orig.mSeriesRecordingId) .setProgramId(orig.mProgramId) - .setStartTime(orig.mStartTimeMs).setState(orig.mState).setType(orig.mType); + .setProgramTitle(orig.mProgramTitle) + .setStartTimeMs(orig.mStartTimeMs) + .setSeasonNumber(orig.getSeasonNumber()) + .setEpisodeNumber(orig.getEpisodeNumber()) + .setEpisodeTitle(orig.getEpisodeTitle()) + .setProgramDescription(orig.getProgramDescription()) + .setProgramLongDescription(orig.getProgramLongDescription()) + .setProgramPosterArtUri(orig.getProgramPosterArtUri()) + .setProgramThumbnailUri(orig.getProgramThumbnailUri()) + .setState(orig.mState).setType(orig.mType); } @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_RECORDING_NOT_STARTED, STATE_RECORDING_IN_PROGRESS, - STATE_RECORDING_UNEXPECTEDLY_STOPPED, STATE_RECORDING_FINISHED, STATE_RECORDING_FAILED}) + @IntDef({STATE_RECORDING_NOT_STARTED, STATE_RECORDING_IN_PROGRESS, STATE_RECORDING_FINISHED, + STATE_RECORDING_FAILED, STATE_RECORDING_CLIPPED, STATE_RECORDING_DELETED, + STATE_RECORDING_CANCELED}) public @interface RecordingState {} public static final int STATE_RECORDING_NOT_STARTED = 0; public static final int STATE_RECORDING_IN_PROGRESS = 1; - @Deprecated // It is not used. - public static final int STATE_RECORDING_UNEXPECTEDLY_STOPPED = 2; - public static final int STATE_RECORDING_FINISHED = 3; - public static final int STATE_RECORDING_FAILED = 4; + public static final int STATE_RECORDING_FINISHED = 2; + public static final int STATE_RECORDING_FAILED = 3; + public static final int STATE_RECORDING_CLIPPED = 4; + public static final int STATE_RECORDING_DELETED = 5; + public static final int STATE_RECORDING_CANCELED = 6; @Retention(RetentionPolicy.SOURCE) @IntDef({TYPE_TIMED, TYPE_PROGRAM}) @@ -180,27 +317,39 @@ public final class ScheduledRecording { /** * Record with given time range. */ - static final int TYPE_TIMED = 1; + public static final int TYPE_TIMED = 1; /** * Record with a given program. */ - static final int TYPE_PROGRAM = 2; + public static final int TYPE_PROGRAM = 2; @RecordingType private final int mType; /** - * Use this projection if you want to create {@link ScheduledRecording} object using {@link #fromCursor}. + * Use this projection if you want to create {@link ScheduledRecording} object using + * {@link #fromCursor}. */ public static final String[] PROJECTION = { - // Columns must match what is read in Recording.fromCursor() - DvrContract.Recordings._ID, - DvrContract.Recordings.COLUMN_PRIORITY, - DvrContract.Recordings.COLUMN_TYPE, - DvrContract.Recordings.COLUMN_CHANNEL_ID, - DvrContract.Recordings.COLUMN_PROGRAM_ID, - DvrContract.Recordings.COLUMN_START_TIME_UTC_MILLIS, - DvrContract.Recordings.COLUMN_END_TIME_UTC_MILLIS, - DvrContract.Recordings.COLUMN_STATE}; + // Columns must match what is read in #fromCursor + Schedules._ID, + Schedules.COLUMN_PRIORITY, + Schedules.COLUMN_TYPE, + Schedules.COLUMN_INPUT_ID, + Schedules.COLUMN_CHANNEL_ID, + Schedules.COLUMN_PROGRAM_ID, + Schedules.COLUMN_PROGRAM_TITLE, + Schedules.COLUMN_START_TIME_UTC_MILLIS, + Schedules.COLUMN_END_TIME_UTC_MILLIS, + Schedules.COLUMN_SEASON_NUMBER, + Schedules.COLUMN_EPISODE_NUMBER, + Schedules.COLUMN_EPISODE_TITLE, + Schedules.COLUMN_PROGRAM_DESCRIPTION, + Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION, + Schedules.COLUMN_PROGRAM_POST_ART_URI, + Schedules.COLUMN_PROGRAM_THUMBNAIL_URI, + Schedules.COLUMN_STATE, + Schedules.COLUMN_SERIES_RECORDING_ID}; + /** * Creates {@link ScheduledRecording} object from the given {@link Cursor}. */ @@ -210,65 +359,145 @@ public final class ScheduledRecording { .setId(c.getLong(++index)) .setPriority(c.getLong(++index)) .setType(recordingType(c.getString(++index))) + .setInputId(c.getString(++index)) .setChannelId(c.getLong(++index)) .setProgramId(c.getLong(++index)) - .setStartTime(c.getLong(++index)) - .setEndTime(c.getLong(++index)) + .setProgramTitle(c.getString(++index)) + .setStartTimeMs(c.getLong(++index)) + .setEndTimeMs(c.getLong(++index)) + .setSeasonNumber(c.getString(++index)) + .setEpisodeNumber(c.getString(++index)) + .setEpisodeTitle(c.getString(++index)) + .setProgramDescription(c.getString(++index)) + .setProgramLongDescription(c.getString(++index)) + .setProgramPosterArtUri(c.getString(++index)) + .setProgramThumbnailUri(c.getString(++index)) .setState(recordingState(c.getString(++index))) + .setSeriesRecordingId(c.getLong(++index)) .build(); } public static ContentValues toContentValues(ScheduledRecording r) { ContentValues values = new ContentValues(); - values.put(DvrContract.Recordings.COLUMN_CHANNEL_ID, r.getChannelId()); - values.put(DvrContract.Recordings.COLUMN_PROGRAM_ID, r.getProgramId()); - values.put(DvrContract.Recordings.COLUMN_PRIORITY, r.getPriority()); - values.put(DvrContract.Recordings.COLUMN_START_TIME_UTC_MILLIS, r.getStartTimeMs()); - values.put(DvrContract.Recordings.COLUMN_END_TIME_UTC_MILLIS, r.getEndTimeMs()); - values.put(DvrContract.Recordings.COLUMN_STATE, r.getState()); - values.put(DvrContract.Recordings.COLUMN_TYPE, r.getType()); + if (r.getId() != ID_NOT_SET) { + values.put(Schedules._ID, r.getId()); + } + values.put(Schedules.COLUMN_INPUT_ID, r.getInputId()); + values.put(Schedules.COLUMN_CHANNEL_ID, r.getChannelId()); + values.put(Schedules.COLUMN_PROGRAM_ID, r.getProgramId()); + values.put(Schedules.COLUMN_PROGRAM_TITLE, r.getProgramTitle()); + values.put(Schedules.COLUMN_PRIORITY, r.getPriority()); + values.put(Schedules.COLUMN_START_TIME_UTC_MILLIS, r.getStartTimeMs()); + values.put(Schedules.COLUMN_END_TIME_UTC_MILLIS, r.getEndTimeMs()); + values.put(Schedules.COLUMN_SEASON_NUMBER, r.getSeasonNumber()); + values.put(Schedules.COLUMN_EPISODE_NUMBER, r.getEpisodeNumber()); + values.put(Schedules.COLUMN_EPISODE_TITLE, r.getEpisodeTitle()); + values.put(Schedules.COLUMN_PROGRAM_DESCRIPTION, r.getProgramDescription()); + values.put(Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION, r.getProgramLongDescription()); + values.put(Schedules.COLUMN_PROGRAM_POST_ART_URI, r.getProgramPosterArtUri()); + values.put(Schedules.COLUMN_PROGRAM_THUMBNAIL_URI, r.getProgramThumbnailUri()); + values.put(Schedules.COLUMN_STATE, recordingState(r.getState())); + values.put(Schedules.COLUMN_TYPE, recordingType(r.getType())); + if (r.getSeriesRecordingId() != ID_NOT_SET) { + values.put(Schedules.COLUMN_SERIES_RECORDING_ID, r.getSeriesRecordingId()); + } else { + values.putNull(Schedules.COLUMN_SERIES_RECORDING_ID); + } return values; } + public static ScheduledRecording fromParcel(Parcel in) { + return new Builder() + .setId(in.readLong()) + .setPriority(in.readLong()) + .setInputId(in.readString()) + .setChannelId(in.readLong()) + .setProgramId(in.readLong()) + .setProgramTitle(in.readString()) + .setType(in.readInt()) + .setStartTimeMs(in.readLong()) + .setEndTimeMs(in.readLong()) + .setSeasonNumber(in.readString()) + .setEpisodeNumber(in.readString()) + .setEpisodeTitle(in.readString()) + .setProgramDescription(in.readString()) + .setProgramLongDescription(in.readString()) + .setProgramPosterArtUri(in.readString()) + .setProgramThumbnailUri(in.readString()) + .setState(in.readInt()) + .setSeriesRecordingId(in.readLong()) + .build(); + } + + public static final Parcelable.Creator<ScheduledRecording> CREATOR = + new Parcelable.Creator<ScheduledRecording>() { + @Override + public ScheduledRecording createFromParcel(Parcel in) { + return ScheduledRecording.fromParcel(in); + } + + @Override + public ScheduledRecording[] newArray(int size) { + return new ScheduledRecording[size]; + } + }; + /** * The ID internal to Live TV */ - private final long mId; + private long mId; /** * The priority of this recording. * - * <p> The lowest number is recorded first. If there is a tie in priority then the lower id + * <p> The highest number is recorded first. If there is a tie in priority then the higher id * wins. */ private final long mPriority; - + private final String mInputId; private final long mChannelId; /** * Optional id of the associated program. - * */ private final long mProgramId; + private final String mProgramTitle; private final long mStartTimeMs; private final long mEndTimeMs; + private final String mSeasonNumber; + private final String mEpisodeNumber; + private final String mEpisodeTitle; + private final String mProgramDescription; + private final String mProgramLongDescription; + private final String mProgramPosterArtUri; + private final String mProgramThumbnailUri; @RecordingState private final int mState; + private final long mSeriesRecordingId; - private final SeasonRecording mParentSeasonRecording; - - private ScheduledRecording(long id, long priority, long channelId, long programId, - @RecordingType int type, long startTime, long endTime, - @RecordingState int state, SeasonRecording parentSeasonRecording) { + private ScheduledRecording(long id, long priority, String inputId, long channelId, long programId, + String programTitle, @RecordingType int type, long startTime, long endTime, + String seasonNumber, String episodeNumber, String episodeTitle, + String programDescription, String programLongDescription, String programPosterArtUri, + String programThumbnailUri, @RecordingState int state, long seriesRecordingId) { mId = id; mPriority = priority; + mInputId = inputId; mChannelId = channelId; mProgramId = programId; + mProgramTitle = programTitle; mType = type; mStartTimeMs = startTime; mEndTimeMs = endTime; + mSeasonNumber = seasonNumber; + mEpisodeNumber = episodeNumber; + mEpisodeTitle = episodeTitle; + mProgramDescription = programDescription; + mProgramLongDescription = programLongDescription; + mProgramPosterArtUri = programPosterArtUri; + mProgramThumbnailUri = programThumbnailUri; mState = state; - mParentSeasonRecording = parentSeasonRecording; + mSeriesRecordingId = seriesRecordingId; } /** @@ -281,6 +510,13 @@ public final class ScheduledRecording { } /** + * Returns schedules' input id. + */ + public String getInputId() { + return mInputId; + } + + /** * Returns recorded {@link Channel}. */ public long getChannelId() { @@ -295,6 +531,13 @@ public final class ScheduledRecording { } /** + * Return the optional program Title + */ + public String getProgramTitle() { + return mProgramTitle; + } + + /** * Returns started time. */ public long getStartTimeMs() { @@ -309,6 +552,55 @@ public final class ScheduledRecording { } /** + * Returns the season number. + */ + public String getSeasonNumber() { + return mSeasonNumber; + } + + /** + * Returns the episode number. + */ + public String getEpisodeNumber() { + return mEpisodeNumber; + } + + /** + * Returns the episode title. + */ + public String getEpisodeTitle() { + return mEpisodeTitle; + } + + /** + * Returns the description of program. + */ + public String getProgramDescription() { + return mProgramDescription; + } + + /** + * Returns the long description of program. + */ + public String getProgramLongDescription() { + return mProgramLongDescription; + } + + /** + * Returns the poster uri of program. + */ + public String getProgramPosterArtUri() { + return mProgramPosterArtUri; + } + + /** + * Returns the thumb nail uri of program. + */ + public String getProgramThumbnailUri() { + return mProgramThumbnailUri; + } + + /** * Returns duration. */ public long getDuration() { @@ -316,43 +608,83 @@ public final class ScheduledRecording { } /** - * Returns the state. The possible states are {@link #STATE_RECORDING_FINISHED}, - * {@link #STATE_RECORDING_IN_PROGRESS} and {@link #STATE_RECORDING_UNEXPECTEDLY_STOPPED}. + * Returns the state. The possible states are {@link #STATE_RECORDING_NOT_STARTED}, + * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_FINISHED}, + * {@link #STATE_RECORDING_FAILED}, {@link #STATE_RECORDING_CLIPPED} and + * {@link #STATE_RECORDING_DELETED}. */ @RecordingState public int getState() { return mState; } /** - * Returns {@link SeasonRecording} including this schedule. + * Returns the ID of the {@link SeriesRecording} including this schedule. */ - public SeasonRecording getParentSeasonRecording() { - return mParentSeasonRecording; + public long getSeriesRecordingId() { + return mSeriesRecordingId; } public long getId() { return mId; } + /** + * Sets the ID; + */ + public void setId(long id) { + mId = id; + } + public long getPriority() { return mPriority; } /** + * Returns season number, episode number and episode title for display. + */ + public String getEpisodeDisplayTitle(Context context) { + if (!TextUtils.isEmpty(mEpisodeNumber)) { + String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle; + if (TextUtils.equals(mSeasonNumber, "0")) { + // Do not show "S0: ". + return String.format(context.getResources().getString( + R.string.display_episode_title_format_no_season_number), + mEpisodeNumber, episodeTitle); + } else { + return String.format(context.getResources().getString( + R.string.display_episode_title_format), + mSeasonNumber, mEpisodeNumber, episodeTitle); + } + } + return mEpisodeTitle; + } + + /** + * Returns the program's title withe its season and episode number. + */ + public String getProgramTitleWithEpisodeNumber(Context context) { + if (TextUtils.isEmpty(mProgramTitle)) { + return mProgramTitle; + } + if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) { + return TextUtils.isEmpty(mEpisodeNumber) ? mProgramTitle : context.getString( + R.string.program_title_with_episode_number_no_season, mProgramTitle, + mEpisodeNumber); + } else { + return context.getString(R.string.program_title_with_episode_number, mProgramTitle, + mSeasonNumber, mEpisodeNumber); + } + } + + + /** * Converts a string to a @RecordingType int, defaulting to {@link #TYPE_TIMED}. */ private static @RecordingType int recordingType(String type) { - int t; - try { - t = Integer.valueOf(type); - } catch (NullPointerException | NumberFormatException e) { - SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type); - return TYPE_TIMED; - } - switch (t) { - case TYPE_TIMED: + switch (type) { + case Schedules.TYPE_TIMED: return TYPE_TIMED; - case TYPE_PROGRAM: + case Schedules.TYPE_PROGRAM: return TYPE_PROGRAM; default: SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type); @@ -361,28 +693,40 @@ public final class ScheduledRecording { } /** + * Converts a @RecordingType int to a string, defaulting to {@link Schedules#TYPE_TIMED}. + */ + private static String recordingType(@RecordingType int type) { + switch (type) { + case TYPE_TIMED: + return Schedules.TYPE_TIMED; + case TYPE_PROGRAM: + return Schedules.TYPE_PROGRAM; + default: + SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type); + return Schedules.TYPE_TIMED; + } + } + + /** * Converts a string to a @RecordingState int, defaulting to * {@link #STATE_RECORDING_NOT_STARTED}. */ private static @RecordingState int recordingState(String state) { - int s; - try { - s = Integer.valueOf(state); - } catch (NullPointerException | NumberFormatException e) { - SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state); - return STATE_RECORDING_NOT_STARTED; - } - switch (s) { - case STATE_RECORDING_NOT_STARTED: + switch (state) { + case Schedules.STATE_RECORDING_NOT_STARTED: return STATE_RECORDING_NOT_STARTED; - case STATE_RECORDING_IN_PROGRESS: + case Schedules.STATE_RECORDING_IN_PROGRESS: return STATE_RECORDING_IN_PROGRESS; - case STATE_RECORDING_FINISHED: + case Schedules.STATE_RECORDING_FINISHED: return STATE_RECORDING_FINISHED; - case STATE_RECORDING_UNEXPECTEDLY_STOPPED: - return STATE_RECORDING_UNEXPECTEDLY_STOPPED; - case STATE_RECORDING_FAILED: + case Schedules.STATE_RECORDING_FAILED: return STATE_RECORDING_FAILED; + case Schedules.STATE_RECORDING_CLIPPED: + return STATE_RECORDING_CLIPPED; + case Schedules.STATE_RECORDING_DELETED: + return STATE_RECORDING_DELETED; + case Schedules.STATE_RECORDING_CANCELED: + return STATE_RECORDING_CANCELED; default: SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state); return STATE_RECORDING_NOT_STARTED; @@ -390,20 +734,140 @@ public final class ScheduledRecording { } /** + * Converts a @RecordingState int to string, defaulting to + * {@link Schedules#STATE_RECORDING_NOT_STARTED}. + */ + private static String recordingState(@RecordingState int state) { + switch (state) { + case STATE_RECORDING_NOT_STARTED: + return Schedules.STATE_RECORDING_NOT_STARTED; + case STATE_RECORDING_IN_PROGRESS: + return Schedules.STATE_RECORDING_IN_PROGRESS; + case STATE_RECORDING_FINISHED: + return Schedules.STATE_RECORDING_FINISHED; + case STATE_RECORDING_FAILED: + return Schedules.STATE_RECORDING_FAILED; + case STATE_RECORDING_CLIPPED: + return Schedules.STATE_RECORDING_CLIPPED; + case STATE_RECORDING_DELETED: + return Schedules.STATE_RECORDING_DELETED; + case STATE_RECORDING_CANCELED: + return Schedules.STATE_RECORDING_CANCELED; + default: + SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state); + return Schedules.STATE_RECORDING_NOT_STARTED; + } + } + + /** * Checks if the {@code period} overlaps with the recording time. */ public boolean isOverLapping(Range<Long> period) { - return mStartTimeMs <= period.getUpper() && mEndTimeMs >= period.getLower(); + return mStartTimeMs < period.getUpper() && mEndTimeMs > period.getLower(); } @Override public String toString() { return "ScheduledRecording[" + mId + "]" - + "(startTime=" + Utils.toIsoDateTimeString(mStartTimeMs) + + "(inputId=" + mInputId + + ",channelId=" + mChannelId + + ",programId=" + mProgramId + + ",programTitle=" + mProgramTitle + + ",type=" + mType + + ",startTime=" + Utils.toIsoDateTimeString(mStartTimeMs) + ",endTime=" + Utils.toIsoDateTimeString(mEndTimeMs) + + ",seasonNumber=" + mSeasonNumber + + ",episodeNumber=" + mEpisodeNumber + + ",episodeTitle=" + mEpisodeTitle + + ",programDescription=" + mProgramDescription + + ",programLongDescription=" + mProgramLongDescription + + ",programPosterArtUri=" + mProgramPosterArtUri + + ",programThumbnailUri=" + mProgramThumbnailUri + ",state=" + mState + ",priority=" + mPriority + + ",seriesRecordingId=" + mSeriesRecordingId + ")"; } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int paramInt) { + out.writeLong(mId); + out.writeLong(mPriority); + out.writeString(mInputId); + out.writeLong(mChannelId); + out.writeLong(mProgramId); + out.writeString(mProgramTitle); + out.writeInt(mType); + out.writeLong(mStartTimeMs); + out.writeLong(mEndTimeMs); + out.writeString(mSeasonNumber); + out.writeString(mEpisodeNumber); + out.writeString(mEpisodeTitle); + out.writeString(mProgramDescription); + out.writeString(mProgramLongDescription); + out.writeString(mProgramPosterArtUri); + out.writeString(mProgramThumbnailUri); + out.writeInt(mState); + out.writeLong(mSeriesRecordingId); + } + + /** + * Returns {@code true} if the recording is not started yet, otherwise @{code false}. + */ + public boolean isNotStarted() { + return mState == STATE_RECORDING_NOT_STARTED; + } + + /** + * Returns {@code true} if the recording is in progress, otherwise @{code false}. + */ + public boolean isInProgress() { + return mState == STATE_RECORDING_IN_PROGRESS; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof ScheduledRecording)) { + return false; + } + ScheduledRecording r = (ScheduledRecording) obj; + return mId == r.mId + && mPriority == r.mPriority + && mChannelId == r.mChannelId + && mProgramId == r.mProgramId + && Objects.equals(mProgramTitle, r.mProgramTitle) + && mType == r.mType + && mStartTimeMs == r.mStartTimeMs + && mEndTimeMs == r.mEndTimeMs + && Objects.equals(mSeasonNumber, r.mSeasonNumber) + && Objects.equals(mEpisodeNumber, r.mEpisodeNumber) + && Objects.equals(mEpisodeTitle, r.mEpisodeTitle) + && Objects.equals(mProgramDescription, r.getProgramDescription()) + && Objects.equals(mProgramLongDescription, r.getProgramLongDescription()) + && Objects.equals(mProgramPosterArtUri, r.getProgramPosterArtUri()) + && Objects.equals(mProgramThumbnailUri, r.getProgramThumbnailUri()) + && mState == r.mState + && mSeriesRecordingId == r.mSeriesRecordingId; + } + + @Override + public int hashCode() { + return Objects.hash(mId, mPriority, mChannelId, mProgramId, mProgramTitle, mType, + mStartTimeMs, mEndTimeMs, mSeasonNumber, mEpisodeNumber, mEpisodeTitle, + mProgramDescription, mProgramLongDescription, mProgramPosterArtUri, + mProgramThumbnailUri, mState, mSeriesRecordingId); + } + + /** + * Returns an array containing all of the elements in the list. + */ + public static ScheduledRecording[] toArray(Collection<ScheduledRecording> schedules) { + return schedules.toArray(new ScheduledRecording[schedules.size()]); + } } diff --git a/src/com/android/tv/dvr/Scheduler.java b/src/com/android/tv/dvr/Scheduler.java index ff9bde68..25904ee4 100644 --- a/src/com/android/tv/dvr/Scheduler.java +++ b/src/com/android/tv/dvr/Scheduler.java @@ -20,86 +20,118 @@ import android.app.AlarmManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.os.Handler; +import android.media.tv.TvInputInfo; +import android.media.tv.TvInputManager.TvInputCallback; import android.os.Looper; -import android.os.Message; +import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; import android.util.Log; -import android.util.LongSparseArray; import android.util.Range; -import com.android.tv.data.Channel; +import com.android.tv.InputSessionManager; import com.android.tv.data.ChannelDataManager; +import com.android.tv.data.ChannelDataManager.Listener; +import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; import com.android.tv.util.Clock; +import com.android.tv.util.TvInputManagerHelper; +import com.android.tv.util.Utils; +import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; /** * The core class to manage schedule and run actual recording. */ -@VisibleForTesting -public class Scheduler implements DvrDataManager.ScheduledRecordingListener { +@MainThread +public class Scheduler extends TvInputCallback implements ScheduledRecordingListener { private static final String TAG = "Scheduler"; private static final boolean DEBUG = false; private final static long SOON_DURATION_IN_MS = TimeUnit.MINUTES.toMillis(5); @VisibleForTesting final static long MS_TO_WAKE_BEFORE_START = TimeUnit.MINUTES.toMillis(1); - /** - * Wraps a {@link RecordingTask} removing it from {@link #mPendingRecordings} when it is done. - */ - public final class HandlerWrapper extends Handler { - public static final int MESSAGE_REMOVE = 999; - private final long mId; - - HandlerWrapper(Looper looper, ScheduledRecording scheduledRecording, RecordingTask recordingTask) { - super(looper, recordingTask); - mId = scheduledRecording.getId(); - } - - @Override - public void handleMessage(Message msg) { - // The RecordingTask gets a chance first. - // It must return false to pass this message to here. - if (msg.what == MESSAGE_REMOVE) { - if (DEBUG) Log.d(TAG, "done " + mId); - mPendingRecordings.remove(mId); - } - removeCallbacksAndMessages(null); - super.handleMessage(msg); - } - } - - private final LongSparseArray<HandlerWrapper> mPendingRecordings = new LongSparseArray<>(); private final Looper mLooper; - private final DvrSessionManager mSessionManager; + private final InputSessionManager mSessionManager; private final WritableDvrDataManager mDataManager; private final DvrManager mDvrManager; private final ChannelDataManager mChannelDataManager; + private final TvInputManagerHelper mInputManager; private final Context mContext; private final Clock mClock; private final AlarmManager mAlarmManager; - public Scheduler(Looper looper, DvrManager dvrManager, DvrSessionManager sessionManager, + private final Map<String, InputTaskScheduler> mInputSchedulerMap = new ArrayMap<>(); + private long mLastStartTimePendingMs; + + public Scheduler(Looper looper, DvrManager dvrManager, InputSessionManager sessionManager, WritableDvrDataManager dataManager, ChannelDataManager channelDataManager, - Context context, Clock clock, + TvInputManagerHelper inputManager, Context context, Clock clock, AlarmManager alarmManager) { mLooper = looper; mDvrManager = dvrManager; mSessionManager = sessionManager; mDataManager = dataManager; mChannelDataManager = channelDataManager; + mInputManager = inputManager; mContext = context; mClock = clock; mAlarmManager = alarmManager; } + /** + * Starts the scheduler. + */ + public void start() { + mDataManager.addScheduledRecordingListener(this); + mInputManager.addCallback(this); + if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { + updateInternal(); + } else { + if (!mDataManager.isDvrScheduleLoadFinished()) { + mDataManager.addDvrScheduleLoadFinishedListener( + new OnDvrScheduleLoadFinishedListener() { + @Override + public void onDvrScheduleLoadFinished() { + mDataManager.removeDvrScheduleLoadFinishedListener(this); + updateInternal(); + } + }); + } + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener(new Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + updateInternal(); + } + + @Override + public void onChannelListUpdated() { } + + @Override + public void onChannelBrowsableChanged() { } + }); + } + } + } + + /** + * Stops the scheduler. + */ + public void stop() { + mInputManager.removeCallback(this); + mDataManager.removeScheduledRecordingListener(this); + } + private void updatePendingRecordings() { - List<ScheduledRecording> scheduledRecordings = mDataManager.getRecordingsThatOverlapWith( - new Range(mClock.currentTimeMillis(), - mClock.currentTimeMillis() + SOON_DURATION_IN_MS)); - // TODO(DVR): handle removing and updating exiting recordings. + List<ScheduledRecording> scheduledRecordings = mDataManager + .getScheduledRecordings(new Range<>(mLastStartTimePendingMs, + mClock.currentTimeMillis() + SOON_DURATION_IN_MS), + ScheduledRecording.STATE_RECORDING_NOT_STARTED); for (ScheduledRecording r : scheduledRecordings) { scheduleRecordingSoon(r); } @@ -110,70 +142,150 @@ public class Scheduler implements DvrDataManager.ScheduledRecordingListener { */ public void update() { if (DEBUG) Log.d(TAG, "update"); - updatePendingRecordings(); - updateNextAlarm(); + updateInternal(); } - @Override - public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) { - if (DEBUG) Log.d(TAG, "added " + scheduledRecording); - if (startsWithin(scheduledRecording, SOON_DURATION_IN_MS)) { - scheduleRecordingSoon(scheduledRecording); - } else { + private void updateInternal() { + if (isInitialized()) { + updatePendingRecordings(); updateNextAlarm(); } } + private boolean isInitialized() { + return mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished(); + } + @Override - public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) { - long id = scheduledRecording.getId(); - HandlerWrapper wrapper = mPendingRecordings.get(id); - if (wrapper != null) { - wrapper.removeCallbacksAndMessages(null); - mPendingRecordings.remove(id); - } else { + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { + if (DEBUG) Log.d(TAG, "added " + Arrays.asList(schedules)); + if (!isInitialized()) { + return; + } + handleScheduleChange(schedules); + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { + if (DEBUG) Log.d(TAG, "removed " + Arrays.asList(schedules)); + if (!isInitialized()) { + return; + } + boolean needToUpdateAlarm = false; + for (ScheduledRecording schedule : schedules) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + if (input == null) { + Log.e(TAG, "Can't find input for " + schedule); + mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED); + continue; + } + InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId()); + if (scheduler != null) { + scheduler.removeSchedule(schedule); + needToUpdateAlarm = true; + } + } + if (needToUpdateAlarm) { updateNextAlarm(); } } @Override - public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) { - //TODO(DVR): implement + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + if (DEBUG) Log.d(TAG, "state changed " + Arrays.asList(schedules)); + if (!isInitialized()) { + return; + } + // Update the recordings. + for (ScheduledRecording schedule : schedules) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + if (input == null) { + Log.e(TAG, "Can't find input for " + schedule); + continue; + } + InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId()); + if (scheduler != null) { + scheduler.updateSchedule(schedule); + } + } + handleScheduleChange(schedules); } - private void scheduleRecordingSoon(ScheduledRecording scheduledRecording) { - Channel channel = mChannelDataManager.getChannel(scheduledRecording.getChannelId()); - RecordingTask recordingTask = new RecordingTask(scheduledRecording, channel, mDvrManager, - mSessionManager, mDataManager, mClock); - HandlerWrapper handlerWrapper = new HandlerWrapper(mLooper, scheduledRecording, - recordingTask); - recordingTask.setHandler(handlerWrapper); - mPendingRecordings.put(scheduledRecording.getId(), handlerWrapper); - handlerWrapper.sendEmptyMessage(RecordingTask.MESSAGE_INIT); + private void handleScheduleChange(ScheduledRecording... schedules) { + boolean needToUpdateAlarm = false; + for (ScheduledRecording schedule : schedules) { + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + if (startsWithin(schedule, SOON_DURATION_IN_MS)) { + scheduleRecordingSoon(schedule); + } else { + needToUpdateAlarm = true; + } + } + } + if (needToUpdateAlarm) { + updateNextAlarm(); + } + } + + private void scheduleRecordingSoon(ScheduledRecording schedule) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, schedule.getChannelId()); + if (input == null) { + Log.e(TAG, "Can't find input for " + schedule); + mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED); + return; + } + if (!input.canRecord() || input.getTunerCount() <= 0) { + Log.e(TAG, "TV input doesn't support recording: " + input); + mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED); + return; + } + InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId()); + if (scheduler == null) { + scheduler = new InputTaskScheduler(mContext, input, mLooper, mChannelDataManager, + mDvrManager, mDataManager, mSessionManager, mClock); + mInputSchedulerMap.put(input.getId(), scheduler); + } + scheduler.addSchedule(schedule); + if (mLastStartTimePendingMs < schedule.getStartTimeMs()) { + mLastStartTimePendingMs = schedule.getStartTimeMs(); + } } private void updateNextAlarm() { - long lastStartTimePending = getLastStartTimePending(); - long nextStartTime = mDataManager.getNextScheduledStartTimeAfter(lastStartTimePending); + long nextStartTime = mDataManager.getNextScheduledStartTimeAfter( + Math.max(mLastStartTimePendingMs, mClock.currentTimeMillis())); if (nextStartTime != DvrDataManager.NEXT_START_TIME_NOT_FOUND) { long wakeAt = nextStartTime - MS_TO_WAKE_BEFORE_START; if (DEBUG) Log.d(TAG, "Set alarm to record at " + wakeAt); Intent intent = new Intent(mContext, DvrStartRecordingReceiver.class); PendingIntent alarmIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0); - //This will cancel the previous alarm. + // This will cancel the previous alarm. mAlarmManager.set(AlarmManager.RTC_WAKEUP, wakeAt, alarmIntent); } else { if (DEBUG) Log.d(TAG, "No future recording, alarm not set"); } } - private long getLastStartTimePending() { - // TODO(DVR): implement - return mClock.currentTimeMillis(); - } - @VisibleForTesting boolean startsWithin(ScheduledRecording scheduledRecording, long durationInMs) { return mClock.currentTimeMillis() >= scheduledRecording.getStartTimeMs() - durationInMs; } + + // No need to remove input task scheduler when the input is removed. If the input is removed + // temporarily, the scheduler should keep the non-started schedules. + @Override + public void onInputUpdated(String inputId) { + InputTaskScheduler scheduler = mInputSchedulerMap.get(inputId); + if (scheduler != null) { + scheduler.updateTvInputInfo(Utils.getTvInputInfoForInputId(mContext, inputId)); + } + } + + @Override + public void onTvInputInfoUpdated(TvInputInfo input) { + InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId()); + if (scheduler != null) { + scheduler.updateTvInputInfo(input); + } + } } diff --git a/src/com/android/tv/dvr/SeasonRecording.java b/src/com/android/tv/dvr/SeasonRecording.java deleted file mode 100644 index 7f89e135..00000000 --- a/src/com/android/tv/dvr/SeasonRecording.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.tv.dvr; - -import java.util.List; - -/** - * A data class for one recorded contents. - */ -public class SeasonRecording { - private static final String TAG = "Recording"; - - /** - * Constant for all season. - */ - private static final int ALL_SEASON = -1; - - private List<ScheduledRecording> mSchedule; - private String mTitle; - private int mSeasonNumber; -} diff --git a/src/com/android/tv/dvr/SeriesInfo.java b/src/com/android/tv/dvr/SeriesInfo.java new file mode 100644 index 00000000..30256dc5 --- /dev/null +++ b/src/com/android/tv/dvr/SeriesInfo.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr; + +/** + * Series information. + */ +public class SeriesInfo { + private final String mId; + private final String mTitle; + private final String mDescription; + private final String mLongDescription; + private final int[] mCanonicalGenreIds; + private final String mPosterUri; + private final String mPhotoUri; + + public SeriesInfo(String id, String title, String description, String longDescription, + int[] canonicalGenreIds, String posterUri, String photoUri) { + this.mId = id; + this.mTitle = title; + this.mDescription = description; + this.mLongDescription = longDescription; + this.mCanonicalGenreIds = canonicalGenreIds; + this.mPosterUri = posterUri; + this.mPhotoUri = photoUri; + } + + /** Returns the ID. **/ + public String getId() { + return mId; + } + + /** Returns the title. **/ + public String getTitle() { + return mTitle; + } + + /** Returns the description. **/ + public String getDescription() { + return mDescription; + } + + /** Returns the description. **/ + public String getLongDescription() { + return mLongDescription; + } + + /** Returns the canonical genre IDs. **/ + public int[] getCanonicalGenreIds() { + return mCanonicalGenreIds; + } + + /** Returns the poster URI. **/ + public String getPosterUri() { + return mPosterUri; + } + + /** Returns the photo URI. **/ + public String getPhotoUri() { + return mPhotoUri; + } +} diff --git a/src/com/android/tv/dvr/SeriesRecording.java b/src/com/android/tv/dvr/SeriesRecording.java new file mode 100644 index 00000000..fc68eaf7 --- /dev/null +++ b/src/com/android/tv/dvr/SeriesRecording.java @@ -0,0 +1,749 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.content.ContentValues; +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; + +import com.android.tv.data.Program; +import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; +import com.android.tv.util.Utils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; + +/** + * Schedules the recording of a Series of Programs. + * + * <p> + * Contains the data needed to create new ScheduleRecordings as the programs become available in + * the EPG. + */ +public class SeriesRecording implements Parcelable { + /** + * Indicates that the ID is not assigned yet. + */ + public static final long ID_NOT_SET = 0; + + /** + * The default priority of this recording. + */ + public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef(flag = true, + value = {OPTION_CHANNEL_ONE, OPTION_CHANNEL_ALL}) + public @interface ChannelOption {} + /** + * An option which indicates that the episodes in one channel are recorded. + */ + public static final int OPTION_CHANNEL_ONE = 0; + /** + * An option which indicates that the episodes in all the channels are recorded. + */ + public static final int OPTION_CHANNEL_ALL = 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef(flag = true, + value = {STATE_SERIES_NORMAL, STATE_SERIES_CANCELED}) + private @interface SeriesState {} + + /** + * The state indicates that the series recording is a normal one. + */ + public static final int STATE_SERIES_NORMAL = 0; + + /** + * The state indicates that the series recording is canceled. + */ + public static final int STATE_SERIES_CANCELED = 1; + + /** + * Compare priority in descending order. + */ + public static final Comparator<SeriesRecording> PRIORITY_COMPARATOR = + new Comparator<SeriesRecording>() { + @Override + public int compare(SeriesRecording lhs, SeriesRecording rhs) { + int value = Long.compare(rhs.mPriority, lhs.mPriority); + if (value == 0) { + // New recording has the higher priority. + value = Long.compare(rhs.mId, lhs.mId); + } + return value; + } + }; + + /** + * Compare ID in ascending order. + */ + public static final Comparator<SeriesRecording> ID_COMPARATOR = + new Comparator<SeriesRecording>() { + @Override + public int compare(SeriesRecording lhs, SeriesRecording rhs) { + return Long.compare(lhs.mId, rhs.mId); + } + }; + + /** + * Creates a new Builder with the values set from the series information of {@link Program}. + */ + public static Builder builder(String inputId, Program p) { + return new Builder() + .setInputId(inputId) + .setSeriesId(p.getSeriesId()) + .setChannelId(p.getChannelId()) + .setTitle(p.getTitle()) + .setDescription(p.getDescription()) + .setLongDescription(p.getLongDescription()) + .setCanonicalGenreIds(p.getCanonicalGenreIds()) + .setPosterUri(p.getPosterArtUri()) + .setPhotoUri(p.getThumbnailUri()); + } + + /** + * Creates a new Builder with the values set from an existing {@link SeriesRecording}. + */ + @VisibleForTesting + public static Builder buildFrom(SeriesRecording r) { + return new Builder() + .setId(r.mId) + .setInputId(r.getInputId()) + .setChannelId(r.getChannelId()) + .setPriority(r.getPriority()) + .setTitle(r.getTitle()) + .setDescription(r.getDescription()) + .setLongDescription(r.getLongDescription()) + .setSeriesId(r.getSeriesId()) + .setStartFromEpisode(r.getStartFromEpisode()) + .setStartFromSeason(r.getStartFromSeason()) + .setChannelOption(r.getChannelOption()) + .setCanonicalGenreIds(r.getCanonicalGenreIds()) + .setPosterUri(r.getPosterUri()) + .setPhotoUri(r.getPhotoUri()) + .setState(r.getState()); + } + + /** + * Use this projection if you want to create {@link SeriesRecording} object using + * {@link #fromCursor}. + */ + public static final String[] PROJECTION = { + // Columns must match what is read in fromCursor() + SeriesRecordings._ID, + SeriesRecordings.COLUMN_INPUT_ID, + SeriesRecordings.COLUMN_CHANNEL_ID, + SeriesRecordings.COLUMN_PRIORITY, + SeriesRecordings.COLUMN_TITLE, + SeriesRecordings.COLUMN_SHORT_DESCRIPTION, + SeriesRecordings.COLUMN_LONG_DESCRIPTION, + SeriesRecordings.COLUMN_SERIES_ID, + SeriesRecordings.COLUMN_START_FROM_EPISODE, + SeriesRecordings.COLUMN_START_FROM_SEASON, + SeriesRecordings.COLUMN_CHANNEL_OPTION, + SeriesRecordings.COLUMN_CANONICAL_GENRE, + SeriesRecordings.COLUMN_POSTER_URI, + SeriesRecordings.COLUMN_PHOTO_URI, + SeriesRecordings.COLUMN_STATE + }; + /** + * Creates {@link SeriesRecording} object from the given {@link Cursor}. + */ + public static SeriesRecording fromCursor(Cursor c) { + int index = -1; + return new Builder() + .setId(c.getLong(++index)) + .setInputId(c.getString(++index)) + .setChannelId(c.getLong(++index)) + .setPriority(c.getLong(++index)) + .setTitle(c.getString(++index)) + .setDescription(c.getString(++index)) + .setLongDescription(c.getString(++index)) + .setSeriesId(c.getString(++index)) + .setStartFromEpisode(c.getInt(++index)) + .setStartFromSeason(c.getInt(++index)) + .setChannelOption(channelOption(c.getString(++index))) + .setCanonicalGenreIds(c.getString(++index)) + .setPosterUri(c.getString(++index)) + .setPhotoUri(c.getString(++index)) + .setState(seriesRecordingCanceled(c.getString(++index))) + .build(); + } + + /** + * Returns the ContentValues with keys as the columns specified in {@link SeriesRecordings} + * and the values from {@code r}. + */ + public static ContentValues toContentValues(SeriesRecording r) { + ContentValues values = new ContentValues(); + if (r.getId() != ID_NOT_SET) { + values.put(SeriesRecordings._ID, r.getId()); + } else { + values.putNull(SeriesRecordings._ID); + } + values.put(SeriesRecordings.COLUMN_INPUT_ID, r.getInputId()); + values.put(SeriesRecordings.COLUMN_CHANNEL_ID, r.getChannelId()); + values.put(SeriesRecordings.COLUMN_PRIORITY, r.getPriority()); + values.put(SeriesRecordings.COLUMN_TITLE, r.getTitle()); + values.put(SeriesRecordings.COLUMN_SHORT_DESCRIPTION, r.getDescription()); + values.put(SeriesRecordings.COLUMN_LONG_DESCRIPTION, r.getLongDescription()); + values.put(SeriesRecordings.COLUMN_SERIES_ID, r.getSeriesId()); + values.put(SeriesRecordings.COLUMN_START_FROM_EPISODE, r.getStartFromEpisode()); + values.put(SeriesRecordings.COLUMN_START_FROM_SEASON, r.getStartFromSeason()); + values.put(SeriesRecordings.COLUMN_CHANNEL_OPTION, + channelOption(r.getChannelOption())); + values.put(SeriesRecordings.COLUMN_CANONICAL_GENRE, + Utils.getCanonicalGenre(r.getCanonicalGenreIds())); + values.put(SeriesRecordings.COLUMN_POSTER_URI, r.getPosterUri()); + values.put(SeriesRecordings.COLUMN_PHOTO_URI, r.getPhotoUri()); + values.put(SeriesRecordings.COLUMN_STATE, seriesRecordingCanceled(r.getState())); + return values; + } + + private static String channelOption(@ChannelOption int option) { + switch (option) { + case OPTION_CHANNEL_ONE: + return SeriesRecordings.OPTION_CHANNEL_ONE; + case OPTION_CHANNEL_ALL: + return SeriesRecordings.OPTION_CHANNEL_ALL; + } + return SeriesRecordings.OPTION_CHANNEL_ONE; + } + + @ChannelOption private static int channelOption(String option) { + switch (option) { + case SeriesRecordings.OPTION_CHANNEL_ONE: + return OPTION_CHANNEL_ONE; + case SeriesRecordings.OPTION_CHANNEL_ALL: + return OPTION_CHANNEL_ALL; + } + return OPTION_CHANNEL_ONE; + } + + private static String seriesRecordingCanceled(@SeriesState int state) { + switch (state) { + case STATE_SERIES_NORMAL: + return SeriesRecordings.STATE_SERIES_NORMAL; + case STATE_SERIES_CANCELED: + return SeriesRecordings.STATE_SERIES_CANCELED; + } + return SeriesRecordings.STATE_SERIES_NORMAL; + } + + @SeriesState private static int seriesRecordingCanceled(String state) { + switch (state) { + case SeriesRecordings.STATE_SERIES_NORMAL: + return STATE_SERIES_NORMAL; + case SeriesRecordings.STATE_SERIES_CANCELED: + return STATE_SERIES_CANCELED; + } + return STATE_SERIES_NORMAL; + } + + /** + * Builder for {@link SeriesRecording}. + */ + public static class Builder { + private long mId = ID_NOT_SET; + private long mPriority = DvrScheduleManager.DEFAULT_SERIES_PRIORITY; + private String mTitle; + private String mDescription; + private String mLongDescription; + private String mInputId; + private long mChannelId; + private String mSeriesId; + private int mStartFromSeason = SeriesRecordings.THE_BEGINNING; + private int mStartFromEpisode = SeriesRecordings.THE_BEGINNING; + private int mChannelOption = OPTION_CHANNEL_ONE; + private int[] mCanonicalGenreIds; + private String mPosterUri; + private String mPhotoUri; + private int mState = SeriesRecording.STATE_SERIES_NORMAL; + + /** + * @see #getId() + */ + public Builder setId(long id) { + mId = id; + return this; + } + + /** + * @see #getPriority() () + */ + public Builder setPriority(long priority) { + mPriority = priority; + return this; + } + + /** + * @see #getTitle() + */ + public Builder setTitle(String title) { + mTitle = title; + return this; + } + + /** + * @see #getDescription() + */ + public Builder setDescription(String description) { + mDescription = description; + return this; + } + + /** + * @see #getLongDescription() + */ + public Builder setLongDescription(String longDescription) { + mLongDescription = longDescription; + return this; + } + + /** + * @see #getInputId() + */ + public Builder setInputId(String inputId) { + mInputId = inputId; + return this; + } + /** + * @see #getChannelId() + */ + public Builder setChannelId(long channelId) { + mChannelId = channelId; + return this; + } + + /** + * @see #getSeriesId() + */ + public Builder setSeriesId(String seriesId) { + mSeriesId = seriesId; + return this; + } + + /** + * @see #getStartFromSeason() + */ + public Builder setStartFromSeason(int startFromSeason) { + mStartFromSeason = startFromSeason; + return this; + } + + /** + * @see #getChannelOption() + */ + public Builder setChannelOption(@ChannelOption int option) { + mChannelOption = option; + return this; + } + + /** + * @see #getStartFromEpisode() + */ + public Builder setStartFromEpisode(int startFromEpisode) { + mStartFromEpisode = startFromEpisode; + return this; + } + + /** + * @see #getCanonicalGenreIds() + */ + public Builder setCanonicalGenreIds(String genres) { + mCanonicalGenreIds = Utils.getCanonicalGenreIds(genres); + return this; + } + + /** + * @see #getCanonicalGenreIds() + */ + public Builder setCanonicalGenreIds(int[] canonicalGenreIds) { + mCanonicalGenreIds = canonicalGenreIds; + return this; + } + + /** + * @see #getPosterUri() + */ + public Builder setPosterUri(String posterUri) { + mPosterUri = posterUri; + return this; + } + + /** + * @see #getPhotoUri() + */ + public Builder setPhotoUri(String photoUri) { + mPhotoUri = photoUri; + return this; + } + + /** + * @see #getState() + */ + public Builder setState(@SeriesState int state) { + mState = state; + return this; + } + + /** + * Creates a new {@link SeriesRecording}. + */ + public SeriesRecording build() { + return new SeriesRecording(mId, mPriority, mTitle, mDescription, mLongDescription, + mInputId, mChannelId, mSeriesId, mStartFromSeason, mStartFromEpisode, + mChannelOption, mCanonicalGenreIds, mPosterUri, mPhotoUri, mState); + } + } + + public static SeriesRecording fromParcel(Parcel in) { + return new Builder() + .setId(in.readLong()) + .setPriority(in.readLong()) + .setTitle(in.readString()) + .setDescription(in.readString()) + .setLongDescription(in.readString()) + .setInputId(in.readString()) + .setChannelId(in.readLong()) + .setSeriesId(in.readString()) + .setStartFromSeason(in.readInt()) + .setStartFromEpisode(in.readInt()) + .setChannelOption(in.readInt()) + .setCanonicalGenreIds(in.createIntArray()) + .setPosterUri(in.readString()) + .setPhotoUri(in.readString()) + .setState(in.readInt()) + .build(); + } + + public static final Parcelable.Creator<SeriesRecording> CREATOR = + new Parcelable.Creator<SeriesRecording>() { + @Override + public SeriesRecording createFromParcel(Parcel in) { + return SeriesRecording.fromParcel(in); + } + + @Override + public SeriesRecording[] newArray(int size) { + return new SeriesRecording[size]; + } + }; + + private long mId; + private final long mPriority; + private final String mTitle; + private final String mDescription; + private final String mLongDescription; + private final String mInputId; + private final long mChannelId; + private final String mSeriesId; + private final int mStartFromSeason; + private final int mStartFromEpisode; + @ChannelOption private final int mChannelOption; + private final int[] mCanonicalGenreIds; + private final String mPosterUri; + private final String mPhotoUri; + @SeriesState private int mState; + + /** + * The input id of this SeriesRecording. + */ + public String getInputId() { + return mInputId; + } + + /** + * The channelId to match. + */ + public long getChannelId() { + return mChannelId; + } + + /** + * The id of this SeriesRecording. + */ + public long getId() { + return mId; + } + + /** + * Sets the ID. + */ + public void setId(long id) { + mId = id; + } + + /** + * The priority of this recording. + * + * <p> The highest number is recorded first. If there is a tie in mPriority then the higher mId + * wins. + */ + public long getPriority() { + return mPriority; + } + + /** + * The series title. + */ + public String getTitle() { + return mTitle; + } + + /** + * The series description. + */ + public String getDescription() { + return mDescription; + } + + /** + * The long series description. + */ + public String getLongDescription() { + return mLongDescription; + } + + /** + * SeriesId when not null is used to match programs instead of using title and channelId. + * + * <p>SeriesId is an opaque but stable string. + */ + @NonNull + public String getSeriesId() { + return mSeriesId; + } + + /** + * If not == {@link SeriesRecordings#THE_BEGINNING} and seasonNumber == startFromSeason then + * only record episodes with a episodeNumber >= this + */ + public int getStartFromEpisode() { + return mStartFromEpisode; + } + + /** + * If not == {@link SeriesRecordings#THE_BEGINNING} then only record episodes with a + * seasonNumber >= this + */ + public int getStartFromSeason() { + return mStartFromSeason; + } + + /** + * Returns the channel recording option. + */ + @ChannelOption public int getChannelOption() { + return mChannelOption; + } + + /** + * Returns the canonical genre ID's. + */ + public int[] getCanonicalGenreIds() { + return mCanonicalGenreIds; + } + + /** + * Returns the poster URI. + */ + public String getPosterUri() { + return mPosterUri; + } + + /** + * Returns the photo URI. + */ + public String getPhotoUri() { + return mPhotoUri; + } + + /** + * Returns the state of series recording. + */ + @SeriesState public int getState() { + return mState; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SeriesRecording)) return false; + SeriesRecording that = (SeriesRecording) o; + return mPriority == that.mPriority + && mChannelId == that.mChannelId + && mStartFromSeason == that.mStartFromSeason + && mStartFromEpisode == that.mStartFromEpisode + && Objects.equals(mId, that.mId) + && Objects.equals(mTitle, that.mTitle) + && Objects.equals(mDescription, that.mDescription) + && Objects.equals(mLongDescription, that.mLongDescription) + && Objects.equals(mSeriesId, that.mSeriesId) + && mChannelOption == that.mChannelOption + && Arrays.equals(mCanonicalGenreIds, that.mCanonicalGenreIds) + && Objects.equals(mPosterUri, that.mPosterUri) + && Objects.equals(mPhotoUri, that.mPhotoUri) + && mState == that.mState; + } + + @Override + public int hashCode() { + return Objects.hash(mPriority, mChannelId, mStartFromSeason, mStartFromEpisode, mId, + mTitle, mDescription, mLongDescription, mSeriesId, mChannelOption, + mCanonicalGenreIds, mPosterUri, mPhotoUri, mState); + } + + @Override + public String toString() { + return "SeriesRecording{" + + "inputId=" + mInputId + + ", channelId=" + mChannelId + + ", id='" + mId + '\'' + + ", priority=" + mPriority + + ", title='" + mTitle + '\'' + + ", description='" + mDescription + '\'' + + ", longDescription='" + mLongDescription + '\'' + + ", startFromSeason=" + mStartFromSeason + + ", startFromEpisode=" + mStartFromEpisode + + ", channelOption=" + mChannelOption + + ", canonicalGenreIds=" + Arrays.toString(mCanonicalGenreIds) + + ", posterUri=" + mPosterUri + + ", photoUri=" + mPhotoUri + + ", state=" + mState + + '}'; + } + + private SeriesRecording(long id, long priority, String title, String description, + String longDescription, String inputId, long channelId, String seriesId, + int startFromSeason, int startFromEpisode, int channelOption, int[] canonicalGenreIds, + String posterUri, String photoUri, int state) { + this.mId = id; + this.mPriority = priority; + this.mTitle = title; + this.mDescription = description; + this.mLongDescription = longDescription; + this.mInputId = inputId; + this.mChannelId = channelId; + this.mSeriesId = seriesId; + this.mStartFromSeason = startFromSeason; + this.mStartFromEpisode = startFromEpisode; + this.mChannelOption = channelOption; + this.mCanonicalGenreIds = canonicalGenreIds; + this.mPosterUri = posterUri; + this.mPhotoUri = photoUri; + this.mState = state; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int paramInt) { + out.writeLong(mId); + out.writeLong(mPriority); + out.writeString(mTitle); + out.writeString(mDescription); + out.writeString(mLongDescription); + out.writeString(mInputId); + out.writeLong(mChannelId); + out.writeString(mSeriesId); + out.writeInt(mStartFromSeason); + out.writeInt(mStartFromEpisode); + out.writeInt(mChannelOption); + out.writeIntArray(mCanonicalGenreIds); + out.writeString(mPosterUri); + out.writeString(mPhotoUri); + out.writeInt(mState); + } + + /** + * Returns an array containing all of the elements in the list. + */ + public static SeriesRecording[] toArray(Collection<SeriesRecording> series) { + return series.toArray(new SeriesRecording[series.size()]); + } + + /** + * Returns {@code true} if the {@code program} is part of the series and meets the season and + * episode constraints. + */ + public boolean matchProgram(Program program) { + return matchProgram(program, true); + } + + /** + * Returns {@code true} if the {@code program} is part of the series and meets the season and + * episode constraints. It checks the channel option only if {@code checkChannelOption} is + * {@code true}. + */ + public boolean matchProgram(Program program, boolean checkChannelOption) { + String seriesId = program.getSeriesId(); + long channelId = program.getChannelId(); + String seasonNumber = program.getSeasonNumber(); + String episodeNumber = program.getEpisodeNumber(); + if (!mSeriesId.equals(seriesId) || (checkChannelOption + && mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE + && mChannelId != channelId)) { + return false; + } + // Season number and episode number matches if + // start_season_number < program_season_number + // || (start_season_number == program_season_number + // && start_episode_number <= program_episode_number). + if (mStartFromSeason == SeriesRecordings.THE_BEGINNING + || TextUtils.isEmpty(seasonNumber)) { + return true; + } else { + int intSeasonNumber; + try { + intSeasonNumber = Integer.valueOf(seasonNumber); + } catch (NumberFormatException e) { + return true; + } + if (intSeasonNumber > mStartFromSeason) { + return true; + } else if (intSeasonNumber < mStartFromSeason) { + return false; + } + } + if (mStartFromEpisode == SeriesRecordings.THE_BEGINNING + || TextUtils.isEmpty(episodeNumber)) { + return true; + } else { + int intEpisodeNumber; + try { + intEpisodeNumber = Integer.valueOf(episodeNumber); + } catch (NumberFormatException e) { + return true; + } + return intEpisodeNumber >= mStartFromEpisode; + } + } +} diff --git a/src/com/android/tv/dvr/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/SeriesRecordingScheduler.java new file mode 100644 index 00000000..9e9b3add --- /dev/null +++ b/src/com/android/tv/dvr/SeriesRecordingScheduler.java @@ -0,0 +1,705 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import android.util.ArraySet; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.common.CollectionUtils; +import com.android.tv.common.SharedPreferencesUtils; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Program; +import com.android.tv.data.epg.EpgFetcher; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; +import com.android.tv.experiments.Experiments; +import com.android.tv.util.AsyncDbTask.AsyncProgramQueryTask; +import com.android.tv.util.AsyncDbTask.CursorFilter; +import com.android.tv.util.PermissionUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Set; + +/** + * Creates the {@link ScheduledRecording}s for the {@link SeriesRecording}. + * <p> + * The current implementation assumes that the series recordings are scheduled only for one channel. + */ +@TargetApi(Build.VERSION_CODES.N) +public class SeriesRecordingScheduler { + private static final String TAG = "SeriesRecordingSchd"; + + private static final int PROGRAM_ID_INDEX = Program.getColumnIndex(Programs._ID); + private static final int RECORDING_PROHIBITED_INDEX = + Program.getColumnIndex(Programs.COLUMN_RECORDING_PROHIBITED); + + private static final String PARAM_START_TIME = "start_time"; + private static final String PARAM_END_TIME = "end_time"; + + private static final String PROGRAM_SELECTION = + Programs.COLUMN_START_TIME_UTC_MILLIS + ">? AND (" + + Programs.COLUMN_SEASON_DISPLAY_NUMBER + " IS NOT NULL OR " + + Programs.COLUMN_EPISODE_DISPLAY_NUMBER + " IS NOT NULL) AND " + + Programs.COLUMN_RECORDING_PROHIBITED + "=0"; + private static final String CHANNEL_ID_PREDICATE = Programs.COLUMN_CHANNEL_ID + "=?"; + + private static final String KEY_FETCHED_SERIES_IDS = + "SeriesRecordingScheduler.fetched_series_ids"; + + @SuppressLint("StaticFieldLeak") + private static SeriesRecordingScheduler sInstance; + + /** + * Creates and returns the {@link SeriesRecordingScheduler}. + */ + public static synchronized SeriesRecordingScheduler getInstance(Context context) { + if (sInstance == null) { + sInstance = new SeriesRecordingScheduler(context); + } + return sInstance; + } + + private final Context mContext; + private final DvrManager mDvrManager; + private final WritableDvrDataManager mDataManager; + private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>(); + private final List<FetchSeriesInfoTask> mFetchSeriesInfoTasks = new ArrayList<>(); + private final Set<String> mFetchedSeriesIds = new ArraySet<>(); + private final SharedPreferences mSharedPreferences; + private boolean mStarted; + + private final SeriesRecordingListener mSeriesRecordingListener = new SeriesRecordingListener() { + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + executeFetchSeriesInfoTask(seriesRecording); + } + } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + // Cancel the update. + for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); + iter.hasNext(); ) { + SeriesRecordingUpdateTask task = iter.next(); + if (CollectionUtils.subtract(task.mSeriesRecordings, seriesRecordings, + SeriesRecording.ID_COMPARATOR).isEmpty()) { + task.cancel(true); + iter.remove(); + } + } + } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + updateSchedules(Arrays.asList(seriesRecordings)); + } + }; + + private final ScheduledRecordingListener mScheduledRecordingListener = + new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { + // No need to update series recordings when the new schedule is added. + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { + handleScheduledRecordingChange(Arrays.asList(schedules)); + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + List<ScheduledRecording> schedulesForUpdate = new ArrayList<>(); + for (ScheduledRecording r : schedules) { + if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED + || r.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED) + && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET + && !TextUtils.isEmpty(r.getSeasonNumber()) + && !TextUtils.isEmpty(r.getEpisodeNumber())) { + schedulesForUpdate.add(r); + } + } + if (!schedulesForUpdate.isEmpty()) { + handleScheduledRecordingChange(schedulesForUpdate); + } + } + + private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) { + if (schedules.isEmpty()) { + return; + } + Set<Long> seriesRecordingIds = new HashSet<>(); + for (ScheduledRecording r : schedules) { + if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) { + SoftPreconditions.checkState(r.getState() + != ScheduledRecording.STATE_RECORDING_FINISHED); + seriesRecordingIds.add(r.getSeriesRecordingId()); + } + } + if (!seriesRecordingIds.isEmpty()) { + List<SeriesRecording> seriesRecordings = new ArrayList<>(); + for (Long id : seriesRecordingIds) { + SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id); + if (seriesRecording != null) { + seriesRecordings.add(seriesRecording); + } + } + if (!seriesRecordings.isEmpty()) { + updateSchedules(seriesRecordings); + } + } + } + }; + + private SeriesRecordingScheduler(Context context) { + mContext = context.getApplicationContext(); + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mDvrManager = appSingletons.getDvrManager(); + mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager(); + mSharedPreferences = context.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE); + mFetchedSeriesIds.addAll(mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS, + Collections.emptySet())); + } + + /** + * Starts the scheduler. + */ + @MainThread + public void start() { + SoftPreconditions.checkState(mDataManager.isInitialized()); + if (mStarted) { + return; + } + mStarted = true; + mDataManager.addSeriesRecordingListener(mSeriesRecordingListener); + mDataManager.addScheduledRecordingListener(mScheduledRecordingListener); + startFetchingSeriesInfo(); + updateSchedules(mDataManager.getSeriesRecordings()); + } + + @MainThread + public void stop() { + if (!mStarted) { + return; + } + mStarted = false; + for (FetchSeriesInfoTask task : mFetchSeriesInfoTasks) { + task.cancel(true); + } + for (SeriesRecordingUpdateTask task : mScheduleTasks) { + task.cancel(true); + } + mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); + mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener); + } + + private void startFetchingSeriesInfo() { + for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) { + if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) { + executeFetchSeriesInfoTask(seriesRecording); + } + } + } + + private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) { + if (Experiments.CLOUD_EPG.get()) { + FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording); + task.execute(); + mFetchSeriesInfoTasks.add(task); + } + } + + /** + * Creates/Updates the schedules for all the series recordings. + */ + @MainThread + public void updateSchedules() { + if (!mStarted) { + return; + } + updateSchedules(mDataManager.getSeriesRecordings()); + } + + private void updateSchedules(Collection<SeriesRecording> seriesRecordings) { + Set<SeriesRecording> previousSeriesRecordings = new HashSet<>(); + for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); + iter.hasNext(); ) { + SeriesRecordingUpdateTask task = iter.next(); + if (CollectionUtils.containsAny(task.mSeriesRecordings, seriesRecordings, + SeriesRecording.ID_COMPARATOR)) { + // The task is affected by the seriesRecordings + task.cancel(true); + previousSeriesRecordings.addAll(task.mSeriesRecordings); + iter.remove(); + } + } + List<SeriesRecording> seriesRecordingsToUpdate = CollectionUtils.union(seriesRecordings, + previousSeriesRecordings, SeriesRecording.ID_COMPARATOR); + for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator(); + iter.hasNext(); ) { + if (mDataManager.getSeriesRecording(iter.next().getId()) == null) { + // Series recording has been removed. + iter.remove(); + } + } + if (seriesRecordingsToUpdate.isEmpty()) { + return; + } + List<SeriesRecordingUpdateTask> tasksToRun = new ArrayList<>(); + if (needToReadAllChannels(seriesRecordingsToUpdate)) { + SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask(seriesRecordingsToUpdate, + createSqlParams(seriesRecordingsToUpdate, null)); + tasksToRun.add(task); + mScheduleTasks.add(task); + } else { + for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { + SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask( + Collections.singletonList(seriesRecording), + createSqlParams(Collections.singletonList(seriesRecording), null)); + tasksToRun.add(task); + mScheduleTasks.add(task); + } + } + if (mDataManager.isDvrScheduleLoadFinished()) { + runTasks(tasksToRun); + } + } + + private void runTasks(List<SeriesRecordingUpdateTask> tasks) { + for (SeriesRecordingUpdateTask task : tasks) { + task.executeOnDbThread(); + } + } + + private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) { + for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { + if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) { + return true; + } + } + return false; + } + + /** + * Queries the programs which are related to the series. + * <p> + * This is called from the UI when the series recording is created. + */ + public void queryPrograms(SeriesRecording series, ProgramLoadCallback callback) { + SoftPreconditions.checkState(mDataManager.isInitialized()); + Set<ScheduledEpisode> scheduledEpisodes = new HashSet<>(); + for (RecordedProgram recordedProgram : mDataManager.getRecordedPrograms()) { + if (series.getSeriesId().equals(recordedProgram.getSeriesId())) { + scheduledEpisodes.add(new ScheduledEpisode(series.getId(), + recordedProgram.getSeasonNumber(), recordedProgram.getEpisodeNumber())); + } + } + SqlParams sqlParams = createSqlParams(Collections.singletonList(series), scheduledEpisodes); + new AsyncProgramQueryTask(mContext.getContentResolver(), sqlParams.uri, sqlParams.selection, + sqlParams.selectionArgs, null, sqlParams.filter) { + @Override + protected void onPostExecute(List<Program> programs) { + SoftPreconditions.checkNotNull(programs); + if (programs == null) { + Log.e(TAG, "Creating schedules for series recording failed: " + series); + callback.onProgramLoadFinished(Collections.emptyList()); + } else { + Map<Long, List<Program>> seriesProgramMap = pickOneProgramPerEpisode( + Collections.singletonList(series), programs); + callback.onProgramLoadFinished(seriesProgramMap.get(series.getId())); + } + } + }.executeOnDbThread(); + // To shorten the response time from UI, cancel and restart the background job. + restartTasks(); + } + + private void restartTasks() { + Set<SeriesRecording> seriesRecordings = new HashSet<>(); + for (SeriesRecordingUpdateTask task : mScheduleTasks) { + seriesRecordings.addAll(task.mSeriesRecordings); + task.cancel(true); + } + mScheduleTasks.clear(); + updateSchedules(seriesRecordings); + } + + private SqlParams createSqlParams(List<SeriesRecording> seriesRecordings, + Set<ScheduledEpisode> scheduledEpisodes) { + SqlParams sqlParams = new SqlParams(); + if (PermissionUtils.hasAccessAllEpg(mContext)) { + sqlParams.uri = Programs.CONTENT_URI; + if (needToReadAllChannels(seriesRecordings)) { + sqlParams.selection = PROGRAM_SELECTION; + sqlParams.selectionArgs = new String[] {Long.toString(System.currentTimeMillis())}; + } else { + SoftPreconditions.checkArgument(seriesRecordings.size() == 1); + sqlParams.selection = PROGRAM_SELECTION + " AND " + CHANNEL_ID_PREDICATE; + sqlParams.selectionArgs = new String[] {Long.toString(System.currentTimeMillis()), + Long.toString(seriesRecordings.get(0).getChannelId())}; + } + sqlParams.filter = new SeriesRecordingCursorFilter(seriesRecordings, scheduledEpisodes); + } else { + if (needToReadAllChannels(seriesRecordings)) { + sqlParams.uri = Programs.CONTENT_URI.buildUpon() + .appendQueryParameter(PARAM_START_TIME, + String.valueOf(System.currentTimeMillis())) + .appendQueryParameter(PARAM_END_TIME, String.valueOf(Long.MAX_VALUE)) + .build(); + } else { + SoftPreconditions.checkArgument(seriesRecordings.size() == 1); + sqlParams.uri = TvContract.buildProgramsUriForChannel( + seriesRecordings.get(0).getChannelId(), + System.currentTimeMillis(), Long.MAX_VALUE); + } + sqlParams.selection = null; + sqlParams.selectionArgs = null; + sqlParams.filter = new SeriesRecordingCursorFilterForNonSystem(seriesRecordings, + scheduledEpisodes); + } + return sqlParams; + } + + @VisibleForTesting + static boolean isEpisodeScheduled(Collection<ScheduledEpisode> scheduledEpisodes, + ScheduledEpisode episode) { + // The episode whose season number or episode number is null will always be scheduled. + return scheduledEpisodes.contains(episode) && !TextUtils.isEmpty(episode.seasonNumber) + && !TextUtils.isEmpty(episode.episodeNumber); + } + + /** + * Pick one program per an episode. + * + * <p>Note that the programs which has been already scheduled have the highest priority, and all + * of them are added even though they are the same episodes. That's because the schedules + * should be added to the series recording. + * <p>If there are no existing schedules for an episode, one program which starts earlier is + * picked. + */ + private Map<Long, List<Program>> pickOneProgramPerEpisode( + List<SeriesRecording> seriesRecordings, List<Program> programs) { + return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs); + } + + /** + * @see #pickOneProgramPerEpisode(List, List) + */ + @VisibleForTesting + static Map<Long, List<Program>> pickOneProgramPerEpisode(DvrDataManager dataManager, + List<SeriesRecording> seriesRecordings, List<Program> programs) { + // Initialize. + Map<Long, List<Program>> result = new HashMap<>(); + Map<String, Long> seriesRecordingIds = new HashMap<>(); + for (SeriesRecording seriesRecording : seriesRecordings) { + result.put(seriesRecording.getId(), new ArrayList<>()); + seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId()); + } + // Group programs by the episode. + Map<ScheduledEpisode, List<Program>> programsForEpisodeMap = new HashMap<>(); + for (Program program : programs) { + long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId()); + if (TextUtils.isEmpty(program.getSeasonNumber()) + || TextUtils.isEmpty(program.getEpisodeNumber())) { + // Add all the programs if it doesn't have season number or episode number. + result.get(seriesRecordingId).add(program); + continue; + } + ScheduledEpisode episode = new ScheduledEpisode(seriesRecordingId, + program.getSeasonNumber(), program.getEpisodeNumber()); + List<Program> programsForEpisode = programsForEpisodeMap.get(episode); + if (programsForEpisode == null) { + programsForEpisode = new ArrayList<>(); + programsForEpisodeMap.put(episode, programsForEpisode); + } + programsForEpisode.add(program); + } + // Pick one program. + for (Entry<ScheduledEpisode, List<Program>> entry : programsForEpisodeMap.entrySet()) { + List<Program> programsForEpisode = entry.getValue(); + Collections.sort(programsForEpisode, new Comparator<Program>() { + @Override + public int compare(Program lhs, Program rhs) { + // Place the existing schedule first. + boolean lhsScheduled = isProgramScheduled(dataManager, lhs); + boolean rhsScheduled = isProgramScheduled(dataManager, rhs); + if (lhsScheduled && !rhsScheduled) { + return -1; + } + if (!lhsScheduled && rhsScheduled) { + return 1; + } + // Sort by the start time in ascending order. + return lhs.compareTo(rhs); + } + }); + boolean added = false; + // Add all the scheduled programs + List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId); + for (Program program : programsForEpisode) { + if (isProgramScheduled(dataManager, program)) { + programsForSeries.add(program); + added = true; + } else if (!added) { + programsForSeries.add(program); + break; + } + } + } + return result; + } + + private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) { + ScheduledRecording schedule = + dataManager.getScheduledRecordingForProgramId(program.getId()); + return schedule != null && schedule.getState() + == ScheduledRecording.STATE_RECORDING_NOT_STARTED; + } + + private void updateFetchedSeries() { + mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply(); + } + + /** + * This works only for the existing series recordings. Do not use this task for the + * "adding series recording" UI. + */ + private class SeriesRecordingUpdateTask extends AsyncProgramQueryTask { + private final List<SeriesRecording> mSeriesRecordings = new ArrayList<>(); + + SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings, SqlParams sqlParams) { + super(mContext.getContentResolver(), sqlParams.uri, sqlParams.selection, + sqlParams.selectionArgs, null, sqlParams.filter); + mSeriesRecordings.addAll(seriesRecordings); + } + + @Override + protected void onPostExecute(List<Program> programs) { + mScheduleTasks.remove(this); + if (programs == null) { + Log.e(TAG, "Creating schedules for series recording failed: " + mSeriesRecordings); + return; + } + Map<Long, List<Program>> seriesProgramMap = pickOneProgramPerEpisode( + mSeriesRecordings, programs); + for (SeriesRecording seriesRecording : mSeriesRecordings) { + // Check the series recording is still valid. + if (mDataManager.getSeriesRecording(seriesRecording.getId()) == null) { + continue; + } + List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId()); + if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null + && !programsToSchedule.isEmpty()) { + mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule); + } + } + } + + @Override + protected void onCancelled(List<Program> programs) { + mScheduleTasks.remove(this); + } + } + + /** + * Filter the programs which match the series recording. The episodes which the schedules are + * already created for are filtered out too. + */ + private class SeriesRecordingCursorFilter implements CursorFilter { + private final List<SeriesRecording> mSeriesRecording = new ArrayList<>(); + private final Set<Long> mDisallowedProgramIds = new HashSet<>(); + private final Set<ScheduledEpisode> mScheduledEpisodes = new HashSet<>(); + + SeriesRecordingCursorFilter(List<SeriesRecording> seriesRecordings, + Set<ScheduledEpisode> scheduledEpisodes) { + mSeriesRecording.addAll(seriesRecordings); + mDisallowedProgramIds.addAll(mDataManager.getDisallowedProgramIds()); + Set<Long> seriesRecordingIds = new HashSet<>(); + for (SeriesRecording r : seriesRecordings) { + seriesRecordingIds.add(r.getId()); + } + if (scheduledEpisodes != null) { + mScheduledEpisodes.addAll(scheduledEpisodes); + } + for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) { + if (seriesRecordingIds.contains(r.getSeriesRecordingId()) + && r.getState() != ScheduledRecording.STATE_RECORDING_FAILED + && r.getState() != ScheduledRecording.STATE_RECORDING_CLIPPED) { + mScheduledEpisodes.add(new ScheduledEpisode(r)); + } + } + } + + @Override + @WorkerThread + public boolean filter(Cursor c) { + if (mDisallowedProgramIds.contains(c.getLong(PROGRAM_ID_INDEX))) { + return false; + } + Program program = Program.fromCursor(c); + for (SeriesRecording seriesRecording : mSeriesRecording) { + boolean programMatches = seriesRecording.matchProgram(program); + if (programMatches && !isEpisodeScheduled(mScheduledEpisodes, new ScheduledEpisode( + seriesRecording.getId(), program.getSeasonNumber(), + program.getEpisodeNumber()))) { + return true; + } + } + return false; + } + } + + private class SeriesRecordingCursorFilterForNonSystem extends SeriesRecordingCursorFilter { + SeriesRecordingCursorFilterForNonSystem(List<SeriesRecording> seriesRecordings, + Set<ScheduledEpisode> scheduledEpisodes) { + super(seriesRecordings, scheduledEpisodes); + } + + @Override + public boolean filter(Cursor c) { + return c.getInt(RECORDING_PROHIBITED_INDEX) != 0 && super.filter(c); + } + } + + private static class SqlParams { + public Uri uri; + public String selection; + public String[] selectionArgs; + public CursorFilter filter; + } + + @VisibleForTesting + static class ScheduledEpisode { + public final long seriesRecordingId; + public final String seasonNumber; + public final String episodeNumber; + + /** + * Create a new Builder with the values set from an existing {@link ScheduledRecording}. + */ + ScheduledEpisode(ScheduledRecording r) { + this(r.getSeriesRecordingId(), r.getSeasonNumber(), r.getEpisodeNumber()); + } + + public ScheduledEpisode(long seriesRecordingId, String seasonNumber, String episodeNumber) { + this.seriesRecordingId = seriesRecordingId; + this.seasonNumber = seasonNumber; + this.episodeNumber = episodeNumber; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ScheduledEpisode)) return false; + ScheduledEpisode that = (ScheduledEpisode) o; + return seriesRecordingId == that.seriesRecordingId + && Objects.equals(seasonNumber, that.seasonNumber) + && Objects.equals(episodeNumber, that.episodeNumber); + } + + @Override + public int hashCode() { + return Objects.hash(seriesRecordingId, seasonNumber, episodeNumber); + } + + @Override + public String toString() { + return "ScheduledEpisode{" + + "seriesRecordingId=" + seriesRecordingId + + ", seasonNumber='" + seasonNumber + + ", episodeNumber=" + episodeNumber + + '}'; + } + } + + private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> { + private SeriesRecording mSeriesRecording; + + FetchSeriesInfoTask(SeriesRecording seriesRecording) { + mSeriesRecording = seriesRecording; + } + + String getSeriesId() { + return mSeriesRecording.getSeriesId(); + } + + @Override + protected SeriesInfo doInBackground(Void... voids) { + return EpgFetcher.createEpgReader(mContext) + .getSeriesInfo(mSeriesRecording.getSeriesId()); + } + + @Override + protected void onPostExecute(SeriesInfo seriesInfo) { + if (seriesInfo != null) { + mDataManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording) + .setTitle(seriesInfo.getTitle()) + .setDescription(seriesInfo.getDescription()) + .setLongDescription(seriesInfo.getLongDescription()) + .setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds()) + .setPosterUri(seriesInfo.getPosterUri()) + .setPhotoUri(seriesInfo.getPhotoUri()) + .build()); + mFetchedSeriesIds.add(seriesInfo.getId()); + updateFetchedSeries(); + } + mFetchSeriesInfoTasks.remove(this); + } + + @Override + protected void onCancelled(SeriesInfo seriesInfo) { + mFetchSeriesInfoTasks.remove(this); + } + } + + /** + * Called when the program loading is finished for the series recording. + */ + public interface ProgramLoadCallback { + void onProgramLoadFinished(@NonNull List<Program> programs); + } +} diff --git a/src/com/android/tv/dvr/WritableDvrDataManager.java b/src/com/android/tv/dvr/WritableDvrDataManager.java index 0b8a4c99..382f7112 100644 --- a/src/com/android/tv/dvr/WritableDvrDataManager.java +++ b/src/com/android/tv/dvr/WritableDvrDataManager.java @@ -18,6 +18,8 @@ package com.android.tv.dvr; import android.support.annotation.MainThread; +import com.android.tv.dvr.ScheduledRecording.RecordingState; + /** * Full data manager. * @@ -27,27 +29,39 @@ import android.support.annotation.MainThread; @MainThread interface WritableDvrDataManager extends DvrDataManager { /** - * Add a new recording. + * Adds new recordings. + */ + void addScheduledRecording(ScheduledRecording... scheduledRecordings); + + /** + * Adds new series recordings. + */ + void addSeriesRecording(SeriesRecording... seriesRecordings); + + /** + * Removes recordings. */ - void addScheduledRecording(ScheduledRecording scheduledRecording); + void removeScheduledRecording(ScheduledRecording... scheduledRecordings); /** - * Add a season recording/ + * Removes series recordings. + * + * <p>Note that the finished or failed schedules are not deleted. */ - void addSeasonRecording(SeasonRecording seasonRecording); + void removeSeriesRecording(SeriesRecording... seasonSchedules); /** - * Remove a recording. + * Updates existing recordings. */ - void removeScheduledRecording(ScheduledRecording ScheduledRecording); + void updateScheduledRecording(ScheduledRecording... scheduledRecordings); /** - * Remove a season schedule. + * Updates existing series recordings. */ - void removeSeasonSchedule(SeasonRecording seasonSchedule); + void updateSeriesRecording(SeriesRecording... seriesRecordings); /** - * Update an existing recording. + * Changes the state of the recording. */ - void updateScheduledRecording(ScheduledRecording r); + void changeState(ScheduledRecording scheduledRecording, @RecordingState int newState); } diff --git a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java index 6058aa54..1a12fb23 100644 --- a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java +++ b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java @@ -22,7 +22,9 @@ import android.os.AsyncTask; import android.support.annotation.Nullable; import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.dvr.provider.DvrContract.Recordings; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.provider.DvrContract.Schedules; +import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; import com.android.tv.util.NamedThreadFactory; import java.util.ArrayList; @@ -76,61 +78,59 @@ public abstract class AsyncDvrDbTask<Params, Progress, Result> protected abstract Result doInDvrBackground(Params... params); /** - * Inserts recordings returning the list of recordings with id set. - * The id will be -1 if there was an error. + * Inserts schedules. */ - public abstract static class AsyncAddRecordingTask - extends AsyncDvrDbTask<ScheduledRecording, Void, List<ScheduledRecording>> { - - public AsyncAddRecordingTask(Context context) { + public static class AsyncAddScheduleTask + extends AsyncDvrDbTask<ScheduledRecording, Void, Void> { + public AsyncAddScheduleTask(Context context) { super(context); } @Override - protected final List<ScheduledRecording> doInDvrBackground(ScheduledRecording... params) { - return sDbHelper.insertRecordings(params); + protected final Void doInDvrBackground(ScheduledRecording... params) { + sDbHelper.insertSchedules(params); + return null; } } /** - * Update recordings. - * - * @return list of row update counts. The count will be -1 if there was an error or 0 - * if no match was found. The count is expected to be exactly 1 for each recording. + * Update schedules. */ - public abstract static class AsyncUpdateRecordingTask - extends AsyncDvrDbTask<ScheduledRecording, Void, List<Integer>> { - public AsyncUpdateRecordingTask(Context context) { + public static class AsyncUpdateScheduleTask + extends AsyncDvrDbTask<ScheduledRecording, Void, Void> { + public AsyncUpdateScheduleTask(Context context) { super(context); } @Override - protected final List<Integer> doInDvrBackground(ScheduledRecording... params) { - return sDbHelper.updateRecordings(params); + protected final Void doInDvrBackground(ScheduledRecording... params) { + sDbHelper.updateSchedules(params); + return null; } } /** - * Delete recordings. - * - * @return list of row delete counts. The count will be -1 if there was an error or 0 - * if no match was found. The count is expected to be exactly 1 for each recording. + * Delete schedules. */ - public abstract static class AsyncDeleteRecordingTask - extends AsyncDvrDbTask<ScheduledRecording, Void, List<Integer>> { - public AsyncDeleteRecordingTask(Context context) { + public static class AsyncDeleteScheduleTask + extends AsyncDvrDbTask<ScheduledRecording, Void, Void> { + public AsyncDeleteScheduleTask(Context context) { super(context); } @Override - protected final List<Integer> doInDvrBackground(ScheduledRecording... params) { - return sDbHelper.deleteRecordings(params); + protected final Void doInDvrBackground(ScheduledRecording... params) { + sDbHelper.deleteSchedules(params); + return null; } } - public abstract static class AsyncDvrQueryTask + /** + * Returns all {@link ScheduledRecording}s. + */ + public abstract static class AsyncDvrQueryScheduleTask extends AsyncDvrDbTask<Void, Void, List<ScheduledRecording>> { - public AsyncDvrQueryTask(Context context) { + public AsyncDvrQueryScheduleTask(Context context) { super(context); } @@ -140,17 +140,84 @@ public abstract class AsyncDvrDbTask<Params, Progress, Result> if (isCancelled()) { return null; } - - if (isCancelled()) { - return null; + List<ScheduledRecording> scheduledRecordings = new ArrayList<>(); + try (Cursor c = sDbHelper.query(Schedules.TABLE_NAME, ScheduledRecording.PROJECTION)) { + while (c.moveToNext() && !isCancelled()) { + scheduledRecordings.add(ScheduledRecording.fromCursor(c)); + } } + return scheduledRecordings; + } + } + + /** + * Inserts series recordings. + */ + public static class AsyncAddSeriesRecordingTask + extends AsyncDvrDbTask<SeriesRecording, Void, Void> { + public AsyncAddSeriesRecordingTask(Context context) { + super(context); + } + + @Override + protected final Void doInDvrBackground(SeriesRecording... params) { + sDbHelper.insertSeriesRecordings(params); + return null; + } + } + + /** + * Update series recordings. + */ + public static class AsyncUpdateSeriesRecordingTask + extends AsyncDvrDbTask<SeriesRecording, Void, Void> { + public AsyncUpdateSeriesRecordingTask(Context context) { + super(context); + } + + @Override + protected final Void doInDvrBackground(SeriesRecording... params) { + sDbHelper.updateSeriesRecordings(params); + return null; + } + } + + /** + * Delete series recordings. + */ + public static class AsyncDeleteSeriesRecordingTask + extends AsyncDvrDbTask<SeriesRecording, Void, Void> { + public AsyncDeleteSeriesRecordingTask(Context context) { + super(context); + } + + @Override + protected final Void doInDvrBackground(SeriesRecording... params) { + sDbHelper.deleteSeriesRecordings(params); + return null; + } + } + + /** + * Returns all {@link SeriesRecording}s. + */ + public abstract static class AsyncDvrQuerySeriesRecordingTask + extends AsyncDvrDbTask<Void, Void, List<SeriesRecording>> { + public AsyncDvrQuerySeriesRecordingTask(Context context) { + super(context); + } + + @Override + @Nullable + protected final List<SeriesRecording> doInDvrBackground(Void... params) { if (isCancelled()) { return null; } - List<ScheduledRecording> scheduledRecordings = new ArrayList<>(); - try (Cursor c = sDbHelper.query(Recordings.TABLE_NAME, ScheduledRecording.PROJECTION)) { + List<SeriesRecording> scheduledRecordings = new ArrayList<>(); + try (Cursor c = sDbHelper.query(SeriesRecordings.TABLE_NAME, + SeriesRecording.PROJECTION)) { while (c.moveToNext() && !isCancelled()) { - scheduledRecordings.add(ScheduledRecording.fromCursor(c)); + scheduledRecordings.add(SeriesRecording.fromCursor(c)); } } return scheduledRecordings; diff --git a/src/com/android/tv/dvr/provider/DvrContract.java b/src/com/android/tv/dvr/provider/DvrContract.java index 192cc17b..3fe2d211 100644 --- a/src/com/android/tv/dvr/provider/DvrContract.java +++ b/src/com/android/tv/dvr/provider/DvrContract.java @@ -23,10 +23,10 @@ import android.provider.BaseColumns; * columns. It's for the internal use in Live TV. */ public final class DvrContract { - /** Column definition for Recording table. */ - public static final class Recordings implements BaseColumns { + /** Column definition for Schedules table. */ + public static final class Schedules implements BaseColumns { /** The table name. */ - public static final String TABLE_NAME = "recording"; + public static final String TABLE_NAME = "schedules"; /** The recording type for program recording. */ public static final String TYPE_PROGRAM = "TYPE_PROGRAM"; @@ -34,22 +34,27 @@ public final class DvrContract { /** The recording type for timed recording. */ public static final String TYPE_TIMED = "TYPE_TIMED"; - /** The recording type for season recording. */ - public static final String TYPE_SEASON_RECORDING = "TYPE_SEASON_RECORDING"; - /** The recording has not been started yet. */ public static final String STATE_RECORDING_NOT_STARTED = "STATE_RECORDING_NOT_STARTED"; /** The recording is in progress. */ public static final String STATE_RECORDING_IN_PROGRESS = "STATE_RECORDING_IN_PROGRESS"; - /** The recording was unexpectedly stopped. */ - public static final String STATE_RECORDING_UNEXPECTEDLY_STOPPED = - "STATE_RECORDING_UNEXPECTEDLY_STOPPED"; - /** The recording is finished. */ public static final String STATE_RECORDING_FINISHED = "STATE_RECORDING_FINISHED"; + /** The recording failed. */ + public static final String STATE_RECORDING_FAILED = "STATE_RECORDING_FAILED"; + + /** The recording finished and clipping. */ + public static final String STATE_RECORDING_CLIPPED = "STATE_RECORDING_CLIPPED"; + + /** The recording marked as deleted. */ + public static final String STATE_RECORDING_DELETED = "STATE_RECORDING_DELETED"; + + /** The recording marked as canceled. */ + public static final String STATE_RECORDING_CANCELED = "STATE_RECORDING_CANCELED"; + /** * The priority of this recording. * @@ -63,16 +68,25 @@ public final class DvrContract { /** * The type of this recording. * - * <p>This value should be one of the followings: {@link #TYPE_PROGRAM}, - * {@link #TYPE_TIMED}, and {@link #TYPE_SEASON_RECORDING}. + * <p>This value should be one of the followings: {@link #TYPE_PROGRAM} and + * {@link #TYPE_TIMED}. * * <p>This is a required field. * - * <p>Type: String + * <p>Type: TEXT */ public static final String COLUMN_TYPE = "type"; /** + * The input id of recording. + * + * <p>This is a required field. + * + * <p>Type: TEXT + */ + public static final String COLUMN_INPUT_ID = "input_id"; + + /** * The ID of the channel for recording. * * <p>This is a required field. @@ -81,9 +95,8 @@ public final class DvrContract { */ public static final String COLUMN_CHANNEL_ID = "channel_id"; - /** - * The ID of the associated program for recording. + * The ID of the associated program for recording. * * <p>This is an optional field. * @@ -92,6 +105,15 @@ public final class DvrContract { public static final String COLUMN_PROGRAM_ID = "program_id"; /** + * The title of the associated program for recording. + * + * <p>This is an optional field. + * + * <p>Type: TEXT + */ + public static final String COLUMN_PROGRAM_TITLE = "program_title"; + + /** * The start time of this recording, in milliseconds since the epoch. * * <p>This is a required field. @@ -110,19 +132,261 @@ public final class DvrContract { public static final String COLUMN_END_TIME_UTC_MILLIS = "end_time_utc_millis"; /** + * The season number of this program for episodic TV shows. + * + * <p>Type: TEXT + */ + public static final String COLUMN_SEASON_NUMBER = "season_number"; + + /** + * The episode number of this program for episodic TV shows. + * + * <p>Type: TEXT + */ + public static final String COLUMN_EPISODE_NUMBER = "episode_number"; + + /** + * The episode title of this program for episodic TV shows. + * + * <p>Type: TEXT + */ + public static final String COLUMN_EPISODE_TITLE = "episode_title"; + + /** + * The description of program. + * + * <p>Type: TEXT + */ + public static final String COLUMN_PROGRAM_DESCRIPTION = "program_description"; + + /** + * The long description of program. + * + * <p>Type: TEXT + */ + public static final String COLUMN_PROGRAM_LONG_DESCRIPTION = "program_long_description"; + + /** + * The poster art uri of program. + * + * <p>Type: TEXT + */ + public static final String COLUMN_PROGRAM_POST_ART_URI = "program_poster_art_uri"; + + /** + * The thumbnail uri of program. + * + * <p>Type: TEXT + */ + public static final String COLUMN_PROGRAM_THUMBNAIL_URI = "program_thumbnail_uri"; + + /** * The state of this recording. * * <p>This value should be one of the followings: {@link #STATE_RECORDING_NOT_STARTED}, - * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_UNEXPECTEDLY_STOPPED}, - * and {@link #STATE_RECORDING_FINISHED}. + * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_FINISHED}, + * {@link #STATE_RECORDING_FAILED}, {@link #STATE_RECORDING_CLIPPED} and + * {@link #STATE_RECORDING_DELETED}. * * <p>This is a required field. * - * <p>Type: String + * <p>Type: TEXT + */ + public static final String COLUMN_STATE = "state"; + + /** + * The ID of the parent series recording. + * + * <p>Type: INTEGER (long) + */ + public static final String COLUMN_SERIES_RECORDING_ID = "series_recording_id"; + + private Schedules() { } + } + + /** Column definition for Recording table. */ + public static final class SeriesRecordings implements BaseColumns { + /** The table name. */ + public static final String TABLE_NAME = "series_recording"; + + /** + * This value is used for {@link #COLUMN_START_FROM_SEASON} and + * {@link #COLUMN_START_FROM_EPISODE} to mean record all seasons or episodes. + */ + public static final int THE_BEGINNING = -1; + + /** + * The series recording option which indicates that the episodes in one channel are + * recorded. + */ + public static final String OPTION_CHANNEL_ONE = "OPTION_CHANNEL_ONE"; + + /** + * The series recording option which indicates that the episodes in all the channels are + * recorded. + */ + public static final String OPTION_CHANNEL_ALL = "OPTION_CHANNEL_ALL"; + + /** + * The state indicates that it is a normal one. + */ + public static final String STATE_SERIES_NORMAL = "STATE_SERIES_NORMAL"; + + /** + * The state indicates that it is a canceled one. + */ + public static final String STATE_SERIES_CANCELED = "STATE_SERIES_CANCELED"; + + /** + * The priority of this recording. + * + * <p> The lowest number is recorded first. If there is a tie in priority then the lower id + * wins. Defaults to {@value Long#MAX_VALUE} + * + * <p>Type: INTEGER (long) + */ + public static final String COLUMN_PRIORITY = "priority"; + + /** + * The input id of recording. + * + * <p>This is a required field. + * + * <p>Type: TEXT + */ + public static final String COLUMN_INPUT_ID = "input_id"; + + /** + * The ID of the channel for recording. + * + * <p>This is a required field. + * + * <p>Type: INTEGER (long) + */ + public static final String COLUMN_CHANNEL_ID = "channel_id"; + + /** + * The ID of the associated series to record. + * + * <p>The id is an opaque but stable string. + * + * <p>This is an optional field. + * + * <p>Type: TEXT + */ + public static final String COLUMN_SERIES_ID = "series_id"; + + /** + * The title of the series. + * + * <p>This is a required field. + * + * <p>Type: TEXT + */ + public static final String COLUMN_TITLE = "title"; + + /** + * The short description of the series. + * + * <p>Type: TEXT + */ + public static final String COLUMN_SHORT_DESCRIPTION = "short_description"; + + /** + * The long description of the series. + * + * <p>Type: TEXT + */ + public static final String COLUMN_LONG_DESCRIPTION = "long_description"; + + /** + * The number of the earliest season to record. The + * value {@link #THE_BEGINNING} means record all seasons. + * + * <p>Default value is {@value #THE_BEGINNING} {@link #THE_BEGINNING}. + * + * <p>Type: INTEGER (int) + */ + public static final String COLUMN_START_FROM_SEASON = "start_from_season"; + + /** + * The number of the earliest episode to record in {@link #COLUMN_START_FROM_SEASON}. The + * value {@link #THE_BEGINNING} means record all episodes. + * + * <p>Default value is {@value #THE_BEGINNING} {@link #THE_BEGINNING}. + * + * <p>Type: INTEGER (int) + */ + public static final String COLUMN_START_FROM_EPISODE = "start_from_episode"; + + /** + * The series recording option which indicates the channels to record. + * + * <p>This value should be one of the followings: {@link #OPTION_CHANNEL_ONE} and + * {@link #OPTION_CHANNEL_ALL}. The default value is OPTION_CHANNEL_ONE. + * + * <p>Type: TEXT + */ + public static final String COLUMN_CHANNEL_OPTION = "channel_option"; + + /** + * The comma-separated canonical genre string of this series. + * + * <p>Canonical genres are defined in {@link android.media.tv.TvContract.Programs.Genres}. + * Use {@link android.media.tv.TvContract.Programs.Genres#encode} to create a text that can + * be stored in this column. Use {@link android.media.tv.TvContract.Programs.Genres#decode} + * to get the canonical genre strings from the text stored in the column. + * + * <p>Type: TEXT + * @see android.media.tv.TvContract.Programs.Genres + * @see android.media.tv.TvContract.Programs.Genres#encode + * @see android.media.tv.TvContract.Programs.Genres#decode + */ + public static final String COLUMN_CANONICAL_GENRE = "canonical_genre"; + + /** + * The URI for the poster of this TV series. + * + * <p>The data in the column must be a URL, or a URI in one of the following formats: + * + * <ul> + * <li>content ({@link android.content.ContentResolver#SCHEME_CONTENT})</li> + * <li>android.resource ({@link android.content.ContentResolver#SCHEME_ANDROID_RESOURCE}) + * </li> + * <li>file ({@link android.content.ContentResolver#SCHEME_FILE})</li> + * </ul> + * + * <p>Type: TEXT + */ + public static final String COLUMN_POSTER_URI = "poster_uri"; + + /** + * The URI for the photo of this TV program. + * + * <p>The data in the column must be a URL, or a URI in one of the following formats: + * + * <ul> + * <li>content ({@link android.content.ContentResolver#SCHEME_CONTENT})</li> + * <li>android.resource ({@link android.content.ContentResolver#SCHEME_ANDROID_RESOURCE}) + * </li> + * <li>file ({@link android.content.ContentResolver#SCHEME_FILE})</li> + * </ul> + * + * <p>Type: TEXT + */ + public static final String COLUMN_PHOTO_URI = "photo_uri"; + + /** + * The state of whether the series recording be canceled or not. + * + * <p>This value should be one of the followings: {@link #STATE_SERIES_NORMAL} and + * {@link #STATE_SERIES_CANCELED}. The default value is STATE_SERIES_NORMAL. + * + * <p>Type: TEXT */ public static final String COLUMN_STATE = "state"; - private Recordings() { } + private SeriesRecordings() { } } private DvrContract() { } diff --git a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java index bdba8ac3..2f16ba5d 100644 --- a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java +++ b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java @@ -22,13 +22,15 @@ import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; +import android.database.sqlite.SQLiteStatement; +import android.provider.BaseColumns; +import android.text.TextUtils; import android.util.Log; import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.dvr.provider.DvrContract.Recordings; - -import java.util.ArrayList; -import java.util.List; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.provider.DvrContract.Schedules; +import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; /** * A data class for one recorded contents. @@ -37,24 +39,153 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper { private static final String TAG = "DvrDatabaseHelper"; private static final boolean DEBUG = true; - private static final int DATABASE_VERSION = 4; + private static final int DATABASE_VERSION = 17; private static final String DB_NAME = "dvr.db"; - private static final String SQL_CREATE_RECORDINGS = - "CREATE TABLE " + Recordings.TABLE_NAME + "(" - + Recordings._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," - + Recordings.COLUMN_PRIORITY + " INTEGER DEFAULT " + Long.MAX_VALUE + "," - + Recordings.COLUMN_TYPE + " TEXT NOT NULL," - + Recordings.COLUMN_CHANNEL_ID + " INTEGER NOT NULL," - + Recordings.COLUMN_PROGRAM_ID + " INTEGER ," - + Recordings.COLUMN_START_TIME_UTC_MILLIS + " INTEGER NOT NULL," - + Recordings.COLUMN_END_TIME_UTC_MILLIS + " INTEGER NOT NULL," - + Recordings.COLUMN_STATE + " TEXT NOT NULL)"; - - private static final String SQL_DROP_RECORDINGS = "DROP TABLE IF EXISTS " - + Recordings.TABLE_NAME; - public static final String WHERE_RECORDING_ID_EQUALS = Recordings._ID + " = ?"; + private static final String SQL_CREATE_SCHEDULES = + "CREATE TABLE " + Schedules.TABLE_NAME + "(" + + Schedules._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Schedules.COLUMN_PRIORITY + " INTEGER DEFAULT " + + ScheduledRecording.DEFAULT_PRIORITY + "," + + Schedules.COLUMN_TYPE + " TEXT NOT NULL," + + Schedules.COLUMN_INPUT_ID + " TEXT NOT NULL," + + Schedules.COLUMN_CHANNEL_ID + " INTEGER NOT NULL," + + Schedules.COLUMN_PROGRAM_ID + " INTEGER," + + Schedules.COLUMN_PROGRAM_TITLE + " TEXT," + + Schedules.COLUMN_START_TIME_UTC_MILLIS + " INTEGER NOT NULL," + + Schedules.COLUMN_END_TIME_UTC_MILLIS + " INTEGER NOT NULL," + + Schedules.COLUMN_SEASON_NUMBER + " TEXT," + + Schedules.COLUMN_EPISODE_NUMBER + " TEXT," + + Schedules.COLUMN_EPISODE_TITLE + " TEXT," + + Schedules.COLUMN_PROGRAM_DESCRIPTION + " TEXT," + + Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION + " TEXT," + + Schedules.COLUMN_PROGRAM_POST_ART_URI + " TEXT," + + Schedules.COLUMN_PROGRAM_THUMBNAIL_URI + " TEXT," + + Schedules.COLUMN_STATE + " TEXT NOT NULL," + + Schedules.COLUMN_SERIES_RECORDING_ID + " INTEGER," + + "FOREIGN KEY(" + Schedules.COLUMN_SERIES_RECORDING_ID + ") " + + "REFERENCES " + SeriesRecordings.TABLE_NAME + + "(" + SeriesRecordings._ID + ") " + + "ON UPDATE CASCADE ON DELETE SET NULL);"; + + private static final String SQL_DROP_SCHEDULES = "DROP TABLE IF EXISTS " + Schedules.TABLE_NAME; + + private static final String SQL_CREATE_SERIES_RECORDINGS = + "CREATE TABLE " + SeriesRecordings.TABLE_NAME + "(" + + SeriesRecordings._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + SeriesRecordings.COLUMN_PRIORITY + " INTEGER DEFAULT " + + SeriesRecording.DEFAULT_PRIORITY + "," + + SeriesRecordings.COLUMN_TITLE + " TEXT NOT NULL," + + SeriesRecordings.COLUMN_SHORT_DESCRIPTION + " TEXT," + + SeriesRecordings.COLUMN_LONG_DESCRIPTION + " TEXT," + + SeriesRecordings.COLUMN_INPUT_ID + " TEXT NOT NULL," + + SeriesRecordings.COLUMN_CHANNEL_ID + " INTEGER NOT NULL," + + SeriesRecordings.COLUMN_SERIES_ID + " TEXT NOT NULL," + + SeriesRecordings.COLUMN_START_FROM_SEASON + " INTEGER DEFAULT " + + SeriesRecordings.THE_BEGINNING + "," + + SeriesRecordings.COLUMN_START_FROM_EPISODE + " INTEGER DEFAULT " + + SeriesRecordings.THE_BEGINNING + "," + + SeriesRecordings.COLUMN_CHANNEL_OPTION + " TEXT DEFAULT " + + SeriesRecordings.OPTION_CHANNEL_ONE + "," + + SeriesRecordings.COLUMN_CANONICAL_GENRE + " TEXT," + + SeriesRecordings.COLUMN_POSTER_URI + " TEXT," + + SeriesRecordings.COLUMN_PHOTO_URI + " TEXT," + + SeriesRecordings.COLUMN_STATE + " TEXT)"; + + private static final String SQL_DROP_SERIES_RECORDINGS = "DROP TABLE IF EXISTS " + + SeriesRecordings.TABLE_NAME; + + private static final int SQL_DATA_TYPE_LONG = 0; + private static final int SQL_DATA_TYPE_INT = 1; + private static final int SQL_DATA_TYPE_STRING = 2; + + private static final ColumnInfo[] COLUMNS_SCHEDULES = new ColumnInfo[] { + new ColumnInfo(Schedules._ID, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_PRIORITY, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_TYPE, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_INPUT_ID, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_CHANNEL_ID, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_PROGRAM_ID, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_PROGRAM_TITLE, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_START_TIME_UTC_MILLIS, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_END_TIME_UTC_MILLIS, SQL_DATA_TYPE_LONG), + new ColumnInfo(Schedules.COLUMN_SEASON_NUMBER, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_EPISODE_NUMBER, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_EPISODE_TITLE, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_PROGRAM_DESCRIPTION, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_PROGRAM_POST_ART_URI, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_PROGRAM_THUMBNAIL_URI, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_STATE, SQL_DATA_TYPE_STRING), + new ColumnInfo(Schedules.COLUMN_SERIES_RECORDING_ID, SQL_DATA_TYPE_LONG)}; + private static final String SQL_INSERT_SCHEDULES = + buildInsertSql(Schedules.TABLE_NAME, COLUMNS_SCHEDULES); + private static final String SQL_UPDATE_SCHEDULES = + buildUpdateSql(Schedules.TABLE_NAME, COLUMNS_SCHEDULES); + private static final String SQL_DELETE_SCHEDULES = buildDeleteSql(Schedules.TABLE_NAME); + + private static final ColumnInfo[] COLUMNS_SERIES_RECORDINGS = new ColumnInfo[] { + new ColumnInfo(SeriesRecordings._ID, SQL_DATA_TYPE_LONG), + new ColumnInfo(SeriesRecordings.COLUMN_PRIORITY, SQL_DATA_TYPE_LONG), + new ColumnInfo(SeriesRecordings.COLUMN_INPUT_ID, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_CHANNEL_ID, SQL_DATA_TYPE_LONG), + new ColumnInfo(SeriesRecordings.COLUMN_SERIES_ID, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_TITLE, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_SHORT_DESCRIPTION, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_LONG_DESCRIPTION, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_START_FROM_SEASON, SQL_DATA_TYPE_INT), + new ColumnInfo(SeriesRecordings.COLUMN_START_FROM_EPISODE, SQL_DATA_TYPE_INT), + new ColumnInfo(SeriesRecordings.COLUMN_CHANNEL_OPTION, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_CANONICAL_GENRE, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_POSTER_URI, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_PHOTO_URI, SQL_DATA_TYPE_STRING), + new ColumnInfo(SeriesRecordings.COLUMN_STATE, SQL_DATA_TYPE_STRING)}; + + private static final String SQL_INSERT_SERIES_RECORDINGS = + buildInsertSql(SeriesRecordings.TABLE_NAME, COLUMNS_SERIES_RECORDINGS); + private static final String SQL_UPDATE_SERIES_RECORDINGS = + buildUpdateSql(SeriesRecordings.TABLE_NAME, COLUMNS_SERIES_RECORDINGS); + private static final String SQL_DELETE_SERIES_RECORDINGS = + buildDeleteSql(SeriesRecordings.TABLE_NAME); + + private static String buildInsertSql(String tableName, ColumnInfo[] columns) { + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO ").append(tableName).append(" ("); + boolean appendComma = false; + for (ColumnInfo columnInfo : columns) { + if (appendComma) { + sb.append(","); + } + appendComma = true; + sb.append(columnInfo.name); + } + sb.append(") VALUES (?"); + for (int i = 1; i < columns.length; ++i) { + sb.append(",?"); + } + sb.append(")"); + return sb.toString(); + } + + private static String buildUpdateSql(String tableName, ColumnInfo[] columns) { + StringBuilder sb = new StringBuilder(); + sb.append("UPDATE ").append(tableName).append(" SET "); + boolean appendComma = false; + for (ColumnInfo columnInfo : columns) { + if (appendComma) { + sb.append(","); + } + appendComma = true; + sb.append(columnInfo.name).append("=?"); + } + sb.append(" WHERE ").append(BaseColumns._ID).append("=?"); + return sb.toString(); + } + + private static String buildDeleteSql(String tableName) { + return "DELETE FROM " + tableName + " WHERE " + BaseColumns._ID + "=?"; + } public DvrDatabaseHelper(Context context) { super(context.getApplicationContext(), DB_NAME, null, DATABASE_VERSION); } @@ -66,14 +197,18 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper { @Override public void onCreate(SQLiteDatabase db) { - if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_RECORDINGS); - db.execSQL(SQL_CREATE_RECORDINGS); + if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_SCHEDULES); + db.execSQL(SQL_CREATE_SCHEDULES); + if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_SERIES_RECORDINGS); + db.execSQL(SQL_CREATE_SERIES_RECORDINGS); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_RECORDINGS); - db.execSQL(SQL_DROP_RECORDINGS); + if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_SCHEDULES); + db.execSQL(SQL_DROP_SCHEDULES); + if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_SERIES_RECORDINGS); + db.execSQL(SQL_DROP_SERIES_RECORDINGS); onCreate(db); } @@ -88,61 +223,164 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper { } /** - * Inserts recordings. - * - * @return The list of recordings with id set. The id will be -1 if there was an error. + * Inserts schedules. */ - public List<ScheduledRecording> insertRecordings(ScheduledRecording... scheduledRecordings) { - updateChannelsFromRecordings(scheduledRecordings); + public void insertSchedules(ScheduledRecording... scheduledRecordings) { + SQLiteDatabase db = getWritableDatabase(); + SQLiteStatement statement = db.compileStatement(SQL_INSERT_SCHEDULES); + db.beginTransaction(); + try { + for (ScheduledRecording r : scheduledRecordings) { + statement.clearBindings(); + ContentValues values = ScheduledRecording.toContentValues(r); + bindColumns(statement, COLUMNS_SCHEDULES, values); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } - SQLiteDatabase db = getReadableDatabase(); - List<ScheduledRecording> results = new ArrayList<>(); - for (ScheduledRecording r : scheduledRecordings) { - ContentValues values = ScheduledRecording.toContentValues(r); - long id = db.insert(Recordings.TABLE_NAME, null, values); - results.add(ScheduledRecording.buildFrom(r).setId(id).build()); + /** + * Update schedules. + */ + public void updateSchedules(ScheduledRecording... scheduledRecordings) { + SQLiteDatabase db = getWritableDatabase(); + SQLiteStatement statement = db.compileStatement(SQL_UPDATE_SCHEDULES); + db.beginTransaction(); + try { + for (ScheduledRecording r : scheduledRecordings) { + statement.clearBindings(); + ContentValues values = ScheduledRecording.toContentValues(r); + bindColumns(statement, COLUMNS_SCHEDULES, values); + statement.bindLong(COLUMNS_SCHEDULES.length + 1, r.getId()); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Delete schedules. + */ + public void deleteSchedules(ScheduledRecording... scheduledRecordings) { + SQLiteDatabase db = getWritableDatabase(); + SQLiteStatement statement = db.compileStatement(SQL_DELETE_SCHEDULES); + db.beginTransaction(); + try { + for (ScheduledRecording r : scheduledRecordings) { + statement.clearBindings(); + statement.bindLong(1, r.getId()); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); } - return results; } /** - * Update recordings. - * - * @return The list of row update counts. The count will be -1 if there was an error or 0 - * if no match was found. The count is expected to be exactly 1 for each recording. + * Inserts series recordings. */ - public List<Integer> updateRecordings(ScheduledRecording[] scheduledRecordings) { - updateChannelsFromRecordings(scheduledRecordings); + public void insertSeriesRecordings(SeriesRecording... seriesRecordings) { SQLiteDatabase db = getWritableDatabase(); - List<Integer> results = new ArrayList<>(); - for (ScheduledRecording r : scheduledRecordings) { - ContentValues values = ScheduledRecording.toContentValues(r); - int updated = db.update(Recordings.TABLE_NAME, values, Recordings._ID + " = ?", - new String[] {String.valueOf(r.getId())}); - results.add(updated); + SQLiteStatement statement = db.compileStatement(SQL_INSERT_SERIES_RECORDINGS); + db.beginTransaction(); + try { + for (SeriesRecording r : seriesRecordings) { + statement.clearBindings(); + ContentValues values = SeriesRecording.toContentValues(r); + bindColumns(statement, COLUMNS_SERIES_RECORDINGS, values); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); } - return results; } - private void updateChannelsFromRecordings(ScheduledRecording[] scheduledRecordings) { - // TODO(DVR) implement/ - // TODO(DVR) consider not deleting channels instead of keeping a separate table. + /** + * Update series recordings. + */ + public void updateSeriesRecordings(SeriesRecording... seriesRecordings) { + SQLiteDatabase db = getWritableDatabase(); + SQLiteStatement statement = db.compileStatement(SQL_UPDATE_SERIES_RECORDINGS); + db.beginTransaction(); + try { + for (SeriesRecording r : seriesRecordings) { + statement.clearBindings(); + ContentValues values = SeriesRecording.toContentValues(r); + bindColumns(statement, COLUMNS_SERIES_RECORDINGS, values); + statement.bindLong(COLUMNS_SERIES_RECORDINGS.length + 1, r.getId()); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } } /** - * Delete recordings. - * - * @return The list of row update counts. The count will be -1 if there was an error or 0 - * if no match was found. The count is expected to be exactly 1 for each recording. + * Delete series recordings. */ - public List<Integer> deleteRecordings(ScheduledRecording[] scheduledRecordings) { + public void deleteSeriesRecordings(SeriesRecording... seriesRecordings) { SQLiteDatabase db = getWritableDatabase(); - List<Integer> results = new ArrayList<>(); - for (ScheduledRecording r : scheduledRecordings) { - int deleted = db.delete(Recordings.TABLE_NAME, WHERE_RECORDING_ID_EQUALS, - new String[] {String.valueOf(r.getId())}); - results.add(deleted); + SQLiteStatement statement = db.compileStatement(SQL_DELETE_SERIES_RECORDINGS); + db.beginTransaction(); + try { + for (SeriesRecording r : seriesRecordings) { + statement.clearBindings(); + statement.bindLong(1, r.getId()); + statement.execute(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void bindColumns(SQLiteStatement statement, ColumnInfo[] columns, + ContentValues values) { + for (int i = 0; i < columns.length; ++i) { + ColumnInfo columnInfo = columns[i]; + Object value = values.get(columnInfo.name); + switch (columnInfo.type) { + case SQL_DATA_TYPE_LONG: + if (value == null) { + statement.bindNull(i + 1); + } else { + statement.bindLong(i + 1, (Long) value); + } + break; + case SQL_DATA_TYPE_INT: + if (value == null) { + statement.bindNull(i + 1); + } else { + statement.bindLong(i + 1, (Integer) value); + } + break; + case SQL_DATA_TYPE_STRING: { + if (TextUtils.isEmpty((String) value)) { + statement.bindNull(i + 1); + } else { + statement.bindString(i + 1, (String) value); + } + break; + } + } + } + } + + private static class ColumnInfo { + final String name; + final int type; + + ColumnInfo(String name, int type) { + this.name = name; + this.type = type; } - return results; } } diff --git a/src/com/android/tv/dvr/ui/ActionPresenterSelector.java b/src/com/android/tv/dvr/ui/ActionPresenterSelector.java new file mode 100644 index 00000000..8b8cd5c5 --- /dev/null +++ b/src/com/android/tv/dvr/ui/ActionPresenterSelector.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.graphics.drawable.Drawable; +import android.support.v17.leanback.R; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.Presenter; +import android.support.v17.leanback.widget.PresenterSelector; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +// This class is adapted from Leanback's library, which does not support action icon with one-line +// label. This class modified its getPresenter method to support the above situation. +class ActionPresenterSelector extends PresenterSelector { + private final Presenter mOneLineActionPresenter = new OneLineActionPresenter(); + private final Presenter mTwoLineActionPresenter = new TwoLineActionPresenter(); + private final Presenter[] mPresenters = new Presenter[] { + mOneLineActionPresenter, mTwoLineActionPresenter}; + + @Override + public Presenter getPresenter(Object item) { + Action action = (Action) item; + if (TextUtils.isEmpty(action.getLabel2()) && action.getIcon() == null) { + return mOneLineActionPresenter; + } else { + return mTwoLineActionPresenter; + } + } + + @Override + public Presenter[] getPresenters() { + return mPresenters; + } + + static class ActionViewHolder extends Presenter.ViewHolder { + Action mAction; + Button mButton; + int mLayoutDirection; + + public ActionViewHolder(View view, int layoutDirection) { + super(view); + mButton = (Button) view.findViewById(R.id.lb_action_button); + mLayoutDirection = layoutDirection; + } + } + + class OneLineActionPresenter extends Presenter { + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.lb_action_1_line, parent, false); + return new ActionViewHolder(v, parent.getLayoutDirection()); + } + + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + Action action = (Action) item; + ActionViewHolder vh = (ActionViewHolder) viewHolder; + vh.mAction = action; + vh.mButton.setText(action.getLabel1()); + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { + ((ActionViewHolder) viewHolder).mAction = null; + } + } + + class TwoLineActionPresenter extends Presenter { + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.lb_action_2_lines, parent, false); + return new ActionViewHolder(v, parent.getLayoutDirection()); + } + + @Override + public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + Action action = (Action) item; + ActionViewHolder vh = (ActionViewHolder) viewHolder; + Drawable icon = action.getIcon(); + vh.mAction = action; + + if (icon != null) { + final int startPadding = vh.view.getResources() + .getDimensionPixelSize(R.dimen.lb_action_with_icon_padding_start); + final int endPadding = vh.view.getResources() + .getDimensionPixelSize(R.dimen.lb_action_with_icon_padding_end); + vh.view.setPaddingRelative(startPadding, 0, endPadding, 0); + } else { + final int padding = vh.view.getResources() + .getDimensionPixelSize(R.dimen.lb_action_padding_horizontal); + vh.view.setPaddingRelative(padding, 0, padding, 0); + } + if (vh.mLayoutDirection == View.LAYOUT_DIRECTION_RTL) { + vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, icon, null); + } else { + vh.mButton.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + } + + CharSequence line1 = action.getLabel1(); + CharSequence line2 = action.getLabel2(); + if (TextUtils.isEmpty(line1)) { + vh.mButton.setText(line2); + } else if (TextUtils.isEmpty(line2)) { + vh.mButton.setText(line1); + } else { + vh.mButton.setText(line1 + "\n" + line2); + } + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { + ActionViewHolder vh = (ActionViewHolder) viewHolder; + vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + vh.view.setPadding(0, 0, 0, 0); + vh.mAction = null; + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java new file mode 100644 index 00000000..5d8e20ff --- /dev/null +++ b/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.res.Resources; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.SparseArrayObjectAdapter; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrManager; + +/** + * {@link RecordingDetailsFragment} for current recording in DVR. + */ +public class CurrentRecordingDetailsFragment extends RecordingDetailsFragment { + private static final int ACTION_STOP_RECORDING = 1; + + @Override + protected SparseArrayObjectAdapter onCreateActionsAdapter() { + SparseArrayObjectAdapter adapter = + new SparseArrayObjectAdapter(new ActionPresenterSelector()); + Resources res = getResources(); + adapter.set(ACTION_STOP_RECORDING, new Action(ACTION_STOP_RECORDING, + res.getString(R.string.epg_dvr_dialog_message_stop_recording), null, + res.getDrawable(R.drawable.lb_ic_stop))); + return adapter; + } + + @Override + protected OnActionClickedListener onCreateOnActionClickedListener() { + return new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_STOP_RECORDING) { + DvrManager dvrManager = TvApplication.getSingletons(getActivity()) + .getDvrManager(); + dvrManager.stopRecording(getRecording()); + } + getActivity().finish(); + } + }; + } +} diff --git a/src/com/android/tv/dvr/ui/DetailsContent.java b/src/com/android/tv/dvr/ui/DetailsContent.java new file mode 100644 index 00000000..19521fca --- /dev/null +++ b/src/com/android/tv/dvr/ui/DetailsContent.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.media.tv.TvContract; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.data.BaseProgram; +import com.android.tv.data.Channel; + +/** + * A class for details content. + */ +public class DetailsContent { + /** Constant for invalid time. */ + public static final long INVALID_TIME = -1; + + private CharSequence mTitle; + private long mStartTimeUtcMillis; + private long mEndTimeUtcMillis; + private String mDescription; + private String mLogoImageUri; + private String mBackgroundImageUri; + + private DetailsContent() { } + + /** + * Returns title. + */ + public CharSequence getTitle() { + return mTitle; + } + + /** + * Returns start time. + */ + public long getStartTimeUtcMillis() { + return mStartTimeUtcMillis; + } + + /** + * Returns end time. + */ + public long getEndTimeUtcMillis() { + return mEndTimeUtcMillis; + } + + /** + * Returns description. + */ + public String getDescription() { + return mDescription; + } + + /** + * Returns Logo image URI as a String. + */ + public String getLogoImageUri() { + return mLogoImageUri; + } + + /** + * Returns background image URI as a String. + */ + public String getBackgroundImageUri() { + return mBackgroundImageUri; + } + + /** + * Copies other details content. + */ + public void copyFrom(DetailsContent other) { + if (this == other) { + return; + } + mTitle = other.mTitle; + mStartTimeUtcMillis = other.mStartTimeUtcMillis; + mEndTimeUtcMillis = other.mEndTimeUtcMillis; + mDescription = other.mDescription; + mLogoImageUri = other.mLogoImageUri; + mBackgroundImageUri = other.mBackgroundImageUri; + } + + /** + * A class for building details content. + */ + public static final class Builder { + private final DetailsContent mDetailsContent; + + public Builder() { + mDetailsContent = new DetailsContent(); + mDetailsContent.mStartTimeUtcMillis = INVALID_TIME; + mDetailsContent.mEndTimeUtcMillis = INVALID_TIME; + } + + /** + * Sets title. + */ + public Builder setTitle(CharSequence title) { + mDetailsContent.mTitle = title; + return this; + } + + /** + * Sets start time. + */ + public Builder setStartTimeUtcMillis(long startTimeUtcMillis) { + mDetailsContent.mStartTimeUtcMillis = startTimeUtcMillis; + return this; + } + + /** + * Sets end time. + */ + public Builder setEndTimeUtcMillis(long endTimeUtcMillis) { + mDetailsContent.mEndTimeUtcMillis = endTimeUtcMillis; + return this; + } + + /** + * Sets description. + */ + public Builder setDescription(String description) { + mDetailsContent.mDescription = description; + return this; + } + + /** + * Sets logo image URI as a String. + */ + public Builder setLogoImageUri(String logoImageUri) { + mDetailsContent.mLogoImageUri = logoImageUri; + return this; + } + + /** + * Sets background image URI as a String. + */ + public Builder setBackgroundImageUri(String backgroundImageUri) { + mDetailsContent.mBackgroundImageUri = backgroundImageUri; + return this; + } + + /** + * Sets background image and logo image URI from program and channel. + */ + public Builder setImageUris(@Nullable BaseProgram program, @Nullable Channel channel) { + if (program != null) { + return setImageUris(program.getPosterArtUri(), program.getThumbnailUri(), channel); + } else { + return setImageUris(null, null, channel); + } + } + + /** + * Sets background image and logo image URI and channel is used for fallback images. + */ + public Builder setImageUris(@Nullable String posterArtUri, + @Nullable String thumbnailUri, @Nullable Channel channel) { + mDetailsContent.mLogoImageUri = null; + mDetailsContent.mBackgroundImageUri = null; + if (!TextUtils.isEmpty(posterArtUri) && !TextUtils.isEmpty(thumbnailUri)) { + mDetailsContent.mLogoImageUri = posterArtUri; + mDetailsContent.mBackgroundImageUri = thumbnailUri; + } else if (!TextUtils.isEmpty(posterArtUri)) { + // thumbnailUri is empty + mDetailsContent.mLogoImageUri = posterArtUri; + mDetailsContent.mBackgroundImageUri = posterArtUri; + } else if (!TextUtils.isEmpty(thumbnailUri)) { + // posterArtUri is empty + mDetailsContent.mLogoImageUri = thumbnailUri; + mDetailsContent.mBackgroundImageUri = thumbnailUri; + } + if (TextUtils.isEmpty(mDetailsContent.mLogoImageUri) && channel != null) { + String channelLogoUri = TvContract.buildChannelLogoUri(channel.getId()) + .toString(); + mDetailsContent.mLogoImageUri = channelLogoUri; + mDetailsContent.mBackgroundImageUri = channelLogoUri; + } + return this; + } + + /** + * Builds details content. + */ + public DetailsContent build() { + DetailsContent detailsContent = new DetailsContent(); + detailsContent.copyFrom(mDetailsContent); + return detailsContent; + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DetailsContentPresenter.java b/src/com/android/tv/dvr/ui/DetailsContentPresenter.java new file mode 100644 index 00000000..d6e17161 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DetailsContentPresenter.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; + +import com.android.tv.util.Utils; + +/** + * Presents a {@link DetailsContent}. + */ +public class DetailsContentPresenter extends AbstractDetailsDescriptionPresenter { + @Override + protected void onBindDescription(final ViewHolder viewHolder, Object itemData) { + DetailsContent detailsContent = (DetailsContent) itemData; + Context context = viewHolder.view.getContext(); + viewHolder.getTitle().setText(detailsContent.getTitle()); + if (detailsContent.getStartTimeUtcMillis() != DetailsContent.INVALID_TIME + && detailsContent.getEndTimeUtcMillis() != DetailsContent.INVALID_TIME) { + String playTime = Utils.getDurationString(context, + detailsContent.getStartTimeUtcMillis(), + detailsContent.getEndTimeUtcMillis(), false); + viewHolder.getSubtitle().setText(playTime); + } + viewHolder.getBody().setText(detailsContent.getDescription()); + } +} diff --git a/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java b/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java new file mode 100644 index 00000000..37f152f9 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.support.v17.leanback.app.BackgroundManager; + +/** + * The Background Helper. + */ +public class DetailsViewBackgroundHelper { + // Background delay serves to avoid kicking off expensive bitmap loading + // in case multiple backgrounds are set in quick succession. + private static final int SET_BACKGROUND_DELAY_MS = 100; + + private final BackgroundManager mBackgroundManager; + + class LoadBackgroundRunnable implements Runnable { + final Drawable mBackGround; + + LoadBackgroundRunnable(Drawable background) { + mBackGround = background; + } + + @Override + public void run() { + if (!mBackgroundManager.isAttached()) { + return; + } + if (mBackGround instanceof BitmapDrawable) { + mBackgroundManager.setBitmap(((BitmapDrawable) mBackGround).getBitmap()); + } + mRunnable = null; + } + } + + private LoadBackgroundRunnable mRunnable; + + private final Handler mHandler = new Handler(); + + public DetailsViewBackgroundHelper(Activity activity) { + mBackgroundManager = BackgroundManager.getInstance(activity); + mBackgroundManager.attach(activity.getWindow()); + } + + /** + * Sets the given image to background. + */ + public void setBackground(Drawable background) { + if (mRunnable != null) { + mHandler.removeCallbacks(mRunnable); + } + mRunnable = new LoadBackgroundRunnable(background); + mHandler.postDelayed(mRunnable, SET_BACKGROUND_DELAY_MS); + } + + /** + * Sets the background color. + */ + public void setBackgroundColor(int color) { + mBackgroundManager.setColor(color); + } + + /** + * Sets the background scrim. + */ + public void setScrim(int color) { + mBackgroundManager.setDimLayer(new ColorDrawable(color)); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrActivity.java b/src/com/android/tv/dvr/ui/DvrActivity.java index 01f3fb9c..45fb1cf1 100644 --- a/src/com/android/tv/dvr/ui/DvrActivity.java +++ b/src/com/android/tv/dvr/ui/DvrActivity.java @@ -20,6 +20,7 @@ import android.app.Activity; import android.os.Bundle; import com.android.tv.R; +import com.android.tv.TvApplication; /** * {@link android.app.Activity} for DVR UI. @@ -27,6 +28,7 @@ import com.android.tv.R; public class DvrActivity extends Activity { @Override public void onCreate(Bundle savedInstanceState) { + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.dvr_main); } diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java new file mode 100644 index 00000000..d7c2de88 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.util.Utils; + +import java.util.List; + +/** + * A fragment which notifies the user that the same episode has already been scheduled. + * + * <p>Note that the schedule has not been created yet. + */ +@TargetApi(Build.VERSION_CODES.N) +public class DvrAlreadyRecordedFragment extends DvrGuidedStepFragment { + private static final int ACTION_RECORD_ANYWAY = 1; + private static final int ACTION_WATCH = 2; + private static final int ACTION_CANCEL = 3; + + private Program mProgram; + private RecordedProgram mDuplicate; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); + DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDuplicate = dvrManager.getRecordedProgram(mProgram.getTitle(), + mProgram.getSeasonNumber(), mProgram.getEpisodeNumber()); + if (mDuplicate == null) { + dvrManager.addSchedule(mProgram); + DvrUiHelper.showAddScheduleToast(context, mProgram.getTitle(), + mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis()); + dismissDialog(); + } + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_already_recorded_dialog_title); + String description = getString(R.string.dvr_already_recorded_dialog_description); + Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null); + return new Guidance(title, description, null, image); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Context context = getContext(); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_RECORD_ANYWAY) + .title(R.string.dvr_action_record_anyway) + .build()); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_WATCH) + .title(R.string.dvr_action_watch_now) + .build()); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_CANCEL) + .title(R.string.dvr_action_record_cancel) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_RECORD_ANYWAY) { + getDvrManager().addSchedule(mProgram); + } else if (action.getId() == ACTION_WATCH) { + DvrUiHelper.startDetailsActivity(getActivity(), mDuplicate, null); + } + dismissDialog(); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java new file mode 100644 index 00000000..78f21784 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.format.DateUtils; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.util.Utils; + +import java.util.List; + +/** + * A fragment which notifies the user that the same episode has already been scheduled. + * + * <p>Note that the schedule has not been created yet. + */ +@TargetApi(Build.VERSION_CODES.N) +public class DvrAlreadyScheduledFragment extends DvrGuidedStepFragment { + private static final int ACTION_RECORD_ANYWAY = 1; + private static final int ACTION_RECORD_INSTEAD = 2; + private static final int ACTION_CANCEL = 3; + + private Program mProgram; + private ScheduledRecording mDuplicate; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); + DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDuplicate = dvrManager.getScheduledRecording(mProgram.getTitle(), + mProgram.getSeasonNumber(), mProgram.getEpisodeNumber()); + if (mDuplicate == null) { + dvrManager.addSchedule(mProgram); + DvrUiHelper.showAddScheduleToast(context, mProgram.getTitle(), + mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis()); + dismissDialog(); + } + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_already_scheduled_dialog_title); + String description = getString(R.string.dvr_already_scheduled_dialog_description, + DateUtils.formatDateTime(getContext(), mDuplicate.getStartTimeMs(), + DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE)); + Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null); + return new Guidance(title, description, null, image); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Context context = getContext(); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_RECORD_ANYWAY) + .title(R.string.dvr_action_record_anyway) + .build()); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_RECORD_INSTEAD) + .title(R.string.dvr_action_record_instead) + .build()); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_CANCEL) + .title(R.string.dvr_action_record_cancel) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_RECORD_ANYWAY) { + getDvrManager().addSchedule(mProgram); + } else if (action.getId() == ACTION_RECORD_INSTEAD) { + getDvrManager().addSchedule(mProgram); + getDvrManager().removeScheduledRecording(mDuplicate); + } + dismissDialog(); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java b/src/com/android/tv/dvr/ui/DvrBrowseFragment.java index 70e71cab..74d0ba0b 100644 --- a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java +++ b/src/com/android/tv/dvr/ui/DvrBrowseFragment.java @@ -16,140 +16,591 @@ package com.android.tv.dvr.ui; +import android.content.Context; +import android.media.tv.TvInputManager.TvInputCallback; import android.os.Bundle; -import android.support.annotation.IntDef; +import android.os.Handler; import android.support.v17.leanback.app.BrowseFragment; import android.support.v17.leanback.widget.ArrayObjectAdapter; import android.support.v17.leanback.widget.ClassPresenterSelector; import android.support.v17.leanback.widget.HeaderItem; import android.support.v17.leanback.widget.ListRow; import android.support.v17.leanback.widget.ListRowPresenter; -import android.support.v17.leanback.widget.ObjectAdapter; +import android.support.v17.leanback.widget.TitleViewAdapter; +import android.text.TextUtils; import android.util.Log; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.data.GenreItems; import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; +import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener; import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.util.TvInputManagerHelper; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; /** * {@link BrowseFragment} for DVR functions. */ -public class DvrBrowseFragment extends BrowseFragment { +public class DvrBrowseFragment extends BrowseFragment implements + RecordedProgramListener, ScheduledRecordingListener, SeriesRecordingListener, + OnDvrScheduleLoadFinishedListener, OnRecordedProgramLoadFinishedListener { private static final String TAG = "DvrBrowseFragment"; private static final boolean DEBUG = false; - private ScheduledRecordingsAdapter mRecordingsInProgressAdapter; - private ScheduledRecordingsAdapter mRecordingsNotStatedAdapter; - private RecordedProgramsAdapter mRecordedProgramsAdapter; - - @IntDef({DVR_CURRENT_RECORDINGS, DVR_SCHEDULED_RECORDINGS, DVR_RECORDED_PROGRAMS, DVR_SETTINGS}) - @Retention(RetentionPolicy.SOURCE) - public @interface DVR_HEADERS_MODE {} - public static final int DVR_CURRENT_RECORDINGS = 0; - public static final int DVR_SCHEDULED_RECORDINGS = 1; - public static final int DVR_RECORDED_PROGRAMS = 2; - public static final int DVR_SETTINGS = 3; - - private static final LinkedHashMap<Integer, Integer> sHeaders = - new LinkedHashMap<Integer, Integer>() {{ - put(DVR_CURRENT_RECORDINGS, R.string.dvr_main_current_recordings); - put(DVR_SCHEDULED_RECORDINGS, R.string.dvr_main_scheduled_recordings); - put(DVR_RECORDED_PROGRAMS, R.string.dvr_main_recorded_programs); - /* put(DVR_SETTINGS, R.string.dvr_main_settings); */ // TODO: Temporarily remove it for DP. - }}; + private static final int MAX_RECENT_ITEM_COUNT = 10; + private static final int MAX_SCHEDULED_ITEM_COUNT = 4; + private RecordedProgramAdapter mRecentAdapter; + private ScheduleAdapter mScheduleAdapter; + private RecordedProgramAdapter mSeriesAdapter; + private RecordedProgramAdapter[] mGenreAdapters = + new RecordedProgramAdapter[GenreItems.getGenreCount() + 1]; + private ListRow mRecentRow; + private ListRow mSeriesRow; + private ListRow[] mGenreRows = new ListRow[GenreItems.getGenreCount() + 1]; + private List<String> mGenreLabels; private DvrDataManager mDvrDataManager; + private TvInputManagerHelper mTvInputManagerHelper; private ArrayObjectAdapter mRowsAdapter; + private ClassPresenterSelector mPresenterSelector; + private final HashMap<String, RecordedProgram> mSeriesId2LatestProgram = new HashMap<>(); + private final Handler mHandler = new Handler(); + + private final Comparator<Object> RECORDED_PROGRAM_COMPARATOR = new Comparator<Object>() { + @Override + public int compare(Object lhs, Object rhs) { + if (lhs instanceof SeriesRecording) { + lhs = mSeriesId2LatestProgram.get(((SeriesRecording) lhs).getSeriesId()); + } + if (rhs instanceof SeriesRecording) { + rhs = mSeriesId2LatestProgram.get(((SeriesRecording) rhs).getSeriesId()); + } + if (lhs instanceof RecordedProgram) { + if (rhs instanceof RecordedProgram) { + return RecordedProgram.START_TIME_THEN_ID_COMPARATOR.reversed() + .compare((RecordedProgram) lhs, (RecordedProgram) rhs); + } else { + return -1; + } + } else if (rhs instanceof RecordedProgram) { + return 1; + } else { + return 0; + } + } + }; + + private final Comparator<Object> SCHEDULE_COMPARATOR = new Comparator<Object>() { + @Override + public int compare(Object lhs, Object rhs) { + if (lhs instanceof ScheduledRecording) { + if (rhs instanceof ScheduledRecording) { + return ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR + .compare((ScheduledRecording) lhs, (ScheduledRecording) rhs); + } else { + return -1; + } + } else if (rhs instanceof ScheduledRecording) { + return 1; + } else { + return 0; + } + } + }; + + private final TvInputCallback mTvInputCallback = new TvInputCallback() { + @Override + public void onInputAdded(String inputId) { + List<ScheduledRecording> scheduleRecordings = + mDvrDataManager.getScheduledRecordings(inputId); + if (!scheduleRecordings.isEmpty()) { + onScheduledRecordingStatusChanged(ScheduledRecording.toArray(scheduleRecordings)); + } + handleSeriesRecordingsChanged(mDvrDataManager.getSeriesRecordings(inputId)); + for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) { + if (TextUtils.equals(recordedProgram.getInputId(), inputId)) { + handleRecordedProgramChanged(recordedProgram); + } + } + postUpdateRows(); + } + + @Override + public void onInputRemoved(String inputId) { + List<ScheduledRecording> scheduleRecordings = + mDvrDataManager.getScheduledRecordings(inputId); + onScheduledRecordingRemoved( + scheduleRecordings.toArray(new ScheduledRecording[scheduleRecordings.size()])); + handleSeriesRecordingsRemoved(mDvrDataManager.getSeriesRecordings(inputId)); + for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) { + if (TextUtils.equals(recordedProgram.getInputId(), inputId)) { + handleRecordedProgramRemoved(recordedProgram); + } + } + postUpdateRows(); + } + }; + + private final Runnable mUpdateRowsRunnable = new Runnable() { + @Override + public void run() { + updateRows(); + } + }; @Override public void onCreate(Bundle savedInstanceState) { if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); - mDvrDataManager = TvApplication.getSingletons(getContext()).getDvrDataManager(); + Context context = getContext(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mTvInputManagerHelper = TvApplication.getSingletons(context).getTvInputManagerHelper(); + mPresenterSelector = new ClassPresenterSelector() + .addClassPresenter(ScheduledRecording.class, + new ScheduledRecordingPresenter(context)) + .addClassPresenter(RecordedProgram.class, new RecordedProgramPresenter(context)) + .addClassPresenter(SeriesRecording.class, new SeriesRecordingPresenter(context)) + .addClassPresenter(FullScheduleCardHolder.class, new FullSchedulesCardPresenter()); + mGenreLabels = new ArrayList<>(Arrays.asList(GenreItems.getLabels(context))); + mGenreLabels.add(getString(R.string.dvr_main_others)); setupUiElements(); setupAdapters(); - mRecordingsInProgressAdapter.start(); - mRecordingsNotStatedAdapter.start(); - mRecordedProgramsAdapter.start(); - initRows(); + mTvInputManagerHelper.addCallback(mTvInputCallback); prepareEntranceTransition(); - startEntranceTransition(); - } - - @Override - public void onStart() { - if (DEBUG) Log.d(TAG, "onStart"); - super.onStart(); - // TODO: It's a workaround for a bug that a progress bar isn't hidden. - // We need to remove it later. - getProgressBarManager().disableProgressBar(); + if (mDvrDataManager.isInitialized()) { + startEntranceTransition(); + } else { + if (!mDvrDataManager.isDvrScheduleLoadFinished()) { + mDvrDataManager.addDvrScheduleLoadFinishedListener(this); + } + if (!mDvrDataManager.isRecordedProgramLoadFinished()) { + mDvrDataManager.addRecordedProgramLoadFinishedListener(this); + } + } } @Override public void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy"); - mRecordingsInProgressAdapter.stop(); - mRecordingsNotStatedAdapter.stop(); - mRecordedProgramsAdapter.stop(); super.onDestroy(); + mHandler.removeCallbacks(mUpdateRowsRunnable); + mTvInputManagerHelper.removeCallback(mTvInputCallback); + mDvrDataManager.removeRecordedProgramListener(this); + mDvrDataManager.removeScheduledRecordingListener(this); + mDvrDataManager.removeSeriesRecordingListener(this); + mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + mRowsAdapter.clear(); + mSeriesId2LatestProgram.clear(); + } + + @Override + public void onDvrScheduleLoadFinished() { + List<ScheduledRecording> scheduledRecordings = mDvrDataManager.getAllScheduledRecordings(); + onScheduledRecordingAdded(ScheduledRecording.toArray(scheduledRecordings)); + List<SeriesRecording> seriesRecordings = mDvrDataManager.getSeriesRecordings(); + onSeriesRecordingAdded(SeriesRecording.toArray(seriesRecordings)); + if (mDvrDataManager.isInitialized()) { + startEntranceTransition(); + } + mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); + } + + @Override + public void onRecordedProgramLoadFinished() { + for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) { + if (isInputExist(recordedProgram.getInputId())) { + handleRecordedProgramAdded(recordedProgram, true); + } + } + updateRows(); + if (mDvrDataManager.isInitialized()) { + startEntranceTransition(); + } + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + } + + @Override + public void onRecordedProgramAdded(RecordedProgram recordedProgram) { + if (isInputExist(recordedProgram.getInputId())) { + handleRecordedProgramAdded(recordedProgram, true); + postUpdateRows(); + } + } + + @Override + public void onRecordedProgramChanged(RecordedProgram recordedProgram) { + if (isInputExist(recordedProgram.getInputId())) { + handleRecordedProgramChanged(recordedProgram); + postUpdateRows(); + } + } + + @Override + public void onRecordedProgramRemoved(RecordedProgram recordedProgram) { + if (isInputExist(recordedProgram.getInputId())) { + handleRecordedProgramRemoved(recordedProgram); + postUpdateRows(); + } + } + + // No need to call updateRows() during ScheduledRecordings' change because + // the row for ScheduledRecordings is always displayed. + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording scheduleRecording : scheduledRecordings) { + if (needToShowScheduledRecording(scheduleRecording)) { + mScheduleAdapter.add(scheduleRecording); + } + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording scheduleRecording : scheduledRecordings) { + mScheduleAdapter.remove(scheduleRecording); + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording scheduleRecording : scheduledRecordings) { + if (needToShowScheduledRecording(scheduleRecording)) { + mScheduleAdapter.change(scheduleRecording); + } else { + mScheduleAdapter.removeWithId(scheduleRecording); + } + } + } + + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { + handleSeriesRecordingsAdded(Arrays.asList(seriesRecordings)); + postUpdateRows(); + } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + handleSeriesRecordingsRemoved(Arrays.asList(seriesRecordings)); + postUpdateRows(); + } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + handleSeriesRecordingsChanged(Arrays.asList(seriesRecordings)); + postUpdateRows(); + } + + // Workaround of b/29108300 + @Override + public void showTitle(int flags) { + flags &= ~TitleViewAdapter.SEARCH_VIEW_VISIBLE; + super.showTitle(flags); } private void setupUiElements() { + setBadgeDrawable(getActivity().getDrawable(R.drawable.ic_dvr_badge)); setHeadersState(HEADERS_ENABLED); setHeadersTransitionOnBackEnabled(false); + setBrandColor(getResources().getColor(R.color.program_guide_side_panel_background, null)); } private void setupAdapters() { + mRecentAdapter = new RecordedProgramAdapter(MAX_RECENT_ITEM_COUNT); + mScheduleAdapter = new ScheduleAdapter(MAX_SCHEDULED_ITEM_COUNT); + mSeriesAdapter = new RecordedProgramAdapter(); + for (int i = 0; i < mGenreAdapters.length; i++) { + mGenreAdapters[i] = new RecordedProgramAdapter(); + } + // Schedule Recordings. + List<ScheduledRecording> schedules = mDvrDataManager.getAllScheduledRecordings(); + onScheduledRecordingAdded(ScheduledRecording.toArray(schedules)); + mScheduleAdapter.addExtraItem(FullScheduleCardHolder.FULL_SCHEDULE_CARD_HOLDER); + // Recorded Programs. + for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) { + if (isInputExist(recordedProgram.getInputId())) { + handleRecordedProgramAdded(recordedProgram, false); + } + } + // Series Recordings. Series recordings should be added after recorded programs, because + // we build series recordings' latest program information while adding recorded programs. + List<SeriesRecording> recordings = mDvrDataManager.getSeriesRecordings(); + handleSeriesRecordingsAdded(recordings); mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); + mRecentRow = new ListRow(new HeaderItem( + getString(R.string.dvr_main_recent)), mRecentAdapter); + mRowsAdapter.add(new ListRow(new HeaderItem( + getString(R.string.dvr_main_scheduled)), mScheduleAdapter)); + mSeriesRow = new ListRow(new HeaderItem( + getString(R.string.dvr_main_series)), mSeriesAdapter); + updateRows(); + mDvrDataManager.addRecordedProgramListener(this); + mDvrDataManager.addScheduledRecordingListener(this); + mDvrDataManager.addSeriesRecordingListener(this); setAdapter(mRowsAdapter); - ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); - EmptyItemPresenter emptyItemPresenter = new EmptyItemPresenter(this); - ScheduledRecordingPresenter scheduledRecordingPresenter = new ScheduledRecordingPresenter( - getContext()); - RecordedProgramPresenter recordedProgramPresenter = new RecordedProgramPresenter( - getContext()); - presenterSelector.addClassPresenter(ScheduledRecording.class, scheduledRecordingPresenter); - presenterSelector.addClassPresenter(RecordedProgram.class, recordedProgramPresenter); - presenterSelector.addClassPresenter(EmptyHolder.class, emptyItemPresenter); - mRecordingsInProgressAdapter = new ScheduledRecordingsAdapter(mDvrDataManager, - ScheduledRecording.STATE_RECORDING_IN_PROGRESS, presenterSelector); - mRecordingsNotStatedAdapter = new ScheduledRecordingsAdapter(mDvrDataManager, - ScheduledRecording.STATE_RECORDING_NOT_STARTED, presenterSelector); - mRecordedProgramsAdapter = new RecordedProgramsAdapter(mDvrDataManager, presenterSelector); - } - - private void initRows() { - mRowsAdapter.clear(); - for (@DVR_HEADERS_MODE int i : sHeaders.keySet()) { - HeaderItem gridHeader = new HeaderItem(i, getContext().getString(sHeaders.get(i))); - ObjectAdapter gridRowAdapter = null; - switch (i) { - case DVR_CURRENT_RECORDINGS: { - gridRowAdapter = mRecordingsInProgressAdapter; - break; + } + + private void handleRecordedProgramAdded(RecordedProgram recordedProgram, + boolean updateSeriesRecording) { + mRecentAdapter.add(recordedProgram); + String seriesId = recordedProgram.getSeriesId(); + SeriesRecording seriesRecording = null; + if (seriesId != null) { + seriesRecording = mDvrDataManager.getSeriesRecording(seriesId); + RecordedProgram latestProgram = mSeriesId2LatestProgram.get(seriesId); + if (latestProgram == null || RecordedProgram.START_TIME_THEN_ID_COMPARATOR + .compare(latestProgram, recordedProgram) < 0) { + mSeriesId2LatestProgram.put(seriesId, recordedProgram); + if (updateSeriesRecording && seriesRecording != null) { + onSeriesRecordingChanged(seriesRecording); + } + } + } + if (seriesRecording == null) { + for (RecordedProgramAdapter adapter + : getGenreAdapters(recordedProgram.getCanonicalGenres())) { + adapter.add(recordedProgram); + } + } + } + + private void handleRecordedProgramRemoved(RecordedProgram recordedProgram) { + mRecentAdapter.remove(recordedProgram); + String seriesId = recordedProgram.getSeriesId(); + if (seriesId != null) { + SeriesRecording seriesRecording = mDvrDataManager.getSeriesRecording(seriesId); + RecordedProgram latestProgram = + mSeriesId2LatestProgram.get(recordedProgram.getSeriesId()); + if (latestProgram != null && latestProgram.getId() == recordedProgram.getId()) { + if (seriesRecording != null) { + updateLatestRecordedProgram(seriesRecording); + onSeriesRecordingChanged(seriesRecording); } - case DVR_SCHEDULED_RECORDINGS: { - gridRowAdapter = mRecordingsNotStatedAdapter; + } + } + for (RecordedProgramAdapter adapter + : getGenreAdapters(recordedProgram.getCanonicalGenres())) { + adapter.remove(recordedProgram); + } + } + + private void handleRecordedProgramChanged(RecordedProgram recordedProgram) { + mRecentAdapter.change(recordedProgram); + String seriesId = recordedProgram.getSeriesId(); + SeriesRecording seriesRecording = null; + if (seriesId != null) { + seriesRecording = mDvrDataManager.getSeriesRecording(seriesId); + RecordedProgram latestProgram = mSeriesId2LatestProgram.get(seriesId); + if (latestProgram == null || RecordedProgram.START_TIME_THEN_ID_COMPARATOR + .compare(latestProgram, recordedProgram) <= 0) { + mSeriesId2LatestProgram.put(seriesId, recordedProgram); + if (seriesRecording != null) { + onSeriesRecordingChanged(seriesRecording); } - break; - case DVR_RECORDED_PROGRAMS: { - gridRowAdapter = mRecordedProgramsAdapter; + } else if (latestProgram.getId() == recordedProgram.getId()) { + if (seriesRecording != null) { + updateLatestRecordedProgram(seriesRecording); + onSeriesRecordingChanged(seriesRecording); } - break; - case DVR_SETTINGS: - gridRowAdapter = new ArrayObjectAdapter(new EmptyItemPresenter(this)); - // TODO: provide setup rows. - break; } - if (gridRowAdapter != null) { - mRowsAdapter.add(new ListRow(gridHeader, gridRowAdapter)); + } + if (seriesRecording == null) { + updateGenreAdapters(getGenreAdapters( + recordedProgram.getCanonicalGenres()), recordedProgram); + } else { + updateGenreAdapters(new ArrayList<>(), recordedProgram); + } + } + + private void handleSeriesRecordingsAdded(List<SeriesRecording> seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + if (isInputExist(seriesRecording.getInputId())) { + mSeriesAdapter.add(seriesRecording); + if (mSeriesId2LatestProgram.get(seriesRecording.getSeriesId()) != null) { + for (RecordedProgramAdapter adapter + : getGenreAdapters(seriesRecording.getCanonicalGenreIds())) { + adapter.add(seriesRecording); + } + } + } + } + } + + private void handleSeriesRecordingsRemoved(List<SeriesRecording> seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + mSeriesAdapter.remove(seriesRecording); + for (RecordedProgramAdapter adapter + : getGenreAdapters(seriesRecording.getCanonicalGenreIds())) { + adapter.remove(seriesRecording); + } + } + } + + private void handleSeriesRecordingsChanged(List<SeriesRecording> seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + if (isInputExist(seriesRecording.getInputId())) { + mSeriesAdapter.change(seriesRecording); + if (mSeriesId2LatestProgram.get(seriesRecording.getSeriesId()) != null) { + updateGenreAdapters(getGenreAdapters( + seriesRecording.getCanonicalGenreIds()), seriesRecording); + } else { + // Remove series recording from all genre rows if it has no recorded program + updateGenreAdapters(new ArrayList<>(), seriesRecording); + } + } + } + } + + private List<RecordedProgramAdapter> getGenreAdapters(String[] genres) { + List<RecordedProgramAdapter> result = new ArrayList<>(); + if (genres == null || genres.length == 0) { + result.add(mGenreAdapters[mGenreAdapters.length - 1]); + } else { + for (String genre : genres) { + int genreId = GenreItems.getId(genre); + if(genreId >= mGenreAdapters.length) { + Log.d(TAG, "Wrong Genre ID: " + genreId); + } else { + result.add(mGenreAdapters[genreId]); + } + } + } + return result; + } + + private List<RecordedProgramAdapter> getGenreAdapters(int[] genreIds) { + List<RecordedProgramAdapter> result = new ArrayList<>(); + if (genreIds == null || genreIds.length == 0) { + result.add(mGenreAdapters[mGenreAdapters.length - 1]); + } else { + for (int genreId : genreIds) { + if(genreId >= mGenreAdapters.length) { + Log.d(TAG, "Wrong Genre ID: " + genreId); + } else { + result.add(mGenreAdapters[genreId]); + } + } + } + return result; + } + + private void updateGenreAdapters(List<RecordedProgramAdapter> adapters, Object r) { + for (RecordedProgramAdapter adapter : mGenreAdapters) { + if (adapters.contains(adapter)) { + adapter.change(r); + } else { + adapter.remove(r); + } + } + } + + private void postUpdateRows() { + mHandler.removeCallbacks(mUpdateRowsRunnable); + mHandler.post(mUpdateRowsRunnable); + } + + private void updateRows() { + int visibleRowsCount = 1; // Schedule's Row will never be empty + if (mRecentAdapter.isEmpty()) { + mRowsAdapter.remove(mRecentRow); + } else { + if (mRowsAdapter.indexOf(mRecentRow) < 0) { + mRowsAdapter.add(0, mRecentRow); + } + visibleRowsCount++; + } + if (mSeriesAdapter.isEmpty()) { + mRowsAdapter.remove(mSeriesRow); + } else { + if (mRowsAdapter.indexOf(mSeriesRow) < 0) { + mRowsAdapter.add(visibleRowsCount, mSeriesRow); + } + visibleRowsCount++; + } + for (int i = 0; i < mGenreAdapters.length; i++) { + RecordedProgramAdapter adapter = mGenreAdapters[i]; + if (adapter != null) { + if (adapter.isEmpty()) { + mRowsAdapter.remove(mGenreRows[i]); + } else { + if (mGenreRows[i] == null || mRowsAdapter.indexOf(mGenreRows[i]) < 0) { + mGenreRows[i] = new ListRow(new HeaderItem(mGenreLabels.get(i)), adapter); + mRowsAdapter.add(visibleRowsCount, mGenreRows[i]); + } + visibleRowsCount++; + } + } + } + } + + private boolean isInputExist(String inputId) { + return mTvInputManagerHelper.getTvInputInfo(inputId) != null; + } + + private boolean needToShowScheduledRecording(ScheduledRecording recording) { + int state = recording.getState(); + return isInputExist(recording.getInputId()) + && (state == ScheduledRecording.STATE_RECORDING_IN_PROGRESS + || state == ScheduledRecording.STATE_RECORDING_NOT_STARTED); + } + + private void updateLatestRecordedProgram(SeriesRecording seriesRecording) { + RecordedProgram latestProgram = null; + for (RecordedProgram program : + mDvrDataManager.getRecordedPrograms(seriesRecording.getId())) { + if (isInputExist(program.getInputId()) && (latestProgram == null || RecordedProgram + .START_TIME_THEN_ID_COMPARATOR.compare(latestProgram, program) < 0)) { + latestProgram = program; + } + } + mSeriesId2LatestProgram.put(seriesRecording.getSeriesId(), latestProgram); + } + + private class ScheduleAdapter extends SortedArrayAdapter<Object> { + ScheduleAdapter(int maxItemCount) { + super(mPresenterSelector, SCHEDULE_COMPARATOR, maxItemCount); + } + + @Override + public long getId(Object item) { + if (item instanceof ScheduledRecording) { + return ((ScheduledRecording) item).getId(); + } else { + return -1; + } + } + } + + private class RecordedProgramAdapter extends SortedArrayAdapter<Object> { + RecordedProgramAdapter() { + this(Integer.MAX_VALUE); + } + + RecordedProgramAdapter(int maxItemCount) { + super(mPresenterSelector, RECORDED_PROGRAM_COMPARATOR, maxItemCount); + } + + @Override + public long getId(Object item) { + if (item instanceof SeriesRecording) { + return ((SeriesRecording) item).getId(); + } else if (item instanceof RecordedProgram) { + return ((RecordedProgram) item).getId(); + } else { + return -1; } } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingDialogFragment.java b/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingDialogFragment.java new file mode 100644 index 00000000..d1cf57a6 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingDialogFragment.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.DialogFragment; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; + +/** + * A dialog fragment which contains {@link DvrCancelAllSeriesRecordingFragment}. + */ +public class DvrCancelAllSeriesRecordingDialogFragment extends DialogFragment { + public static final String DIALOG_TAG = "dialog_tag"; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.halfsized_dialog, container, false); + GuidedStepFragment fragment = new DvrCancelAllSeriesRecordingFragment(); + fragment.setArguments(getArguments()); + GuidedStepFragment.add(getChildFragmentManager(), fragment, R.id.halfsized_dialog_host); + return view; + } + + @Override + public int getTheme() { + return R.style.Theme_TV_dialog_HalfSizedDialog; + } +} diff --git a/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingFragment.java new file mode 100644 index 00000000..78f73fd5 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrCancelAllSeriesRecordingFragment.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.app.DialogFragment; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; + +import com.android.tv.R; + +import java.util.List; + +/** + * A fragment which asks the user to cancel all series schedules recordings. + */ +public class DvrCancelAllSeriesRecordingFragment extends DvrGuidedStepFragment { + private static final int ACTION_CANCEL_ALL = 1; + private static final int ACTION_BACK = 2; + + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_series_schedules_dialog_cancel_all); + Drawable icon = getContext().getDrawable(R.drawable.ic_dvr_delete); + return new GuidanceStylist.Guidance(title, null, null, icon); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_CANCEL_ALL) + .title(getResources().getString(R.string.dvr_series_schedules_cancel_all)) + .build()); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_BACK) + .title(getResources().getString(R.string.dvr_series_schedules_dialog_back)) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + DvrSchedulesActivity activity = (DvrSchedulesActivity) getActivity(); + if (action.getId() == ACTION_CANCEL_ALL) { + activity.onCancelAllClicked(); + } + dismissDialog(); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java new file mode 100644 index 00000000..fe65eebd --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelRecordConflictFragment; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragment { + private final List<Long> mDurations = new ArrayList<>(); + private Channel mChannel; + private Program mProgram; + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); + mChannel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(channelId); + } + SoftPreconditions.checkArgument(mChannel != null); + super.onCreate(savedInstanceState); + } + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_channel_record_duration_dialog_title); + Drawable icon = getResources().getDrawable(R.drawable.ic_dvr, null); + return new Guidance(title, null, null, icon); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + int actionId = -1; + mDurations.clear(); + mDurations.add(TimeUnit.MINUTES.toMillis(10)); + mDurations.add(TimeUnit.MINUTES.toMillis(30)); + mDurations.add(TimeUnit.HOURS.toMillis(1)); + mDurations.add(TimeUnit.HOURS.toMillis(3)); + + actions.add(new GuidedAction.Builder(getContext()) + .id(++actionId) + .title(R.string.recording_start_dialog_10_min_duration) + .build()); + actions.add(new GuidedAction.Builder(getContext()) + .id(++actionId) + .title(R.string.recording_start_dialog_30_min_duration) + .build()); + actions.add(new GuidedAction.Builder(getContext()) + .id(++actionId) + .title(R.string.recording_start_dialog_1_hour_duration) + .build()); + actions.add(new GuidedAction.Builder(getContext()) + .id(++actionId) + .title(R.string.recording_start_dialog_3_hours_duration) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + long duration = mDurations.get((int) action.getId()); + long startTimeMs = System.currentTimeMillis(); + long endTimeMs = System.currentTimeMillis() + duration; + List<ScheduledRecording> conflicts = dvrManager.getConflictingSchedules( + mChannel.getId(), startTimeMs, endTimeMs); + dvrManager.addSchedule(mChannel, startTimeMs, endTimeMs); + if (conflicts.isEmpty()) { + dismissDialog(); + } else { + GuidedStepFragment fragment = new DvrChannelRecordConflictFragment(); + Bundle args = new Bundle(); + args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, mChannel.getId()); + args.putLong(DvrHalfSizedDialogFragment.KEY_START_TIME_MS, startTimeMs); + args.putLong(DvrHalfSizedDialogFragment.KEY_END_TIME_MS, endTimeMs); + fragment.setArguments(args); + GuidedStepFragment.add(getFragmentManager(), fragment, + R.id.halfsized_dialog_host); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrConflictFragment.java b/src/com/android/tv/dvr/ui/DvrConflictFragment.java new file mode 100644 index 00000000..e7be4d0a --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrConflictFragment.java @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.graphics.drawable.Drawable; +import android.media.tv.TvInputInfo; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.MainActivity; +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.data.Program; +import com.android.tv.dvr.ConflictChecker; +import com.android.tv.dvr.ConflictChecker.OnUpcomingConflictChangeListener; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +public abstract class DvrConflictFragment extends DvrGuidedStepFragment { + private static final String TAG = "DvrConflictFragment"; + private static final boolean DEBUG = false; + + private static final int ACTION_DELETE_CONFLICT = 1; + private static final int ACTION_CANCEL = 2; + private static final int ACTION_VIEW_SCHEDULES = 3; + + // The program count which will be listed in the description. This is the number of the + // program strings in R.plurals.dvr_program_conflict_dialog_description_many. + private static final int LISTED_PROGRAM_COUNT = 2; + + protected List<ScheduledRecording> mConflicts; + + void setConflicts(List<ScheduledRecording> conflicts) { + mConflicts = conflicts; + } + + List<ScheduledRecording> getConflicts() { + return mConflicts; + } + + @Override + public int onProvideTheme() { + return R.style.Theme_TV_Dvr_Conflict_GuidedStep; + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, + Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getContext()) + .clickAction(GuidedAction.ACTION_ID_OK) + .build()); + actions.add(new GuidedAction.Builder(getContext()) + .id(ACTION_VIEW_SCHEDULES) + .title(R.string.dvr_action_view_schedules) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_VIEW_SCHEDULES) { + DvrUiHelper.startSchedulesActivityForOneTimeRecordingConflict( + getContext(), getConflicts()); + } + dismissDialog(); + } + + String getConflictDescription() { + List<String> titles = new ArrayList<>(); + HashSet<String> titleSet = new HashSet<>(); + for (ScheduledRecording schedule : getConflicts()) { + String scheduleTitle = getScheduleTitle(schedule); + if (scheduleTitle != null && !titleSet.contains(scheduleTitle)) { + titles.add(scheduleTitle); + titleSet.add(scheduleTitle); + } + } + switch (titles.size()) { + case 0: + Log.i(TAG, "Conflict has been resolved by any reason. Maybe input might have" + + " been deleted."); + return null; + case 1: + return getResources().getString( + R.string.dvr_program_conflict_dialog_description_1, titles.get(0)); + case 2: + return getResources().getString( + R.string.dvr_program_conflict_dialog_description_2, titles.get(0), + titles.get(1)); + case 3: + return getResources().getString( + R.string.dvr_program_conflict_dialog_description_3, titles.get(0), + titles.get(1)); + default: + return getResources().getQuantityString( + R.plurals.dvr_program_conflict_dialog_description_many, + titles.size() - LISTED_PROGRAM_COUNT, titles.get(0), titles.get(1), + titles.size() - LISTED_PROGRAM_COUNT); + } + } + + @Nullable + private String getScheduleTitle(ScheduledRecording schedule) { + if (schedule.getType() == ScheduledRecording.TYPE_TIMED) { + Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(schedule.getChannelId()); + if (channel != null) { + return channel.getDisplayName(); + } else { + return null; + } + } else { + return schedule.getProgramTitle(); + } + } + + /** + * A fragment to show the program conflict. + */ + public static class DvrProgramConflictFragment extends DvrConflictFragment { + private Program mProgram; + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); + } + SoftPreconditions.checkArgument(mProgram != null); + TvInputInfo input = Utils.getTvInputInfoForProgram(getContext(), mProgram); + SoftPreconditions.checkNotNull(input); + List<ScheduledRecording> conflicts = null; + if (input != null) { + conflicts = TvApplication.getSingletons(getContext()).getDvrManager() + .getConflictingSchedules(mProgram); + } + if (conflicts == null) { + conflicts = Collections.emptyList(); + } + if (conflicts.isEmpty()) { + dismissDialog(); + } + setConflicts(conflicts); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_program_conflict_dialog_title); + String descriptionPrefix = getString( + R.string.dvr_program_conflict_dialog_description_prefix, mProgram.getTitle()); + String description = getConflictDescription(); + if (description == null) { + dismissDialog(); + } + Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null); + return new Guidance(title, descriptionPrefix + " " + description, null, icon); + } + } + + /** + * A fragment to show the channel recording conflict. + */ + public static class DvrChannelRecordConflictFragment extends DvrConflictFragment { + private Channel mChannel; + private long mStartTimeMs; + private long mEndTimeMs; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + Bundle args = getArguments(); + long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); + mChannel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(channelId); + SoftPreconditions.checkArgument(mChannel != null); + TvInputInfo input = Utils.getTvInputInfoForChannelId(getContext(), mChannel.getId()); + SoftPreconditions.checkNotNull(input); + List<ScheduledRecording> conflicts = null; + if (input != null) { + mStartTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_START_TIME_MS); + mEndTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_END_TIME_MS); + conflicts = TvApplication.getSingletons(getContext()).getDvrManager() + .getConflictingSchedules(mChannel.getId(), mStartTimeMs, mEndTimeMs); + } + if (conflicts == null) { + conflicts = Collections.emptyList(); + } + if (conflicts.isEmpty()) { + dismissDialog(); + } + setConflicts(conflicts); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_channel_conflict_dialog_title); + String descriptionPrefix = getString( + R.string.dvr_channel_conflict_dialog_description_prefix, + mChannel.getDisplayName()); + String description = getConflictDescription(); + if (description == null) { + dismissDialog(); + } + Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null); + return new Guidance(title, descriptionPrefix + " " + description, null, icon); + } + } + + /** + * A fragment to show the channel watching conflict. + * <p> + * This fragment is automatically closed when there are no upcoming conflicts. + */ + public static class DvrChannelWatchConflictFragment extends DvrConflictFragment + implements OnUpcomingConflictChangeListener { + private long mChannelId; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mChannelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); + } + SoftPreconditions.checkArgument(mChannelId != Channel.INVALID_ID); + ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); + List<ScheduledRecording> conflicts = null; + if (checker != null) { + checker.addOnUpcomingConflictChangeListener(this); + conflicts = checker.getUpcomingConflicts(); + if (DEBUG) Log.d(TAG, "onCreateView: upcoming conflicts: " + conflicts); + if (conflicts.isEmpty()) { + dismissDialog(); + } + } + if (conflicts == null) { + if (DEBUG) Log.d(TAG, "onCreateView: There's no conflict."); + conflicts = Collections.emptyList(); + } + if (conflicts.isEmpty()) { + dismissDialog(); + } + setConflicts(conflicts); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString( + R.string.dvr_epg_channel_watch_conflict_dialog_title); + String description = getResources().getString( + R.string.dvr_epg_channel_watch_conflict_dialog_description); + return new Guidance(title, description, null, null); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, + Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getContext()) + .id(ACTION_DELETE_CONFLICT) + .title(R.string.dvr_action_delete_schedule) + .build()); + actions.add(new GuidedAction.Builder(getContext()) + .id(ACTION_CANCEL) + .title(R.string.dvr_action_record_program) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_CANCEL) { + ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); + if (checker != null) { + checker.setCheckedConflictsForChannel(mChannelId, getConflicts()); + } + } else if (action.getId() == ACTION_DELETE_CONFLICT) { + for (ScheduledRecording schedule : mConflicts) { + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + getDvrManager().stopRecording(schedule); + } else { + getDvrManager().removeScheduledRecording(schedule); + } + } + } + super.onGuidedActionClicked(action); + } + + @Override + public void onDetach() { + ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); + if (checker != null) { + checker.removeOnUpcomingConflictChangeListener(this); + } + super.onDetach(); + } + + @Override + public void onUpcomingConflictChange() { + ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); + if (checker == null || checker.getUpcomingConflicts().isEmpty()) { + if (DEBUG) Log.d(TAG, "onUpcomingConflictChange: There's no conflict."); + dismissDialog(); + } + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrDetailsActivity.java b/src/com/android/tv/dvr/ui/DvrDetailsActivity.java new file mode 100644 index 00000000..b273c85c --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrDetailsActivity.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v17.leanback.app.DetailsFragment; + +import com.android.tv.R; + +/** + * Activity to show details view in DVR. + */ +public class DvrDetailsActivity extends Activity { + /** + * Name of record id added to the Intent. + */ + public static final String RECORDING_ID = "record_id"; + + /** + * Name of flag added to the Intent to determine if details view should hide "View schedule" + * button. + */ + public static final String HIDE_VIEW_SCHEDULE = "hide_view_schedule"; + + /** + * Name of details view's type added to the intent. + */ + public static final String DETAILS_VIEW_TYPE = "details_view_type"; + + /** + * Name of shared element between activities. + */ + public static final String SHARED_ELEMENT_NAME = "shared_element"; + + /** + * CURRENT_RECORDING_VIEW refers to Current Recordings in DVR. + */ + public static final int CURRENT_RECORDING_VIEW = 1; + + /** + * SCHEDULED_RECORDING_VIEW refers to Scheduled Recordings in DVR. + */ + public static final int SCHEDULED_RECORDING_VIEW = 2; + + /** + * RECORDED_PROGRAM_VIEW refers to Recorded programs in DVR. + */ + public static final int RECORDED_PROGRAM_VIEW = 3; + + /** + * SERIES_RECORDING_VIEW refers to series recording in DVR. + */ + public static final int SERIES_RECORDING_VIEW = 4; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dvr_details); + long recordId = getIntent().getLongExtra(RECORDING_ID, -1); + int detailsViewType = getIntent().getIntExtra(DETAILS_VIEW_TYPE, -1); + boolean hideViewSchedule = getIntent().getBooleanExtra(HIDE_VIEW_SCHEDULE, false); + if (recordId != -1 && detailsViewType != -1 && savedInstanceState == null) { + Bundle args = new Bundle(); + args.putLong(RECORDING_ID, recordId); + DetailsFragment detailsFragment = null; + if (detailsViewType == CURRENT_RECORDING_VIEW) { + detailsFragment = new CurrentRecordingDetailsFragment(); + } else if (detailsViewType == SCHEDULED_RECORDING_VIEW) { + args.putBoolean(HIDE_VIEW_SCHEDULE, hideViewSchedule); + detailsFragment = new ScheduledRecordingDetailsFragment(); + } else if (detailsViewType == RECORDED_PROGRAM_VIEW) { + detailsFragment = new RecordedProgramDetailsFragment(); + } else if (detailsViewType == SERIES_RECORDING_VIEW) { + detailsFragment = new SeriesRecordingDetailsFragment(); + } + detailsFragment.setArguments(args); + getFragmentManager().beginTransaction() + .replace(R.id.dvr_details_view_frame, detailsFragment).commit(); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrDetailsFragment.java b/src/com/android/tv/dvr/ui/DvrDetailsFragment.java new file mode 100644 index 00000000..be995fcb --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrDetailsFragment.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v17.leanback.app.DetailsFragment; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.DetailsOverviewRow; +import android.support.v17.leanback.widget.DetailsOverviewRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.PresenterSelector; +import android.support.v17.leanback.widget.SparseArrayObjectAdapter; +import android.support.v17.leanback.widget.VerticalGridView; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; + +import com.android.tv.R; +import com.android.tv.data.BaseProgram; +import com.android.tv.data.Channel; +import com.android.tv.util.ImageLoader; + +abstract class DvrDetailsFragment extends DetailsFragment { + private static final int LOAD_LOGO_IMAGE = 1; + private static final int LOAD_BACKGROUND_IMAGE = 2; + + protected DetailsViewBackgroundHelper mBackgroundHelper; + private ArrayObjectAdapter mRowsAdapter; + private DetailsOverviewRow mDetailsOverview; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!onLoadRecordingDetails(getArguments())) { + getActivity().finish(); + return; + } + mBackgroundHelper = new DetailsViewBackgroundHelper(getActivity()); + setupAdapter(); + } + + @Override + public void onStart() { + super.onStart(); + // TODO: remove the workaround of b/30401180. + VerticalGridView container = (VerticalGridView) getActivity() + .findViewById(R.id.container_list); + // Need to manually modify offset. Please refer DetailsFragment.setVerticalGridViewLayout. + container.setItemAlignmentOffset(0); + container.setWindowAlignmentOffset( + getResources().getDimensionPixelSize(R.dimen.lb_details_rows_align_top)); + } + + private void setupAdapter() { + DetailsOverviewRowPresenter rowPresenter = + new DetailsOverviewRowPresenter(new DetailsContentPresenter()); + rowPresenter.setBackgroundColor(getResources().getColor(R.color.common_tv_background, + null)); + rowPresenter.setSharedElementEnterTransition(getActivity(), + DvrDetailsActivity.SHARED_ELEMENT_NAME); + rowPresenter.setOnActionClickedListener(onCreateOnActionClickedListener()); + mRowsAdapter = new ArrayObjectAdapter(onCreatePresenterSelector(rowPresenter)); + setAdapter(mRowsAdapter); + } + + /** + * Returns details views' rows adapter. + */ + protected ArrayObjectAdapter getRowsAdapter() { + return mRowsAdapter; + } + + /** + * Sets details overview. + */ + protected void setDetailsOverviewRow(DetailsContent detailsContent) { + mDetailsOverview = new DetailsOverviewRow(detailsContent); + mDetailsOverview.setActionsAdapter(onCreateActionsAdapter()); + mRowsAdapter.add(mDetailsOverview); + onLoadLogoAndBackgroundImages(detailsContent); + } + + /** + * Creates and returns presenter selector will be used by rows adaptor. + */ + protected PresenterSelector onCreatePresenterSelector(DetailsOverviewRowPresenter rowPresenter) { + ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); + presenterSelector.addClassPresenter(DetailsOverviewRow.class, rowPresenter); + return presenterSelector; + } + + /** + * Updates actions of details overview. + */ + protected void updateActions() { + mDetailsOverview.setActionsAdapter(onCreateActionsAdapter()); + } + + /** + * Loads recording details according to the arguments the fragment got. + * + * @return false if cannot find valid recordings, else return true. If the return value + * is false, the detail activity and fragment will be ended. + */ + abstract boolean onLoadRecordingDetails(Bundle args); + + /** + * Creates actions users can interact with and their adaptor for this fragment. + */ + abstract SparseArrayObjectAdapter onCreateActionsAdapter(); + + /** + * Creates actions listeners to implement the behavior of the fragment after users click some + * action buttons. + */ + abstract OnActionClickedListener onCreateOnActionClickedListener(); + + /** + * Returns program title with episode number. If the program is null, returns channel name. + */ + protected CharSequence getTitleFromProgram(BaseProgram program, Channel channel) { + String titleWithEpisodeNumber = program.getTitleWithEpisodeNumber(getContext()); + SpannableString title = titleWithEpisodeNumber == null ? null + : new SpannableString(titleWithEpisodeNumber); + if (TextUtils.isEmpty(title)) { + title = new SpannableString(channel != null ? channel.getDisplayName() + : getContext().getResources().getString( + R.string.no_program_information)); + } else { + String programTitle = program.getTitle(); + title.setSpan(new TextAppearanceSpan(getContext(), + R.style.text_appearance_card_view_episode_number), programTitle == null ? 0 + : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return title; + } + + /** + * Loads logo and background images for detail fragments. + */ + protected void onLoadLogoAndBackgroundImages(DetailsContent detailsContent) { + Drawable logoDrawable = null; + Drawable backgroundDrawable = null; + if (TextUtils.isEmpty(detailsContent.getLogoImageUri())) { + logoDrawable = getContext().getResources() + .getDrawable(R.drawable.dvr_default_poster, null); + mDetailsOverview.setImageDrawable(logoDrawable); + } + if (TextUtils.isEmpty(detailsContent.getBackgroundImageUri())) { + backgroundDrawable = getContext().getResources() + .getDrawable(R.drawable.dvr_default_poster, null); + mBackgroundHelper.setBackground(backgroundDrawable); + } + if (logoDrawable != null && backgroundDrawable != null) { + return; + } + if (logoDrawable == null && backgroundDrawable == null + && detailsContent.getLogoImageUri().equals( + detailsContent.getBackgroundImageUri())) { + ImageLoader.loadBitmap(getContext(), detailsContent.getLogoImageUri(), + new MyImageLoaderCallback(this, LOAD_LOGO_IMAGE | LOAD_BACKGROUND_IMAGE, + getContext())); + return; + } + if (logoDrawable == null) { + int imageWidth = getResources().getDimensionPixelSize(R.dimen.dvr_details_poster_width); + int imageHeight = getResources() + .getDimensionPixelSize(R.dimen.dvr_details_poster_height); + ImageLoader.loadBitmap(getContext(), detailsContent.getLogoImageUri(), + imageWidth, imageHeight, + new MyImageLoaderCallback(this, LOAD_LOGO_IMAGE, getContext())); + } + if (backgroundDrawable == null) { + ImageLoader.loadBitmap(getContext(), detailsContent.getBackgroundImageUri(), + new MyImageLoaderCallback(this, LOAD_BACKGROUND_IMAGE, getContext())); + } + } + + private static class MyImageLoaderCallback extends + ImageLoader.ImageLoaderCallback<DvrDetailsFragment> { + private final Context mContext; + private final int mLoadType; + + public MyImageLoaderCallback(DvrDetailsFragment fragment, + int loadType, Context context) { + super(fragment); + mLoadType = loadType; + mContext = context; + } + + @Override + public void onBitmapLoaded(DvrDetailsFragment fragment, + @Nullable Bitmap bitmap) { + Drawable drawable; + int loadType = mLoadType; + if (bitmap == null) { + Resources res = mContext.getResources(); + drawable = res.getDrawable(R.drawable.dvr_default_poster, null); + if ((loadType & LOAD_BACKGROUND_IMAGE) != 0 && !fragment.isDetached()) { + loadType &= ~LOAD_BACKGROUND_IMAGE; + fragment.mBackgroundHelper.setBackgroundColor( + res.getColor(R.color.dvr_detail_default_background)); + fragment.mBackgroundHelper.setScrim( + res.getColor(R.color.dvr_detail_default_background_scrim)); + } + } else { + drawable = new BitmapDrawable(mContext.getResources(), bitmap); + } + if (!fragment.isDetached()) { + if ((loadType & LOAD_LOGO_IMAGE) != 0) { + fragment.mDetailsOverview.setImageDrawable(drawable); + } + if ((loadType & LOAD_BACKGROUND_IMAGE) != 0) { + fragment.mBackgroundHelper.setBackground(drawable); + } + } + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrDialogFragment.java b/src/com/android/tv/dvr/ui/DvrDialogFragment.java deleted file mode 100644 index 38de9d8d..00000000 --- a/src/com/android/tv/dvr/ui/DvrDialogFragment.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.android.tv.dvr.ui; - -import android.app.FragmentManager; -import android.content.Context; -import android.os.Bundle; -import android.support.v17.leanback.app.GuidedStepFragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.tv.MainActivity; -import com.android.tv.R; -import com.android.tv.guide.ProgramGuide; - -public class DvrDialogFragment extends HalfSizedDialogFragment { - private final DvrGuidedStepFragment mDvrGuidedStepFragment; - - public DvrDialogFragment(DvrGuidedStepFragment dvrGuidedStepFragment) { - mDvrGuidedStepFragment = dvrGuidedStepFragment; - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - ProgramGuide programGuide = - ((MainActivity) getActivity()).getOverlayManager().getProgramGuide(); - if (programGuide != null && programGuide.isActive()) { - programGuide.cancelHide(); - } - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View view = super.onCreateView(inflater, container, savedInstanceState); - FragmentManager fm = getChildFragmentManager(); - GuidedStepFragment.add(fm, mDvrGuidedStepFragment, R.id.halfsized_dialog_host); - return view; - } - - @Override - public void onDetach() { - super.onDetach(); - ProgramGuide programGuide = - ((MainActivity) getActivity()).getOverlayManager().getProgramGuide(); - if (programGuide != null && programGuide.isActive()) { - programGuide.scheduleHide(); - } - } -} diff --git a/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java new file mode 100644 index 00000000..6f287c70 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; + +import java.util.List; + +public class DvrForgetStorageErrorFragment extends DvrGuidedStepFragment { + private static final int ACTION_CANCEL = 1; + private static final int ACTION_FORGET_STORAGE = 2; + private String mInputId; + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mInputId = args.getString(DvrHalfSizedDialogFragment.KEY_INPUT_ID); + } + SoftPreconditions.checkArgument(!TextUtils.isEmpty(mInputId)); + super.onCreate(savedInstanceState); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_error_forget_storage_title); + String description = getResources().getString( + R.string.dvr_error_forget_storage_description); + return new Guidance(title, description, null, null); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_CANCEL) + .title(getResources().getString(R.string.dvr_action_error_cancel)) + .build()); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_FORGET_STORAGE) + .title(getResources().getString(R.string.dvr_action_error_forget_storage)) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() != ACTION_FORGET_STORAGE) { + dismissDialog(); + return; + } + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + DvrDataManager dataManager = TvApplication.getSingletons(getContext()).getDvrDataManager(); + List<SeriesRecording> seriesRecordings = dataManager.getSeriesRecordings(mInputId); + for(SeriesRecording series : seriesRecordings) { + dvrManager.removeSeriesRecording(series.getId()); + } + List<ScheduledRecording> scheduledRecordings = dataManager.getScheduledRecordings(mInputId); + dvrManager.removeScheduledRecording(ScheduledRecording.toArray(scheduledRecordings)); + dvrManager.removeRecordedProgramByMissingStorage(mInputId); + Activity activity = getActivity(); + if (activity instanceof DvrDetailsActivity) { + // Since we removed everything, just finish the activity. + activity.finish(); + } else { + dismissDialog(); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java b/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java new file mode 100644 index 00000000..6b0c22ff --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidedActionsStylist; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import com.android.tv.R; + +/** + * Stylist class used for DVR settings {@link GuidedStepFragment}. + */ +public class DvrGuidedActionsStylist extends GuidedActionsStylist { + private static boolean sInitialized; + private static float sWidthWeight; + private static int sItemHeight; + + private final boolean mIsButtonActions; + + public DvrGuidedActionsStylist(boolean isButtonActions) { + super(); + mIsButtonActions = isButtonActions; + if (mIsButtonActions) { + setAsButtonActions(); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container) { + initializeIfNeeded(container.getContext()); + View v = super.onCreateView(inflater, container); + if (mIsButtonActions) { + ((LinearLayout.LayoutParams) v.getLayoutParams()).weight = sWidthWeight; + } + return v; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + initializeIfNeeded(parent.getContext()); + ViewHolder viewHolder = super.onCreateViewHolder(parent); + viewHolder.itemView.getLayoutParams().height = sItemHeight; + return viewHolder; + } + + private void initializeIfNeeded(Context context) { + if (sInitialized) { + return; + } + sInitialized = true; + sItemHeight = context.getResources().getDimensionPixelSize( + R.dimen.dvr_settings_one_line_action_container_height); + TypedValue outValue = new TypedValue(); + context.getResources().getValue(R.dimen.dvr_settings_button_actions_list_width_weight, + outValue, true); + sWidthWeight = outValue.getFloat(); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java index 0854b91a..eaccd8ed 100644 --- a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java +++ b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java @@ -1,33 +1,39 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.tv.dvr.ui; +import android.app.DialogFragment; import android.content.Context; import android.os.Bundle; import android.support.v17.leanback.app.GuidedStepFragment; -import android.support.v17.leanback.widget.GuidanceStylist; import android.support.v17.leanback.widget.VerticalGridView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.android.tv.MainActivity; +import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.dialog.SafeDismissDialogFragment; import com.android.tv.dvr.DvrManager; -import com.android.tv.guide.ProgramManager.TableEntry; -import com.android.tv.R; public class DvrGuidedStepFragment extends GuidedStepFragment { - private final TableEntry mEntry; private DvrManager mDvrManager; - public DvrGuidedStepFragment(TableEntry entry) { - mEntry = entry; - } - - protected TableEntry getEntry() { - return mEntry; - } - protected DvrManager getDvrManager() { return mDvrManager; } @@ -42,32 +48,27 @@ public class DvrGuidedStepFragment extends GuidedStepFragment { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = super.onCreateView(inflater, container, savedInstanceState); - VerticalGridView gridView = getGuidedActionsStylist().getActionsGridView(); - gridView.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE); + VerticalGridView actionsList = getGuidedActionsStylist().getActionsGridView(); + actionsList.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE); + VerticalGridView buttonActionsList = getGuidedButtonActionsStylist().getActionsGridView(); + buttonActionsList.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE); return view; } @Override - public GuidanceStylist onCreateGuidanceStylist() { - // Workaround: b/28448653 - return new GuidanceStylist() { - @Override - public int onProvideLayoutId() { - return R.layout.halfsized_guidance; - } - }; - } - - @Override public int onProvideTheme() { return R.style.Theme_TV_Dvr_GuidedStep; } protected void dismissDialog() { - SafeDismissDialogFragment currentDialog = - ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog(); - if (currentDialog instanceof DvrDialogFragment) { - currentDialog.dismiss(); + if (getActivity() instanceof MainActivity) { + SafeDismissDialogFragment currentDialog = + ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog(); + if (currentDialog instanceof DvrHalfSizedDialogFragment) { + currentDialog.dismiss(); + } + } else if (getParentFragment() instanceof DialogFragment) { + ((DialogFragment) getParentFragment()).dismiss(); } } } diff --git a/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java new file mode 100644 index 00000000..50187a56 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.MainActivity; +import com.android.tv.R; +import com.android.tv.data.ParcelableList; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelWatchConflictFragment; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment; +import com.android.tv.guide.ProgramGuide; + +public class DvrHalfSizedDialogFragment extends HalfSizedDialogFragment { + /** + * Key for input ID. + * Type: String. + */ + public static final String KEY_INPUT_ID = "DvrHalfSizedDialogFragment.input_id"; + /** + * Key for the program. + * Type: {@link com.android.tv.data.Program}. + */ + public static final String KEY_PROGRAM = "DvrHalfSizedDialogFragment.program"; + /** + * Key for the programs. + * Type: {@link ParcelableList}. + */ + public static final String KEY_PROGRAMS = "DvrHalfSizedDialogFragment.programs"; + /** + * Key for the channel ID. + * Type: long. + */ + public static final String KEY_CHANNEL_ID = "DvrHalfSizedDialogFragment.channel_id"; + /** + * Key for the recording start time in millisecond. + * Type: long. + */ + public static final String KEY_START_TIME_MS = "DvrHalfSizedDialogFragment.start_time_ms"; + /** + * Key for the recording end time in millisecond. + * Type: long. + */ + public static final String KEY_END_TIME_MS = "DvrHalfSizedDialogFragment.end_time_ms"; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + Activity activity = getActivity(); + if (activity instanceof MainActivity) { + ProgramGuide programGuide = + ((MainActivity) activity).getOverlayManager().getProgramGuide(); + if (programGuide != null && programGuide.isActive()) { + programGuide.cancelHide(); + } + } + } + + @Override + public void onDetach() { + super.onDetach(); + Activity activity = getActivity(); + if (activity instanceof MainActivity) { + ProgramGuide programGuide = + ((MainActivity) activity).getOverlayManager().getProgramGuide(); + if (programGuide != null && programGuide.isActive()) { + programGuide.scheduleHide(); + } + } + } + + public abstract static class DvrGuidedStepDialogFragment extends DvrHalfSizedDialogFragment { + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + GuidedStepFragment fragment = onCreateGuidedStepFragment(); + fragment.setArguments(getArguments()); + GuidedStepFragment.add(getChildFragmentManager(), fragment, R.id.halfsized_dialog_host); + return view; + } + + protected abstract GuidedStepFragment onCreateGuidedStepFragment(); + } + + /** A dialog fragment for {@link DvrScheduleFragment}. */ + public static class DvrScheduleDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrScheduleFragment(); + } + } + + /** A dialog fragment for {@link DvrProgramConflictFragment}. */ + public static class DvrProgramConflictDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrProgramConflictFragment(); + } + } + + /** A dialog fragment for {@link DvrChannelWatchConflictFragment}. */ + public static class DvrChannelWatchConflictDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrChannelWatchConflictFragment(); + } + } + + /** A dialog fragment for {@link DvrChannelRecordDurationOptionFragment}. */ + public static class DvrChannelRecordDurationOptionDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrChannelRecordDurationOptionFragment(); + } + } + + /** A dialog fragment for {@link DvrInsufficientSpaceErrorFragment}. */ + public static class DvrInsufficientSpaceErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrInsufficientSpaceErrorFragment(); + } + } + + /** A dialog fragment for {@link DvrMissingStorageErrorFragment}. */ + public static class DvrMissingStorageErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrMissingStorageErrorFragment(); + } + } + + /** A dialog fragment for {@link DvrStopRecordingFragment}. */ + public static class DvrStopRecordingDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrStopRecordingFragment(); + } + } + + /** A dialog fragment for {@link DvrAlreadyScheduledFragment}. */ + public static class DvrAlreadyScheduledDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrAlreadyScheduledFragment(); + } + } + + /** A dialog fragment for {@link DvrAlreadyRecordedFragment}. */ + public static class DvrAlreadyRecordedDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected GuidedStepFragment onCreateGuidedStepFragment() { + return new DvrAlreadyRecordedFragment(); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java new file mode 100644 index 00000000..3b1dbfa0 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; + +import java.util.List; + +public class DvrInsufficientSpaceErrorFragment extends DvrGuidedStepFragment { + private static final int ACTION_DONE = 1; + private static final int ACTION_OPEN_DVR = 2; + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_error_insufficient_space_title); + String description = getResources() + .getString(R.string.dvr_error_insufficient_space_description); + return new Guidance(title, description, null, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_DONE) + .title(getResources().getString(R.string.dvr_action_error_done)) + .build()); + DvrDataManager dvrDataManager = TvApplication.getSingletons(getContext()) + .getDvrDataManager(); + if (!(dvrDataManager.getRecordedPrograms().isEmpty() + && dvrDataManager.getStartedRecordings().isEmpty() + && dvrDataManager.getNonStartedScheduledRecordings().isEmpty() + && dvrDataManager.getSeriesRecordings().isEmpty())) { + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_OPEN_DVR) + .title(getResources().getString(R.string.dvr_action_error_open_dvr)) + .build()); + } + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_OPEN_DVR) { + Intent intent = new Intent(getActivity(), DvrActivity.class); + getActivity().startActivity(intent); + } + dismissDialog(); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java b/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java new file mode 100644 index 00000000..2e2c2849 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.common.SoftPreconditions; + +import java.util.List; + +public class DvrMissingStorageErrorFragment extends DvrGuidedStepFragment { + private static final int ACTION_CANCEL = 1; + private static final int ACTION_FORGET_STORAGE = 2; + private String mInputId; + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mInputId = args.getString(DvrHalfSizedDialogFragment.KEY_INPUT_ID); + } + SoftPreconditions.checkArgument(!TextUtils.isEmpty(mInputId)); + super.onCreate(savedInstanceState); + } + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString(R.string.dvr_error_missing_storage_title); + String description = getResources().getString( + R.string.dvr_error_missing_storage_description); + return new Guidance(title, description, null, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_CANCEL) + .title(getResources().getString(R.string.dvr_action_error_cancel)) + .build()); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_FORGET_STORAGE) + .title(getResources().getString(R.string.dvr_action_error_forget_storage)) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_FORGET_STORAGE) { + DvrForgetStorageErrorFragment fragment = new DvrForgetStorageErrorFragment(); + Bundle args = new Bundle(); + args.putString(DvrHalfSizedDialogFragment.KEY_INPUT_ID, mInputId); + fragment.setArguments(args); + GuidedStepFragment.add(getFragmentManager(), fragment, R.id.halfsized_dialog_host); + return; + } + dismissDialog(); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java b/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java new file mode 100644 index 00000000..7be92f1e --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.DvrPlaybackActivity; +import com.android.tv.util.Utils; + +/** + * This class is used to generate Views and bind Objects for related recordings in DVR playback. + */ +public class DvrPlaybackCardPresenter extends RecordedProgramPresenter { + private static final String TAG = "DvrPlaybackCardPresenter"; + private static final boolean DEBUG = false; + + private int mSelectedBackgroundColor = -1; + private int mDefaultBackgroundColor = -1; + private final int mRelatedRecordingCardWidth; + private final int mRelatedRecordingCardHeight; + + DvrPlaybackCardPresenter(Context context) { + super(context); + mRelatedRecordingCardWidth = + context.getResources().getDimensionPixelSize(R.dimen.dvr_related_recordings_width); + mRelatedRecordingCardHeight = + context.getResources().getDimensionPixelSize(R.dimen.dvr_related_recordings_height); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + Resources res = parent.getResources(); + RecordingCardView view = new RecordingCardView( + getContext(), mRelatedRecordingCardWidth, mRelatedRecordingCardHeight); + return new ViewHolder(view); + } + + @Override + public void onClick(View v) { + long programId = (long) v.getTag(); + if (DEBUG) Log.d(TAG, "Play Related Recording:" + programId); + Intent intent = new Intent(getContext(), DvrPlaybackActivity.class); + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, programId); + getContext().startActivity(intent); + } + + @Override + protected String getDescription(RecordedProgram program) { + String description = program.getDescription(); + if (TextUtils.isEmpty(description)) { + description = + getContext().getResources().getString(R.string.dvr_msg_no_program_description); + } + return description; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java b/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java new file mode 100644 index 00000000..1a3ae43c --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.graphics.drawable.Drawable; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaController.TransportControls; +import android.media.session.PlaybackState; +import android.support.v17.leanback.app.PlaybackControlGlue; +import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.support.v17.leanback.widget.PlaybackControlsRowPresenter; +import android.support.v17.leanback.widget.RowPresenter; +import android.text.TextUtils; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; + +import com.android.tv.R; +import com.android.tv.util.TimeShiftUtils; + +/** + * A helper class to assist {@link DvrPlaybackOverlayFragment} to manage its controls row and + * send command to the media controller. It also helps to update playback states displayed in the + * fragment according to information the media session provides. + */ +public class DvrPlaybackControlHelper extends PlaybackControlGlue { + private static final String TAG = "DvrPlaybackControlHelper"; + private static final boolean DEBUG = false; + + /** + * Indicates the ID of the media under playback is unknown. + */ + public static int UNKNOWN_MEDIA_ID = -1; + + private int mPlaybackState = PlaybackState.STATE_NONE; + private int mPlaybackSpeedLevel; + private int mPlaybackSpeedId; + private boolean mReadyToControl; + + private final MediaController mMediaController; + private final MediaController.Callback mMediaControllerCallback = new MediaControllerCallback(); + private final TransportControls mTransportControls; + private final int mExtraPaddingTopForNoDescription; + + public DvrPlaybackControlHelper(Activity activity, DvrPlaybackOverlayFragment overlayFragment) { + super(activity, overlayFragment, new int[TimeShiftUtils.MAX_SPEED_LEVEL + 1]); + mMediaController = activity.getMediaController(); + mMediaController.registerCallback(mMediaControllerCallback); + mTransportControls = mMediaController.getTransportControls(); + mExtraPaddingTopForNoDescription = activity.getResources() + .getDimensionPixelOffset(R.dimen.dvr_playback_controls_extra_padding_top); + } + + @Override + public PlaybackControlsRowPresenter createControlsRowAndPresenter() { + PlaybackControlsRow controlsRow = new PlaybackControlsRow(this); + setControlsRow(controlsRow); + AbstractDetailsDescriptionPresenter detailsPresenter = + new AbstractDetailsDescriptionPresenter() { + @Override + protected void onBindDescription( + AbstractDetailsDescriptionPresenter.ViewHolder viewHolder, Object object) { + PlaybackControlGlue glue = (PlaybackControlGlue) object; + if (glue.hasValidMedia()) { + viewHolder.getTitle().setText(glue.getMediaTitle()); + viewHolder.getSubtitle().setText(glue.getMediaSubtitle()); + } else { + viewHolder.getTitle().setText(""); + viewHolder.getSubtitle().setText(""); + } + if (TextUtils.isEmpty(viewHolder.getSubtitle().getText())) { + viewHolder.view.setPadding(viewHolder.view.getPaddingLeft(), + mExtraPaddingTopForNoDescription, + viewHolder.view.getPaddingRight(), viewHolder.view.getPaddingBottom()); + } + } + }; + PlaybackControlsRowPresenter presenter = + new PlaybackControlsRowPresenter(detailsPresenter) { + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) { + super.onBindRowViewHolder(vh, item); + vh.setOnKeyListener(DvrPlaybackControlHelper.this); + } + + @Override + protected void onUnbindRowViewHolder(RowPresenter.ViewHolder vh) { + super.onUnbindRowViewHolder(vh); + vh.setOnKeyListener(null); + } + }; + presenter.setProgressColor(getContext().getResources() + .getColor(R.color.play_controls_progress_bar_watched)); + presenter.setBackgroundColor(getContext().getResources() + .getColor(R.color.play_controls_body_background_enabled)); + presenter.setOnActionClickedListener(new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (mReadyToControl) { + DvrPlaybackControlHelper.super.onActionClicked(action); + } + } + }); + return presenter; + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (mReadyToControl) { + return super.onKey(v, keyCode, event); + } + return false; + } + + @Override + public boolean hasValidMedia() { + PlaybackState playbackState = mMediaController.getPlaybackState(); + if (playbackState == null) { + return false; + } + return true; + } + + @Override + public boolean isMediaPlaying() { + PlaybackState playbackState = mMediaController.getPlaybackState(); + if (playbackState == null) { + return false; + } + int state = playbackState.getState(); + return state != PlaybackState.STATE_NONE && state != PlaybackState.STATE_CONNECTING + && state != PlaybackState.STATE_PAUSED; + } + + /** + * Returns the ID of the media under playback. + */ + public long getMediaId() { + MediaMetadata mediaMetadata = mMediaController.getMetadata(); + return mediaMetadata == null ? UNKNOWN_MEDIA_ID + : mediaMetadata.getLong(MediaMetadata.METADATA_KEY_MEDIA_ID); + } + + @Override + public CharSequence getMediaTitle() { + MediaMetadata mediaMetadata = mMediaController.getMetadata(); + return mediaMetadata == null ? "" + : mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE); + } + + @Override + public CharSequence getMediaSubtitle() { + MediaMetadata mediaMetadata = mMediaController.getMetadata(); + return mediaMetadata == null ? "" + : mediaMetadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE); + } + + @Override + public int getMediaDuration() { + MediaMetadata mediaMetadata = mMediaController.getMetadata(); + return mediaMetadata == null ? 0 + : (int) mediaMetadata.getLong(MediaMetadata.METADATA_KEY_DURATION); + } + + @Override + public Drawable getMediaArt() { + // Do not show the poster art on control row. + return null; + } + + @Override + public long getSupportedActions() { + return ACTION_PLAY_PAUSE | ACTION_FAST_FORWARD | ACTION_REWIND; + } + + @Override + public int getCurrentSpeedId() { + return mPlaybackSpeedId; + } + + @Override + public int getCurrentPosition() { + PlaybackState playbackState = mMediaController.getPlaybackState(); + if (playbackState == null) { + return 0; + } + return (int) playbackState.getPosition(); + } + + /** + * Unregister media controller's callback. + */ + public void unregisterCallback() { + mMediaController.unregisterCallback(mMediaControllerCallback); + } + + @Override + protected void startPlayback(int speedId) { + if (getCurrentSpeedId() == speedId) { + return; + } + if (speedId == PLAYBACK_SPEED_NORMAL) { + mTransportControls.play(); + } else if (speedId <= -PLAYBACK_SPEED_FAST_L0) { + mTransportControls.rewind(); + } else if (speedId >= PLAYBACK_SPEED_FAST_L0){ + mTransportControls.fastForward(); + } + } + + @Override + protected void pausePlayback() { + mTransportControls.pause(); + } + + @Override + protected void skipToNext() { + // Do nothing. + } + + @Override + protected void skipToPrevious() { + // Do nothing. + } + + @Override + protected void onRowChanged(PlaybackControlsRow row) { + // Do nothing. + } + + private void onStateChanged(int state, long positionMs, int speedLevel) { + if (DEBUG) Log.d(TAG, "onStateChanged"); + getControlsRow().setCurrentTime((int) positionMs); + if (state == mPlaybackState && mPlaybackSpeedLevel == speedLevel) { + // Only position is changed, no need to update controls row + return; + } + // NOTICE: The below two variables should only be used in this method. + // The only usage of them is to confirm if the state is changed or not. + mPlaybackState = state; + mPlaybackSpeedLevel = speedLevel; + switch (state) { + case PlaybackState.STATE_PLAYING: + mPlaybackSpeedId = PLAYBACK_SPEED_NORMAL; + setFadingEnabled(true); + mReadyToControl = true; + break; + case PlaybackState.STATE_PAUSED: + mPlaybackSpeedId = PLAYBACK_SPEED_PAUSED; + setFadingEnabled(true); + mReadyToControl = true; + break; + case PlaybackState.STATE_FAST_FORWARDING: + mPlaybackSpeedId = PLAYBACK_SPEED_FAST_L0 + speedLevel; + setFadingEnabled(false); + mReadyToControl = true; + break; + case PlaybackState.STATE_REWINDING: + mPlaybackSpeedId = -PLAYBACK_SPEED_FAST_L0 - speedLevel; + setFadingEnabled(false); + mReadyToControl = true; + break; + case PlaybackState.STATE_CONNECTING: + setFadingEnabled(false); + mReadyToControl = false; + break; + case PlaybackState.STATE_NONE: + mReadyToControl = false; + break; + default: + setFadingEnabled(true); + break; + } + onStateChanged(); + } + + private class MediaControllerCallback extends MediaController.Callback { + @Override + public void onPlaybackStateChanged(PlaybackState state) { + if (DEBUG) Log.d(TAG, "Playback state changed: " + state.getState()); + onStateChanged(state.getState(), state.getPosition(), (int) state.getPlaybackSpeed()); + } + + @Override + public void onMetadataChanged(MediaMetadata metadata) { + DvrPlaybackControlHelper.this.onMetadataChanged(); + ((DvrPlaybackOverlayFragment) getFragment()).onMediaControllerUpdated(); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java new file mode 100644 index 00000000..9184f4f7 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.hardware.display.DisplayManager; +import android.media.tv.TvContentRating; +import android.os.Bundle; +import android.media.session.PlaybackState; +import android.media.tv.TvInputManager; +import android.media.tv.TvView; +import android.support.v17.leanback.app.PlaybackOverlayFragment; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.support.v17.leanback.widget.PlaybackControlsRowPresenter; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dialog.PinDialogFragment; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrPlayer; +import com.android.tv.dvr.DvrPlaybackMediaSessionHelper; +import com.android.tv.parental.ContentRatingsManager; +import com.android.tv.util.Utils; + +public class DvrPlaybackOverlayFragment extends PlaybackOverlayFragment { + // TODO: Handles audio focus. Deals with block and ratings. + private static final String TAG = "DvrPlaybackOverlayFragment"; + private static final boolean DEBUG = false; + + private static final String MEDIA_SESSION_TAG = "com.android.tv.dvr.mediasession"; + private static final float DISPLAY_ASPECT_RATIO_EPSILON = 0.01f; + + // mProgram is only used to store program from intent. Don't use it elsewhere. + private RecordedProgram mProgram; + private DvrPlaybackMediaSessionHelper mMediaSessionHelper; + private DvrPlaybackControlHelper mPlaybackControlHelper; + private ArrayObjectAdapter mRowsAdapter; + private ArrayObjectAdapter mRelatedRecordingsRowAdapter; + private DvrDataManager mDvrDataManager; + private ContentRatingsManager mContentRatingsManager; + private TvView mTvView; + private View mBlockScreenView; + private ListRow mRelatedRecordingsRow; + private int mExtraPaddingNoRelatedRow; + private int mWindowWidth; + private int mWindowHeight; + private float mAppliedAspectRatio; + private float mWindowAspectRatio; + private boolean mPinChecked; + + @Override + public void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); + super.onCreate(savedInstanceState); + mExtraPaddingNoRelatedRow = getActivity().getResources() + .getDimensionPixelOffset(R.dimen.dvr_playback_fragment_extra_padding_top); + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); + mContentRatingsManager = TvApplication.getSingletons(getContext()) + .getTvInputManagerHelper().getContentRatingsManager(); + mProgram = getProgramFromIntent(getActivity().getIntent()); + if (mProgram == null) { + Toast.makeText(getActivity(), getString(R.string.dvr_program_not_found), + Toast.LENGTH_SHORT).show(); + getActivity().finish(); + return; + } + Point size = new Point(); + ((DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE)) + .getDisplay(Display.DEFAULT_DISPLAY).getSize(size); + mWindowWidth = size.x; + mWindowHeight = size.y; + mWindowAspectRatio = mAppliedAspectRatio = (float) mWindowWidth / mWindowHeight; + setBackgroundType(PlaybackOverlayFragment.BG_LIGHT); + setFadingEnabled(true); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mTvView = (TvView) getActivity().findViewById(R.id.dvr_tv_view); + mBlockScreenView = getActivity().findViewById(R.id.block_screen); + mMediaSessionHelper = new DvrPlaybackMediaSessionHelper( + getActivity(), MEDIA_SESSION_TAG, new DvrPlayer(mTvView)); + mPlaybackControlHelper = new DvrPlaybackControlHelper(getActivity(), this); + setUpRows(); + preparePlayback(getActivity().getIntent()); + DvrPlayer dvrPlayer = mMediaSessionHelper.getDvrPlayer(); + dvrPlayer.setAspectRatioChangedListener(new DvrPlayer.AspectRatioChangedListener() { + @Override + public void onAspectRatioChanged(float videoAspectRatio) { + updateAspectRatio(videoAspectRatio); + } + }); + mPinChecked = getActivity().getIntent() + .getBooleanExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, false); + dvrPlayer.setContentBlockedListener(new DvrPlayer.ContentBlockedListener() { + @Override + public void onContentBlocked(TvContentRating rating) { + if (mPinChecked) { + mTvView.unblockContent(rating); + return; + } + mBlockScreenView.setVisibility(View.VISIBLE); + getActivity().getMediaController().getTransportControls().pause(); + new PinDialogFragment(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_DVR, + new PinDialogFragment.ResultListener() { + @Override + public void done(boolean success) { + if (success) { + mPinChecked = true; + mTvView.unblockContent(rating); + mBlockScreenView.setVisibility(View.GONE); + getActivity().getMediaController() + .getTransportControls().play(); + } + } + }, mContentRatingsManager.getDisplayNameForRating(rating)) + .show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG); + } + }); + } + + @Override + public void onPause() { + if (DEBUG) Log.d(TAG, "onPause"); + super.onPause(); + if (mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_FAST_FORWARDING + || mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_REWINDING) { + getActivity().getMediaController().getTransportControls().pause(); + } + if (mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_NONE) { + getActivity().requestVisibleBehind(false); + } else { + getActivity().requestVisibleBehind(true); + } + } + + @Override + public void onDestroy() { + if (DEBUG) Log.d(TAG, "onDestroy"); + super.onDestroy(); + mPlaybackControlHelper.unregisterCallback(); + mMediaSessionHelper.release(); + } + + /** + * Passes the intent to the fragment. + */ + public void onNewIntent(Intent intent) { + mProgram = getProgramFromIntent(intent); + if (mProgram == null) { + Toast.makeText(getActivity(), getString(R.string.dvr_program_not_found), + Toast.LENGTH_SHORT).show(); + // Continue playing the original program + return; + } + preparePlayback(intent); + } + + /** + * Should be called when windows' size is changed in order to notify DVR player + * to update it's view width/height and position. + */ + public void onWindowSizeChanged(final int windowWidth, final int windowHeight) { + mWindowWidth = windowWidth; + mWindowHeight = windowHeight; + mWindowAspectRatio = (float) mWindowWidth / mWindowHeight; + updateAspectRatio(mAppliedAspectRatio); + } + + void onMediaControllerUpdated() { + mRowsAdapter.notifyArrayItemRangeChanged(0, 1); + } + + private void updateAspectRatio(float videoAspectRatio) { + if (Math.abs(mAppliedAspectRatio - videoAspectRatio) < DISPLAY_ASPECT_RATIO_EPSILON) { + // No need to change + return; + } + if (videoAspectRatio < mWindowAspectRatio) { + int newPadding = (mWindowWidth - Math.round(mWindowHeight * videoAspectRatio)) / 2; + ((ViewGroup) mTvView.getParent()).setPadding(newPadding, 0, newPadding, 0); + } else { + int newPadding = (mWindowHeight - Math.round(mWindowWidth / videoAspectRatio)) / 2; + ((ViewGroup) mTvView.getParent()).setPadding(0, newPadding, 0, newPadding); + } + mAppliedAspectRatio = videoAspectRatio; + } + + private void preparePlayback(Intent intent) { + mMediaSessionHelper.setupPlayback(mProgram, getSeekTimeFromIntent(intent)); + getActivity().getMediaController().getTransportControls().prepare(); + updateRelatedRecordingsRow(); + } + + private void updateRelatedRecordingsRow() { + boolean wasEmpty = (mRelatedRecordingsRowAdapter.size() == 0); + mRelatedRecordingsRowAdapter.clear(); + long programId = mProgram.getId(); + String seriesId = mProgram.getSeriesId(); + if (!TextUtils.isEmpty(seriesId)) { + if (DEBUG) Log.d(TAG, "Update related recordings with:" + seriesId); + for (RecordedProgram program : mDvrDataManager.getRecordedPrograms()) { + if (seriesId.equals(program.getSeriesId()) && programId != program.getId()) { + mRelatedRecordingsRowAdapter.add(program); + } + } + } + View view = getView(); + if (mRelatedRecordingsRowAdapter.size() == 0) { + mRowsAdapter.remove(mRelatedRecordingsRow); + view.setPadding(view.getPaddingLeft(), mExtraPaddingNoRelatedRow, + view.getPaddingRight(), view.getPaddingBottom()); + } else if (wasEmpty){ + mRowsAdapter.add(mRelatedRecordingsRow); + view.setPadding(view.getPaddingLeft(), 0, + view.getPaddingRight(), view.getPaddingBottom()); + } + } + + private void setUpRows() { + PlaybackControlsRowPresenter controlsRowPresenter = + mPlaybackControlHelper.createControlsRowAndPresenter(); + + ClassPresenterSelector selector = new ClassPresenterSelector(); + selector.addClassPresenter(PlaybackControlsRow.class, controlsRowPresenter); + selector.addClassPresenter(ListRow.class, new ListRowPresenter()); + + mRowsAdapter = new ArrayObjectAdapter(selector); + mRowsAdapter.add(mPlaybackControlHelper.getControlsRow()); + mRelatedRecordingsRow = getRelatedRecordingsRow(); + setAdapter(mRowsAdapter); + } + + private ListRow getRelatedRecordingsRow() { + mRelatedRecordingsRowAdapter = + new ArrayObjectAdapter(new DvrPlaybackCardPresenter(getActivity())); + HeaderItem header = new HeaderItem(0, + getActivity().getString(R.string.dvr_playback_related_recordings)); + return new ListRow(header, mRelatedRecordingsRowAdapter); + } + + private RecordedProgram getProgramFromIntent(Intent intent) { + long programId = intent.getLongExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, -1); + return mDvrDataManager.getRecordedProgram(programId); + } + + private long getSeekTimeFromIntent(Intent intent) { + return intent.getLongExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME, + TvInputManager.TIME_SHIFT_INVALID_TIME); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java b/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java deleted file mode 100644 index 92052b5b..00000000 --- a/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.android.tv.dvr.ui; - -import android.app.Activity; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import com.android.tv.MainActivity; -import com.android.tv.R; - -import android.support.v17.leanback.widget.GuidanceStylist.Guidance; -import android.support.v17.leanback.widget.GuidedAction; - -import com.android.tv.data.Channel; -import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.Program; -import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.guide.ProgramManager.TableEntry; - -import java.text.DateFormat; -import java.util.Date; -import java.util.List; - -public class DvrRecordConflictFragment extends DvrGuidedStepFragment { - private static final int DVR_EPG_RECORD = 1; - private static final int DVR_EPG_NOT_RECORD = 2; - - private List<ScheduledRecording> mConflicts; - - public DvrRecordConflictFragment(TableEntry entry) { - super(entry); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - mConflicts = getDvrManager().getScheduledRecordingsThatConflict(getEntry().program); - return super.onCreateView(inflater, container, savedInstanceState); - } - - @Override - public Guidance onCreateGuidance(Bundle savedInstanceState) { - final MainActivity tvActivity = (MainActivity) getActivity(); - final ChannelDataManager channelDataManager = tvActivity.getChannelDataManager(); - StringBuilder sb = new StringBuilder(); - for (ScheduledRecording r : mConflicts) { - Channel channel = channelDataManager.getChannel(r.getChannelId()); - if (channel == null) { - continue; - } - sb.append(channel.getDisplayName()) - .append(" : ") - .append(DateFormat.getDateTimeInstance().format(new Date(r.getStartTimeMs()))) - .append("\n"); - } - String title = getResources().getString(R.string.dvr_epg_conflict_dialog_title); - String description = sb.toString(); - return new Guidance(title, description, null, null); - } - - @Override - public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { - Activity activity = getActivity(); - actions.add(new GuidedAction.Builder(activity) - .id(DVR_EPG_RECORD) - .title(getResources().getString(R.string.dvr_epg_record)) - .build()); - actions.add(new GuidedAction.Builder(activity) - .id(DVR_EPG_NOT_RECORD) - .title(getResources().getString(R.string.dvr_epg_do_not_record)) - .build()); - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - Program program = getEntry().program; - if (action.getId() == DVR_EPG_RECORD) { - getDvrManager().addSchedule(program, mConflicts); - } - dismissDialog(); - } -} diff --git a/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java b/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java deleted file mode 100644 index d4d5cc41..00000000 --- a/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.android.tv.dvr.ui; - -import android.app.Activity; -import android.os.Bundle; - -import android.support.v17.leanback.widget.GuidanceStylist.Guidance; -import android.support.v17.leanback.widget.GuidedAction; - -import com.android.tv.R; -import com.android.tv.guide.ProgramManager.TableEntry; - -import java.util.List; - -public class DvrRecordDeleteFragment extends DvrGuidedStepFragment { - private static final int ACTION_DELETE_YES = 1; - private static final int ACTION_DELETE_NO = 2; - - public DvrRecordDeleteFragment(TableEntry entry) { - super(entry); - } - - @Override - public Guidance onCreateGuidance(Bundle savedInstanceState) { - String title = getResources().getString(R.string.epg_dvr_dialog_message_delete_schedule); - return new Guidance(title, null, null, null); - } - - @Override - public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { - Activity activity = getActivity(); - actions.add(new GuidedAction.Builder(activity) - .id(ACTION_DELETE_YES) - .title(getResources().getString(android.R.string.yes)) - .build()); - actions.add(new GuidedAction.Builder(activity) - .id(ACTION_DELETE_NO) - .title(getResources().getString(android.R.string.no)) - .build()); - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - if (action.getId() == ACTION_DELETE_YES) { - getDvrManager().removeScheduledRecording(getEntry().scheduledRecording); - } - dismissDialog(); - } -} diff --git a/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java deleted file mode 100644 index 77e78ccc..00000000 --- a/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.android.tv.dvr.ui; - -import android.app.Activity; -import android.app.FragmentManager; -import android.os.Bundle; - -import android.support.v17.leanback.app.GuidedStepFragment; -import android.support.v17.leanback.widget.GuidanceStylist.Guidance; -import android.support.v17.leanback.widget.GuidedAction; - -import com.android.tv.data.Program; -import com.android.tv.dialog.SafeDismissDialogFragment; -import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.guide.ProgramManager.TableEntry; -import com.android.tv.MainActivity; -import com.android.tv.R; - -import java.util.List; - -public class DvrRecordScheduleFragment extends DvrGuidedStepFragment { - private static final int ACTION_RECORD_YES = 1; - private static final int ACTION_RECORD_NO = 2; - - public DvrRecordScheduleFragment(TableEntry entry) { - super(entry); - } - - @Override - public Guidance onCreateGuidance(Bundle savedInstanceState) { - String title = getResources().getString(R.string.epg_dvr_dialog_message_schedule_recording); - return new Guidance(title, null, null, null); - } - - @Override - public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { - Activity activity = getActivity(); - actions.add(new GuidedAction.Builder(activity) - .id(ACTION_RECORD_YES) - .title(getResources().getString(android.R.string.yes)) - .build()); - actions.add(new GuidedAction.Builder(activity) - .id(ACTION_RECORD_NO) - .title(getResources().getString(android.R.string.no)) - .build()); - } - - @Override - public void onGuidedActionClicked(GuidedAction action) { - TableEntry entry = getEntry(); - Program program = entry.program; - final List<ScheduledRecording> conflicts = - getDvrManager().getScheduledRecordingsThatConflict(program); - if (action.getId() == ACTION_RECORD_YES) { - if (conflicts.isEmpty()) { - getDvrManager().addSchedule(program, conflicts); - dismissDialog(); - } else { - DvrRecordConflictFragment dvrConflict = new DvrRecordConflictFragment(entry); - SafeDismissDialogFragment currentDialog = - ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog(); - if (currentDialog instanceof DvrDialogFragment) { - FragmentManager fm = currentDialog.getChildFragmentManager(); - GuidedStepFragment.add(fm, dvrConflict, R.id.halfsized_dialog_host); - } - } - } else if (action.getId() == ACTION_RECORD_NO) { - dismissDialog(); - } - } -} diff --git a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java new file mode 100644 index 00000000..a907b198 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.annotation.TargetApi; +import android.app.ProgressDialog; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.format.DateUtils; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.SeriesRecordingScheduler.ProgramLoadCallback; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment; +import com.android.tv.util.Utils; + +import java.util.List; + +/** + * A fragment which asks the user the type of the recording. + * <p> + * The program should be episodic and the series recording should not had been created yet. + */ +@TargetApi(Build.VERSION_CODES.N) +public class DvrScheduleFragment extends DvrGuidedStepFragment { + private static final int ACTION_RECORD_EPISODE = 1; + private static final int ACTION_RECORD_SERIES = 2; + + private Program mProgram; + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); + } + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + SoftPreconditions.checkArgument(mProgram != null && mProgram.isEpisodic() + && dvrManager.getSeriesRecording(mProgram) == null); + super.onCreate(savedInstanceState); + } + + @Override + public int onProvideTheme() { + return R.style.Theme_TV_Dvr_GuidedStep_Twoline_Action; + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_schedule_dialog_title); + Drawable icon = getResources().getDrawable(R.drawable.ic_dvr, null); + return new Guidance(title, null, null, icon); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Context context = getContext(); + String description; + if (mProgram.getStartTimeUtcMillis() <= System.currentTimeMillis()) { + description = getString(R.string.dvr_action_record_episode_from_now_description, + DateUtils.formatDateTime(context, mProgram.getEndTimeUtcMillis(), + DateUtils.FORMAT_SHOW_TIME)); + } else { + description = Utils.getDurationString(context, mProgram.getStartTimeUtcMillis(), + mProgram.getEndTimeUtcMillis(), true); + } + actions.add(new GuidedAction.Builder(context) + .id(ACTION_RECORD_EPISODE) + .title(R.string.dvr_action_record_episode) + .description(description) + .build()); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_RECORD_SERIES) + .title(R.string.dvr_action_record_series) + .description(mProgram.getTitle()) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_RECORD_EPISODE) { + getDvrManager().addSchedule(mProgram); + List<ScheduledRecording> conflicts = getDvrManager().getConflictingSchedules(mProgram); + if (conflicts.isEmpty()) { + DvrUiHelper.showAddScheduleToast(getContext(), mProgram.getTitle(), + mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis()); + dismissDialog(); + } else { + GuidedStepFragment fragment = new DvrProgramConflictFragment(); + Bundle args = new Bundle(); + args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, mProgram); + fragment.setArguments(args); + GuidedStepFragment.add(getFragmentManager(), fragment, + R.id.halfsized_dialog_host); + } + } else if (action.getId() == ACTION_RECORD_SERIES) { + ProgressDialog dialog = ProgressDialog.show(getContext(), null, + getString(R.string.dvr_schedule_progress_message_reading_programs)); + getDvrManager().queryProgramsForSeries(mProgram, new ProgramLoadCallback() { + @Override + public void onProgramLoadFinished(@NonNull List<Program> programs) { + dialog.dismiss(); + // TODO: Create series recording in series settings fragment. + SeriesRecording seriesRecording = + getDvrManager().addSeriesRecording(mProgram, programs); + DvrUiHelper.startSeriesSettingsActivity(getContext(), seriesRecording.getId()); + dismissDialog(); + } + }); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java b/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java new file mode 100644 index 00000000..316cb381 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.annotation.IntDef; +import android.support.v17.leanback.app.DetailsFragment; + +import com.android.tv.R; +import com.android.tv.dvr.ui.list.DvrSchedulesFragment; +import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Activity to show the list of recording schedules. + */ +public class DvrSchedulesActivity extends Activity { + /** + * The key for the type of the schedules which will be listed in the list. The type of the value + * should be {@link ScheduleListType}. + */ + public static final String KEY_SCHEDULES_TYPE = "schedules_type"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_FULL_SCHEDULE, TYPE_SERIES_SCHEDULE}) + public @interface ScheduleListType {} + /** + * A type which means the activity will display the full scheduled recordings. + */ + public static final int TYPE_FULL_SCHEDULE = 0; + /** + * A type which means the activity will display a scheduled recording list of a series + * recording. + */ + public final static int TYPE_SERIES_SCHEDULE = 1; + + private Runnable mCancelAllClickedRunnable; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dvr_schedules); + if (savedInstanceState == null) { + int schedulesType = getIntent().getIntExtra(KEY_SCHEDULES_TYPE, TYPE_FULL_SCHEDULE); + DetailsFragment schedulesFragment = null; + if (schedulesType == TYPE_FULL_SCHEDULE) { + schedulesFragment = new DvrSchedulesFragment(); + schedulesFragment.setArguments(getIntent().getExtras()); + } else if (schedulesType == TYPE_SERIES_SCHEDULE) { + schedulesFragment = new DvrSeriesSchedulesFragment(); + schedulesFragment.setArguments(getIntent().getExtras()); + } + if (schedulesFragment != null) { + getFragmentManager().beginTransaction().add( + R.id.fragment_container, schedulesFragment).commit(); + } else { + finish(); + } + } + } + + /** + * Sets cancel all runnable which will implement operations after clicking cancel all dialog. + */ + public void setCancelAllClickedRunnable(Runnable cancelAllClickedRunnable) { + mCancelAllClickedRunnable = cancelAllClickedRunnable; + } + + /** + * Operations after clicking the cancel all. + */ + public void onCancelAllClicked() { + if (mCancelAllClickedRunnable != null) { + mCancelAllClickedRunnable.run(); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java new file mode 100644 index 00000000..ab695234 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.ui.SeriesDeletionFragment; +import com.android.tv.ui.sidepanel.SettingsFragment; + +/** + * Activity to show details view in DVR. + */ +public class DvrSeriesDeletionActivity extends Activity { + /** + * Name of series id added to the Intent. + */ + public static final String SERIES_RECORDING_ID = "series_recording_id"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dvr_series_settings); + // Check savedInstanceState to prevent that activity is being showed with animation. + if (savedInstanceState == null) { + SeriesDeletionFragment deletionFragment = new SeriesDeletionFragment(); + deletionFragment.setArguments(getIntent().getExtras()); + GuidedStepFragment.addAsRoot(this, deletionFragment, R.id.dvr_settings_view_frame); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java new file mode 100644 index 00000000..2af78081 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.ui.sidepanel.SettingsFragment; + +/** + * Activity to show details view in DVR. + */ +public class DvrSeriesSettingsActivity extends Activity { + /** + * Name of series id added to the Intent. + */ + public static final String SERIES_RECORDING_ID = "series_recording_id"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dvr_series_settings); + long seriesRecordingId = getIntent().getLongExtra(SERIES_RECORDING_ID, -1); + SoftPreconditions.checkArgument(seriesRecordingId != -1); + + if (savedInstanceState == null) { + Bundle args = new Bundle(); + args.putLong(SeriesSettingsFragment.SERIES_RECORDING_ID, seriesRecordingId); + SeriesSettingsFragment settingFragment = new SeriesSettingsFragment(); + settingFragment.setArguments(args); + GuidedStepFragment.addAsRoot(this, settingFragment, R.id.dvr_settings_view_frame); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java new file mode 100644 index 00000000..c0e21a18 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.ScheduledRecording; + +import java.util.List; + +/** + * A fragment which asks the user to make a recording schedule for the program. + * <p> + * If the program belongs to a series and the series recording is not created yet, we will show the + * option to record all the episodes of the series. + */ +@TargetApi(Build.VERSION_CODES.N) +public class DvrStopRecordingFragment extends DvrGuidedStepFragment { + private static final int ACTION_STOP = 1; + + private ScheduledRecording mSchedule; + private DvrDataManager mDvrDataManager; + private final ScheduledRecordingListener mScheduledRecordingListener = + new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + if (schedule.getId() == mSchedule.getId()) { + dismissDialog(); + return; + } + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + if (schedule.getId() == mSchedule.getId() + && schedule.getState() + != ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + dismissDialog(); + return; + } + } + } + }; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + Bundle args = getArguments(); + long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID); + mSchedule = getDvrManager().getCurrentRecording(channelId); + if (mSchedule == null) { + dismissDialog(); + return; + } + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener); + } + + @Override + public void onDetach() { + if (mDvrDataManager != null) { + mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); + } + super.onDetach(); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_stop_recording_dialog_title); + String description = getString(R.string.dvr_stop_recording_dialog_description); + Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null); + return new Guidance(title, description, null, image); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Context context = getContext(); + actions.add(new GuidedAction.Builder(context) + .id(ACTION_STOP) + .title(R.string.dvr_action_stop) + .build()); + actions.add(new GuidedAction.Builder(context) + .clickAction(GuidedAction.ACTION_ID_CANCEL) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_STOP) { + getDvrManager().stopRecording(mSchedule); + } + dismissDialog(); + } +} diff --git a/src/com/android/tv/dvr/ui/EmptyItemPresenter.java b/src/com/android/tv/dvr/ui/EmptyItemPresenter.java deleted file mode 100644 index c0305128..00000000 --- a/src/com/android/tv/dvr/ui/EmptyItemPresenter.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.tv.dvr.ui; - -import android.content.res.Resources; -import android.graphics.Color; -import android.support.v17.leanback.widget.Presenter; -import android.view.Gravity; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.android.tv.R; -import com.android.tv.TvApplication; -import com.android.tv.data.ChannelDataManager; -import com.android.tv.util.Utils; - -/** - * Shows the item "NONE". Used for rows with now items. - */ -public class EmptyItemPresenter extends Presenter { - - private final DvrBrowseFragment mMainFragment; - - public EmptyItemPresenter(DvrBrowseFragment mainFragment) { - mMainFragment = mainFragment; - } - - @Override - public ViewHolder onCreateViewHolder(ViewGroup parent) { - TextView view = new TextView(parent.getContext()); - Resources resources = view.getResources(); - view.setLayoutParams(new ViewGroup.LayoutParams( - resources.getDimensionPixelSize(R.dimen.dvr_card_layout_width), - resources.getDimensionPixelSize(R.dimen.dvr_card_layout_width))); - view.setFocusable(true); - view.setFocusableInTouchMode(true); - view.setBackgroundColor( - Utils.getColor(mMainFragment.getResources(), R.color.setup_background)); - view.setTextColor(Color.WHITE); - view.setGravity(Gravity.CENTER); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(ViewHolder viewHolder, Object recording) { - ((TextView) viewHolder.view).setText( - viewHolder.view.getContext().getString(R.string.dvr_msg_no_recording_on_the_row)); - } - - @Override - public void onUnbindViewHolder(ViewHolder viewHolder) { } -} diff --git a/src/com/android/tv/dvr/ui/EmptyHolder.java b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java index 45cd3a36..d4d4d8ab 100644 --- a/src/com/android/tv/dvr/ui/EmptyHolder.java +++ b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java @@ -17,11 +17,13 @@ package com.android.tv.dvr.ui; /** - * Special object meaning a row is empty; + * Special object for schedule preview; */ -final class EmptyHolder { - static final EmptyHolder EMPTY_HOLDER = new EmptyHolder(); +final class FullScheduleCardHolder { + /** + * Full schedule card holder. + */ + static final FullScheduleCardHolder FULL_SCHEDULE_CARD_HOLDER = new FullScheduleCardHolder(); - private EmptyHolder() { - } + private FullScheduleCardHolder() { } } diff --git a/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java b/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java new file mode 100644 index 00000000..7dd85f45 --- /dev/null +++ b/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.support.v17.leanback.widget.Presenter; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.util.Utils; + +import java.util.Collections; +import java.util.List; + +/** + * Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}. + */ +public class FullSchedulesCardPresenter extends Presenter { + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + Context context = parent.getContext(); + RecordingCardView view = new RecordingCardView(context); + return new ScheduledRecordingViewHolder(view); + } + + @Override + public void onBindViewHolder(ViewHolder baseHolder, Object o) { + final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; + final RecordingCardView cardView = (RecordingCardView) viewHolder.view; + final Context context = viewHolder.view.getContext(); + + cardView.setImage(context.getDrawable(R.drawable.dvr_full_schedule)); + cardView.setTitle(context.getString(R.string.dvr_full_schedule_card_view_title)); + List<ScheduledRecording> scheduledRecordings = TvApplication.getSingletons(context) + .getDvrDataManager().getAvailableScheduledRecordings(); + int fullDays = 0; + if (!scheduledRecordings.isEmpty()) { + fullDays = Utils.computeDateDifference(System.currentTimeMillis(), + Collections.max(scheduledRecordings, ScheduledRecording.START_TIME_COMPARATOR) + .getStartTimeMs()) + 1; + } + cardView.setContent(context.getResources().getQuantityString( + R.plurals.dvr_full_schedule_card_view_content, fullDays, fullDays), null); + + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + DvrUiHelper.startSchedulesActivity(context, null); + } + }; + baseHolder.view.setOnClickListener(clickListener); + } + + @Override + public void onUnbindViewHolder(ViewHolder baseHolder) { + ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; + final RecordingCardView cardView = (RecordingCardView) viewHolder.view; + cardView.reset(); + } + + private static final class ScheduledRecordingViewHolder extends ViewHolder { + ScheduledRecordingViewHolder(RecordingCardView view) { + super(view); + } + } +} diff --git a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java b/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java index dc89a8e0..fcf0925b 100644 --- a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java +++ b/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java @@ -1,21 +1,71 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.android.tv.dvr.ui; +import android.content.DialogInterface; import android.os.Bundle; +import android.os.Handler; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import com.android.tv.dialog.SafeDismissDialogFragment; import com.android.tv.R; +import com.android.tv.dialog.SafeDismissDialogFragment; + +import java.util.concurrent.TimeUnit; public class HalfSizedDialogFragment extends SafeDismissDialogFragment { public static final String DIALOG_TAG = HalfSizedDialogFragment.class.getSimpleName(); public static final String TRACKER_LABEL = "Half sized dialog"; + private static final long AUTO_DISMISS_TIME_THRESHOLD_MS = TimeUnit.SECONDS.toMillis(30); + + private Handler mHandler = new Handler(); + private Runnable mAutoDismisser = new Runnable() { + @Override + public void run() { + dismiss(); + } + }; + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - return inflater.inflate(R.layout.halfsized_dialog, null); + return inflater.inflate(R.layout.halfsized_dialog, container, false); + } + + @Override + public void onStart() { + super.onStart(); + getDialog().setOnKeyListener(new DialogInterface.OnKeyListener() { + public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent keyEvent) { + mHandler.removeCallbacks(mAutoDismisser); + mHandler.postDelayed(mAutoDismisser, AUTO_DISMISS_TIME_THRESHOLD_MS); + return false; + } + }); + mHandler.postDelayed(mAutoDismisser, AUTO_DISMISS_TIME_THRESHOLD_MS); + } + + @Override + public void onStop() { + super.onStop(); + mHandler.removeCallbacks(mAutoDismisser); } @Override @@ -27,4 +77,4 @@ public class HalfSizedDialogFragment extends SafeDismissDialogFragment { public String getTrackerLabel() { return TRACKER_LABEL; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java b/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java new file mode 100644 index 00000000..9f78985f --- /dev/null +++ b/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.FragmentManager; +import android.content.Context; +import android.graphics.Typeface; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v17.leanback.widget.GuidedActionsStylist; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.SeriesRecording; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fragment for DVR series recording settings. + */ +public class PrioritySettingsFragment extends GuidedStepFragment { + /** + * Name of series recording id starting the fragment. + * Type: Long + */ + public static final String COME_FROM_SERIES_RECORDING_ID = "series_recording_id"; + + private static final int ONE_TIME_RECORDING_ID = 0; + // button action's IDs are negative. + private static final long ACTION_ID_SAVE = -100L; + + private DvrDataManager mDvrDataManager; + private final List<SeriesRecording> mSeriesRecordings = new ArrayList<>(); + + private SeriesRecording mSelectedRecording; + private SeriesRecording mComeFromSeriesRecording; + private float mSelectedActionElevation; + private int mActionColor; + private int mSelectedActionColor; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mSeriesRecordings.clear(); + mSeriesRecordings.add(new SeriesRecording.Builder() + .setTitle(getString(R.string.dvr_priority_action_one_time_recording)) + .setPriority(Long.MAX_VALUE) + .setId(ONE_TIME_RECORDING_ID) + .build()); + long comeFromSeriesRecordingId = + getArguments().getLong(COME_FROM_SERIES_RECORDING_ID, -1); + for (SeriesRecording series : mDvrDataManager.getSeriesRecordings()) { + if (series.getState() == SeriesRecording.STATE_SERIES_NORMAL + || series.getId() == comeFromSeriesRecordingId) { + mSeriesRecordings.add(series); + } + } + mSeriesRecordings.sort(SeriesRecording.PRIORITY_COMPARATOR); + mComeFromSeriesRecording = mDvrDataManager.getSeriesRecording(comeFromSeriesRecordingId); + mSelectedActionElevation = getResources().getDimension(R.dimen.card_elevation_normal); + mActionColor = getResources().getColor(R.color.dvr_guided_step_action_text_color, null); + mSelectedActionColor = + getResources().getColor(R.color.dvr_guided_step_action_text_color_selected, null); + } + + @Override + public void onResume() { + super.onResume(); + setSelectedActionPosition(mComeFromSeriesRecording == null ? 1 + : mSeriesRecordings.indexOf(mComeFromSeriesRecording)); + } + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String breadcrumb = mComeFromSeriesRecording == null ? null + : mComeFromSeriesRecording.getTitle(); + return new Guidance(getString(R.string.dvr_priority_title), + getString(R.string.dvr_priority_description), breadcrumb, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + int position = 0; + for (SeriesRecording seriesRecording : mSeriesRecordings) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(position++) + .title(seriesRecording.getTitle()) + .build()); + } + } + + @Override + public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_SAVE) + .title(getString(R.string.dvr_priority_button_action_save)) + .build()); + actions.add(new GuidedAction.Builder(getActivity()) + .clickAction(GuidedAction.ACTION_ID_CANCEL) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_ID_SAVE) { + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + int size = mSeriesRecordings.size(); + for (int i = 1; i < size; ++i) { + long priority = DvrScheduleManager.suggestSeriesPriority(size - i); + SeriesRecording seriesRecording = mSeriesRecordings.get(i); + if (seriesRecording.getPriority() != priority) { + dvrManager.updateSeriesRecording(SeriesRecording.buildFrom(seriesRecording) + .setPriority(priority).build()); + } + } + FragmentManager fragmentManager = getFragmentManager(); + fragmentManager.popBackStack(); + } else if (actionId == GuidedAction.ACTION_ID_CANCEL) { + FragmentManager fragmentManager = getFragmentManager(); + fragmentManager.popBackStack(); + } else if (mSelectedRecording == null) { + mSelectedRecording = mSeriesRecordings.get((int) actionId); + for (int i = 0; i < mSeriesRecordings.size(); ++i) { + updateItem(i); + } + } else { + mSelectedRecording = null; + for (int i = 0; i < mSeriesRecordings.size(); ++i) { + updateItem(i); + } + } + } + + @Override + public void onGuidedActionFocused(GuidedAction action) { + super.onGuidedActionFocused(action); + if (mSelectedRecording == null) { + return; + } + if (action.getId() < 0) { + int selectedPosition = mSeriesRecordings.indexOf(mSelectedRecording); + mSelectedRecording = null; + for (int i = 0; i < mSeriesRecordings.size(); ++i) { + updateItem(i); + } + return; + } + int position = (int) action.getId(); + int previousPosition = mSeriesRecordings.indexOf(mSelectedRecording); + mSeriesRecordings.remove(mSelectedRecording); + mSeriesRecordings.add(position, mSelectedRecording); + updateItem(previousPosition); + updateItem(position); + notifyActionChanged(previousPosition); + notifyActionChanged(position); + } + + @Override + public GuidedActionsStylist onCreateButtonActionsStylist() { + return new DvrGuidedActionsStylist(true); + } + + @Override + public GuidedActionsStylist onCreateActionsStylist() { + return new DvrGuidedActionsStylist(false) { + @Override + public void onBindViewHolder(ViewHolder vh, GuidedAction action) { + super.onBindViewHolder(vh, action); + updateItem(vh.itemView, (int) action.getId()); + } + + @Override + public int onProvideItemLayoutId() { + return R.layout.priority_settings_action_item; + } + }; + } + + private void updateItem(int position) { + View itemView = getActionItemView(position); + if (itemView == null) { + return; + } + updateItem(itemView, position); + } + + private void updateItem(View itemView, int position) { + GuidedAction action = getActions().get(position); + action.setTitle(mSeriesRecordings.get(position).getTitle()); + boolean selected = mSelectedRecording != null + && mSeriesRecordings.indexOf(mSelectedRecording) == position; + TextView titleView = (TextView) itemView.findViewById(R.id.guidedactions_item_title); + ImageView imageView = (ImageView) itemView.findViewById(R.id.guidedactions_item_tail_image); + if (position == 0) { + // one-time recording + itemView.setBackgroundResource(R.drawable.setup_selector_background); + imageView.setVisibility(View.GONE); + itemView.setFocusable(false); + itemView.setElevation(0); + // strings.xml <i> tag doesn't work. + titleView.setTypeface(titleView.getTypeface(), Typeface.ITALIC); + } else if (mSelectedRecording == null) { + titleView.setTextColor(mActionColor); + itemView.setBackgroundResource(R.drawable.setup_selector_background); + imageView.setImageResource(R.drawable.ic_draggable_white); + imageView.setVisibility(View.VISIBLE); + itemView.setFocusable(true); + itemView.setElevation(0); + titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL); + } else if (selected) { + titleView.setTextColor(mSelectedActionColor); + itemView.setBackgroundResource(R.drawable.priority_settings_action_item_selected); + imageView.setImageResource(R.drawable.ic_dragging_grey); + imageView.setVisibility(View.VISIBLE); + itemView.setFocusable(true); + itemView.setElevation(mSelectedActionElevation); + titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL); + } else { + titleView.setTextColor(mActionColor); + itemView.setBackgroundResource(R.drawable.setup_selector_background); + imageView.setVisibility(View.INVISIBLE); + itemView.setFocusable(true); + itemView.setElevation(0); + titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL); + } + } +} diff --git a/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java b/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java new file mode 100644 index 00000000..9eb7e385 --- /dev/null +++ b/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.Intent; +import android.content.res.Resources; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputManager; +import android.net.Uri; +import android.os.Bundle; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.SparseArrayObjectAdapter; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.dialog.PinDialogFragment; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrPlaybackActivity; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.parental.ParentalControlSettings; +import com.android.tv.util.TvInputManagerHelper; +import com.android.tv.util.Utils; + +import java.io.File; + +/** + * {@link DetailsFragment} for recorded program in DVR. + */ +public class RecordedProgramDetailsFragment extends DvrDetailsFragment { + private static final int ACTION_RESUME_PLAYING = 1; + private static final int ACTION_PLAY_FROM_BEGINNING = 2; + private static final int ACTION_DELETE_RECORDING = 3; + + private DvrWatchedPositionManager mDvrWatchedPositionManager; + private TvInputManagerHelper mTvInputManagerHelper; + + private RecordedProgram mRecordedProgram; + private DetailsContent mDetailsContent; + private boolean mPaused; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity()) + .getDvrWatchedPositionManager(); + mTvInputManagerHelper = TvApplication.getSingletons(getActivity()) + .getTvInputManagerHelper(); + setDetailsOverviewRow(mDetailsContent); + } + + @Override + public void onResume() { + super.onResume(); + if (mPaused) { + updateActions(); + mPaused = false; + } + } + + @Override + public void onPause() { + super.onPause(); + mPaused = true; + } + + @Override + protected boolean onLoadRecordingDetails(Bundle args) { + long recordedProgramId = args.getLong(DvrDetailsActivity.RECORDING_ID); + mRecordedProgram = TvApplication.getSingletons(getActivity()).getDvrDataManager() + .getRecordedProgram(recordedProgramId); + if (mRecordedProgram == null) { + // notify super class to end activity before initializing anything + return false; + } + mDetailsContent = createDetailsContent(); + return true; + } + + private DetailsContent createDetailsContent() { + Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(mRecordedProgram.getChannelId()); + String description = TextUtils.isEmpty(mRecordedProgram.getLongDescription()) + ? mRecordedProgram.getDescription() : mRecordedProgram.getLongDescription(); + return new DetailsContent.Builder() + .setTitle(getTitleFromProgram(mRecordedProgram, channel)) + .setStartTimeUtcMillis(mRecordedProgram.getStartTimeUtcMillis()) + .setEndTimeUtcMillis(mRecordedProgram.getEndTimeUtcMillis()) + .setDescription(description) + .setImageUris(mRecordedProgram, channel) + .build(); + } + + @Override + protected SparseArrayObjectAdapter onCreateActionsAdapter() { + SparseArrayObjectAdapter adapter = + new SparseArrayObjectAdapter(new ActionPresenterSelector()); + Resources res = getResources(); + if (mDvrWatchedPositionManager.getWatchedPosition(mRecordedProgram.getId()) + != TvInputManager.TIME_SHIFT_INVALID_TIME) { + adapter.set(ACTION_RESUME_PLAYING, new Action(ACTION_RESUME_PLAYING, + res.getString(R.string.dvr_detail_resume_play), null, + res.getDrawable(R.drawable.lb_ic_play))); + adapter.set(ACTION_PLAY_FROM_BEGINNING, new Action(ACTION_PLAY_FROM_BEGINNING, + res.getString(R.string.dvr_detail_play_from_beginning), null, + res.getDrawable(R.drawable.lb_ic_replay))); + } else { + adapter.set(ACTION_PLAY_FROM_BEGINNING, new Action(ACTION_PLAY_FROM_BEGINNING, + res.getString(R.string.dvr_detail_watch), null, + res.getDrawable(R.drawable.lb_ic_play))); + } + adapter.set(ACTION_DELETE_RECORDING, new Action(ACTION_DELETE_RECORDING, + res.getString(R.string.dvr_detail_delete), null, + res.getDrawable(R.drawable.ic_delete_32dp))); + return adapter; + } + + @Override + protected OnActionClickedListener onCreateOnActionClickedListener() { + return new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_PLAY_FROM_BEGINNING) { + startPlayback(TvInputManager.TIME_SHIFT_INVALID_TIME); + } else if (action.getId() == ACTION_RESUME_PLAYING) { + startPlayback(mDvrWatchedPositionManager + .getWatchedPosition(mRecordedProgram.getId())); + } else if (action.getId() == ACTION_DELETE_RECORDING) { + DvrManager dvrManager = TvApplication + .getSingletons(getActivity()).getDvrManager(); + dvrManager.removeRecordedProgram(mRecordedProgram); + getActivity().finish(); + } + } + }; + } + + private boolean isDataUriAccessible(Uri dataUri) { + if (dataUri == null || dataUri.getPath() == null) { + return false; + } + try { + File recordedProgramPath = new File(dataUri.getPath()); + if (recordedProgramPath.exists()) { + return true; + } + } catch (SecurityException e) { + } + return false; + } + + private void startPlayback(long seekTimeMs) { + if (Utils.isInBundledPackageSet(mRecordedProgram.getPackageName()) + && !isDataUriAccessible(mRecordedProgram.getDataUri())) { + // Currently missing storage is handled only for TunerTvInput. + DvrUiHelper.showDvrMissingStorageErrorDialog(getActivity(), + mRecordedProgram.getInputId()); + return; + } + ParentalControlSettings parental = mTvInputManagerHelper.getParentalControlSettings(); + if (!parental.isParentalControlsEnabled()) { + launchPlaybackActivity(seekTimeMs, false); + return; + } + String ratingString = mRecordedProgram.getContentRating(); + if (TextUtils.isEmpty(ratingString)) { + launchPlaybackActivity(seekTimeMs, false); + return; + } + String[] ratingList = ratingString.split(","); + TvContentRating[] programRatings = new TvContentRating[ratingList.length]; + for (int i = 0; i < ratingList.length; i++) { + programRatings[i] = TvContentRating.unflattenFromString(ratingList[i]); + } + TvContentRating blockRatings = parental.getBlockedRating(programRatings); + if (blockRatings != null) { + new PinDialogFragment(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM, + new PinDialogFragment.ResultListener() { + @Override + public void done(boolean success) { + if (success) { + launchPlaybackActivity(seekTimeMs, true); + } + } + }).show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG); + } else { + launchPlaybackActivity(seekTimeMs, false); + } + } + + private void launchPlaybackActivity(long seekTimeMs, boolean pinChecked) { + Intent intent = new Intent(getActivity(), DvrPlaybackActivity.class); + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, mRecordedProgram.getId()); + if (seekTimeMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME, seekTimeMs); + } + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, pinChecked); + getActivity().startActivity(intent); + } +} diff --git a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java index 0b656bdc..704d3a3f 100644 --- a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java +++ b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java @@ -17,105 +17,180 @@ package com.android.tv.dvr.ui; import android.app.Activity; -import android.app.AlertDialog; import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.res.Resources; import android.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.net.Uri; import android.support.v17.leanback.widget.Presenter; +import android.text.Spannable; +import android.text.SpannableString; import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; import android.view.View; +import android.view.View.OnClickListener; import android.view.ViewGroup; -import java.util.List; - -import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.dvr.RecordedProgram; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; -import com.android.tv.dvr.DvrManager; -import com.android.tv.ui.DialogUtils; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener; import com.android.tv.util.Utils; +import java.util.concurrent.TimeUnit; + /** * Presents a {@link RecordedProgram} in the {@link DvrBrowseFragment}. */ -public class RecordedProgramPresenter extends Presenter { +public class RecordedProgramPresenter extends Presenter implements OnClickListener { private final ChannelDataManager mChannelDataManager; + private final DvrWatchedPositionManager mDvrWatchedPositionManager; + private final Context mContext; + private String mTodayString; + private String mYesterdayString; + private final int mProgressBarColor; + private final boolean mShowEpisodeTitle; - public RecordedProgramPresenter(Context context) { + private static final class RecordedProgramViewHolder extends ViewHolder + implements WatchedPositionChangedListener { + private RecordedProgram mProgram; + + RecordedProgramViewHolder(RecordingCardView view, int progressColor) { + super(view); + view.setProgressBarColor(progressColor); + } + + private void setProgram(RecordedProgram program) { + mProgram = program; + } + + private void setProgressBar(long watchedPositionMs) { + ((RecordingCardView) view).setProgressBar( + (watchedPositionMs == TvInputManager.TIME_SHIFT_INVALID_TIME) ? null + : Math.min(100, (int) (100.0f * watchedPositionMs + / mProgram.getDurationMillis()))); + } + + @Override + public void onWatchedPositionChanged(long programId, long positionMs) { + if (programId == mProgram.getId()) { + setProgressBar(positionMs); + } + } + } + + public RecordedProgramPresenter(Context context, boolean showEpisodeTitle) { + mContext = context; mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); + mTodayString = context.getString(R.string.dvr_date_today); + mYesterdayString = context.getString(R.string.dvr_date_yesterday); + mDvrWatchedPositionManager = + TvApplication.getSingletons(context).getDvrWatchedPositionManager(); + mProgressBarColor = context.getResources() + .getColor(R.color.play_controls_progress_bar_watched); + mShowEpisodeTitle = showEpisodeTitle; + } + + public RecordedProgramPresenter(Context context) { + this(context, false); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent) { - Context context = parent.getContext(); - RecordingCardView view = new RecordingCardView(context); - return new ViewHolder(view); + RecordingCardView view = new RecordingCardView(mContext); + return new RecordedProgramViewHolder(view, mProgressBarColor); } @Override public void onBindViewHolder(ViewHolder viewHolder, Object o) { - final RecordedProgram recording = (RecordedProgram) o; + final RecordedProgram program = (RecordedProgram) o; final RecordingCardView cardView = (RecordingCardView) viewHolder.view; - final Context context = viewHolder.view.getContext(); - final Resources resources = context.getResources(); - - Channel channel = mChannelDataManager.getChannel(recording.getChannelId()); - - if (!TextUtils.isEmpty(recording.getTitle())) { - cardView.setTitle(recording.getTitle()); + cardView.setTag(program); + Channel channel = mChannelDataManager.getChannel(program.getChannelId()); + SpannableString title; + if (mShowEpisodeTitle) { + title = new SpannableString(program.getEpisodeDisplayTitle(mContext)); } else { - cardView.setTitle(resources.getString(R.string.dvr_msg_program_title_unknown)); + String titleWithEpisodeNumber = program.getTitleWithEpisodeNumber(mContext); + title = titleWithEpisodeNumber == null ? null + : new SpannableString(titleWithEpisodeNumber); } - if (recording.getPosterArt() != null) { - cardView.setImageUri(recording.getPosterArt()); - } else if (recording.getThumbnail() != null) { - cardView.setImageUri(recording.getThumbnail()); - } else { - if (channel != null) { - cardView.setImageUri(TvContract.buildChannelLogoUri(channel.getId()).toString()); - } + if (TextUtils.isEmpty(title)) { + title = new SpannableString(channel != null ? channel.getDisplayName() + : mContext.getResources().getString(R.string.no_program_information)); + } else if (!mShowEpisodeTitle) { + String programTitle = program.getTitle(); + title.setSpan(new TextAppearanceSpan(mContext, + R.style.text_appearance_card_view_episode_number), programTitle == null ? 0 + : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + cardView.setTitle(title); + String imageUri = null; + boolean isChannelLogo = false; + if (program.getPosterArtUri() != null) { + imageUri = program.getPosterArtUri(); + } else if (program.getThumbnailUri() != null) { + imageUri = program.getThumbnailUri(); + } else if (channel != null) { + imageUri = TvContract.buildChannelLogoUri(channel.getId()).toString(); + isChannelLogo = true; + } + cardView.setImageUri(imageUri, isChannelLogo); + int durationMinutes = + Math.max(1, (int) TimeUnit.MILLISECONDS.toMinutes(program.getDurationMillis())); + String durationString = getContext().getResources().getQuantityString( + R.plurals.dvr_program_duration, durationMinutes, durationMinutes); + cardView.setContent(getDescription(program), durationString); + viewHolder.view.setOnClickListener(this); + if (viewHolder instanceof RecordedProgramViewHolder) { + RecordedProgramViewHolder cardViewHolder = (RecordedProgramViewHolder) viewHolder; + cardViewHolder.setProgram(program); + mDvrWatchedPositionManager.addListener(cardViewHolder, program.getId()); + cardViewHolder + .setProgressBar(mDvrWatchedPositionManager.getWatchedPosition(program.getId())); } - cardView.setContent(Utils.getDurationString(context, recording.getStartTimeUtcMillis(), - recording.getEndTimeUtcMillis(), true)); - //TODO: replace with a detail card - viewHolder.view.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - DialogUtils.showListDialog(v.getContext(), - new int[] { R.string.dvr_detail_play, R.string.dvr_detail_delete }, - new Runnable[] { - new Runnable() { - @Override - public void run() { - Intent intent = new Intent(context, MainActivity.class); - intent.putExtra(Utils.EXTRA_KEY_RECORDING_URI, - recording.getUri()); - context.startActivity(intent); - ((Activity) context).finish(); - } - }, - new Runnable() { - @Override - public void run() { - DvrManager dvrManager = TvApplication - .getSingletons(context).getDvrManager(); - dvrManager.removeRecordedProgram(recording); - } - }, - }); - } - }); - } @Override public void onUnbindViewHolder(ViewHolder viewHolder) { - final RecordingCardView cardView = (RecordingCardView) viewHolder.view; - cardView.reset(); + if (viewHolder instanceof RecordedProgramViewHolder) { + mDvrWatchedPositionManager.removeListener((RecordedProgramViewHolder) viewHolder, + ((RecordedProgramViewHolder) viewHolder).mProgram.getId()); + } + ((RecordingCardView) viewHolder.view).reset(); + } + + @Override + public void onClick(View v) { + if (v instanceof RecordingCardView) { + DvrUiHelper.startDetailsActivity((Activity) mContext, (RecordedProgram) v.getTag(), + ((RecordingCardView) v).getImageView()); + } + } + + /** + * Returns description would be used in its card view. + */ + protected String getDescription(RecordedProgram recording) { + int dateDifference = Utils.computeDateDifference(recording.getStartTimeUtcMillis(), + System.currentTimeMillis()); + if (dateDifference == 0) { + return mTodayString; + } else if (dateDifference == 1) { + return mYesterdayString; + } else { + return Utils.getDurationString(mContext, recording.getStartTimeUtcMillis(), + recording.getStartTimeUtcMillis(), false, true, false, 0); + } + } + + /** + * Returns context. + */ + protected Context getContext() { + return mContext; } } diff --git a/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java b/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java deleted file mode 100644 index eeb26041..00000000 --- a/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.tv.dvr.ui; - -import android.support.v17.leanback.widget.PresenterSelector; - -import com.android.tv.common.recording.RecordedProgram; -import com.android.tv.dvr.DvrDataManager; - -/** - * Adapter for {@link RecordedProgram}. - */ -final class RecordedProgramsAdapter extends SortedArrayAdapter<RecordedProgram> - implements DvrDataManager.RecordedProgramListener { - private final DvrDataManager mDataManager; - - RecordedProgramsAdapter(DvrDataManager dataManager, PresenterSelector presenterSelector) { - super(presenterSelector, RecordedProgram.START_TIME_THEN_ID_COMPARATOR); - mDataManager = dataManager; - } - - public void start() { - clear(); - addAll(mDataManager.getRecordedPrograms()); - mDataManager.addRecordedProgramListener(this); - } - - public void stop() { - mDataManager.removeRecordedProgramListener(this); - } - - @Override - long getId(RecordedProgram item) { - return item.getId(); - } - - @Override // DvrDataManager.RecordedProgramListener - public void onRecordedProgramAdded(RecordedProgram recordedProgram) { - add(recordedProgram); - } - - @Override // DvrDataManager.RecordedProgramListener - public void onRecordedProgramChanged(RecordedProgram recordedProgram) { - change(recordedProgram); - } - - @Override // DvrDataManager.RecordedProgramListener - public void onRecordedProgramRemoved(RecordedProgram recordedProgram) { - remove(recordedProgram); - } -} diff --git a/src/com/android/tv/dvr/ui/RecordingCardView.java b/src/com/android/tv/dvr/ui/RecordingCardView.java index def11248..fa4562fd 100644 --- a/src/com/android/tv/dvr/ui/RecordingCardView.java +++ b/src/com/android/tv/dvr/ui/RecordingCardView.java @@ -25,15 +25,19 @@ import android.support.annotation.Nullable; import android.support.v17.leanback.widget.BaseCardView; import android.text.TextUtils; import android.view.LayoutInflater; +import android.view.View; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.TextView; import com.android.tv.R; +import com.android.tv.dvr.RecordedProgram; import com.android.tv.util.ImageLoader; /** * A CardView for displaying info about a {@link com.android.tv.dvr.ScheduledRecording} or - * {@link com.android.tv.common.recording.RecordedProgram} + * {@link RecordedProgram} or + * {@link com.android.tv.dvr.SeriesRecording}. */ class RecordingCardView extends BaseCardView { private final ImageView mImageView; @@ -41,36 +45,81 @@ class RecordingCardView extends BaseCardView { private final int mImageHeight; private String mImageUri; private final TextView mTitleView; - private final TextView mContentView; + private final TextView mMajorContentView; + private final TextView mMinorContentView; + private final ProgressBar mProgressBar; private final Drawable mDefaultImage; RecordingCardView(Context context) { + this(context, + context.getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width), + context.getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_height)); + } + + RecordingCardView(Context context, int imageWidth, int imageHeight) { super(context); //TODO(dvr): move these to the layout XML. setCardType(BaseCardView.CARD_TYPE_INFO_UNDER_WITH_EXTRA); + setInfoVisibility(BaseCardView.CARD_REGION_VISIBLE_ALWAYS); setFocusable(true); setFocusableInTouchMode(true); - mDefaultImage = getResources().getDrawable(R.drawable.default_now_card, null); + mDefaultImage = getResources().getDrawable(R.drawable.dvr_default_poster, null); LayoutInflater inflater = LayoutInflater.from(getContext()); inflater.inflate(R.layout.dvr_recording_card_view, this); - mImageView = (ImageView) findViewById(R.id.image); - mImageWidth = getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width); - mImageHeight = getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width); + mImageWidth = imageWidth; + mImageHeight = imageHeight; + mProgressBar = (ProgressBar) findViewById(R.id.recording_progress); mTitleView = (TextView) findViewById(R.id.title); - mContentView = (TextView) findViewById(R.id.content); + mMajorContentView = (TextView) findViewById(R.id.content_major); + mMinorContentView = (TextView) findViewById(R.id.content_minor); } void setTitle(CharSequence title) { mTitleView.setText(title); } - void setContent(CharSequence content) { - mContentView.setText(content); + void setContent(CharSequence majorContent, CharSequence minorContent) { + if (!TextUtils.isEmpty(majorContent)) { + mMajorContentView.setText(majorContent); + mMajorContentView.setVisibility(View.VISIBLE); + } else { + mMajorContentView.setVisibility(View.GONE); + } + if (!TextUtils.isEmpty(minorContent)) { + mMinorContentView.setText(minorContent); + mMinorContentView.setVisibility(View.VISIBLE); + } else { + mMinorContentView.setVisibility(View.GONE); + } + } + + /** + * Sets progress bar. If progress is {@code null}, hides progress bar. + */ + void setProgressBar(Integer progress) { + if (progress == null) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setProgress(progress); + mProgressBar.setVisibility(View.VISIBLE); + } } - void setImageUri(String uri) { + /** + * Sets the color of progress bar. + */ + void setProgressBarColor(int color) { + mProgressBar.getProgressDrawable().setTint(color); + } + + void setImageUri(String uri, boolean isChannelLogo) { + if (isChannelLogo) { + mImageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + } else { + mImageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + } mImageUri = uri; if (TextUtils.isEmpty(uri)) { mImageView.setImageDrawable(mDefaultImage); @@ -80,14 +129,22 @@ class RecordingCardView extends BaseCardView { } } - public void setImageUri(Uri uri) { - if (uri != null) { - setImageUri(uri.toString()); - } else { - setImageUri(""); + /** + * Set image to card view. + */ + public void setImage(Drawable image) { + if (image != null) { + mImageView.setImageDrawable(image); } } + /** + * Returns image view. + */ + public ImageView getImageView() { + return mImageView; + } + private static class RecordingCardImageLoaderCallback extends ImageLoader.ImageLoaderCallback<RecordingCardView> { private final String mUri; @@ -108,8 +165,8 @@ class RecordingCardView extends BaseCardView { } public void reset() { - mTitleView.setText(""); - mContentView.setText(""); + mTitleView.setText(null); + setContent(null, null); mImageView.setImageDrawable(mDefaultImage); } } diff --git a/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java new file mode 100644 index 00000000..2271d932 --- /dev/null +++ b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.os.Bundle; +import android.support.v17.leanback.app.DetailsFragment; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.dvr.ScheduledRecording; + +/** + * {@link DetailsFragment} for recordings in DVR. + */ +abstract class RecordingDetailsFragment extends DvrDetailsFragment { + private ScheduledRecording mRecording; + private DetailsContent mDetailsContent; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mDetailsContent = createDetailsContent(); + setDetailsOverviewRow(mDetailsContent); + } + + @Override + protected boolean onLoadRecordingDetails(Bundle args) { + long scheduledRecordingId = args.getLong(DvrDetailsActivity.RECORDING_ID); + mRecording = TvApplication.getSingletons(getContext()).getDvrDataManager() + .getScheduledRecording(scheduledRecordingId); + if (mRecording == null) { + // notify super class to end activity before initializing anything + return false; + } + return true; + } + + /** + * Returns {@link ScheduledRecording} for the current fragment. + */ + public ScheduledRecording getRecording() { + return mRecording; + } + + private DetailsContent createDetailsContent() { + Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(mRecording.getChannelId()); + SpannableString title = mRecording.getProgramTitleWithEpisodeNumber(getContext()) == null ? + null : new SpannableString(mRecording + .getProgramTitleWithEpisodeNumber(getContext())); + if (TextUtils.isEmpty(title)) { + title = new SpannableString(channel != null ? channel.getDisplayName() + : getContext().getResources().getString( + R.string.no_program_information)); + } else { + String programTitle = mRecording.getProgramTitle(); + title.setSpan(new TextAppearanceSpan(getContext(), + R.style.text_appearance_card_view_episode_number), programTitle == null ? 0 + : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + String description = !TextUtils.isEmpty(mRecording.getProgramDescription()) ? + mRecording.getProgramDescription() : mRecording.getProgramLongDescription(); + if (TextUtils.isEmpty(description)) { + description = channel != null ? channel.getDescription() : null; + } + return new DetailsContent.Builder() + .setTitle(title) + .setStartTimeUtcMillis(mRecording.getStartTimeMs()) + .setEndTimeUtcMillis(mRecording.getEndTimeMs()) + .setDescription(description) + .setImageUris(mRecording.getProgramPosterArtUri(), + mRecording.getProgramThumbnailUri(), channel) + .build(); + } +} diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java new file mode 100644 index 00000000..5c1ba48c --- /dev/null +++ b/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.SparseArrayObjectAdapter; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; + +/** + * {@link RecordingDetailsFragment} for scheduled recording in DVR. + */ +public class ScheduledRecordingDetailsFragment extends RecordingDetailsFragment { + private static final int ACTION_VIEW_SCHEDULE = 1; + private static final int ACTION_CANCEL = 2; + + private DvrManager mDvrManager; + private Action mScheduleAction; + private boolean mHideViewSchedule; + + @Override + public void onCreate(Bundle savedInstance) { + mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + mHideViewSchedule = getArguments().getBoolean(DvrDetailsActivity.HIDE_VIEW_SCHEDULE); + super.onCreate(savedInstance); + } + + @Override + public void onResume() { + super.onResume(); + if (mScheduleAction != null) { + mScheduleAction.setIcon(getResources().getDrawable(getScheduleIconId())); + } + } + + @Override + protected SparseArrayObjectAdapter onCreateActionsAdapter() { + SparseArrayObjectAdapter adapter = + new SparseArrayObjectAdapter(new ActionPresenterSelector()); + Resources res = getResources(); + if (!mHideViewSchedule) { + mScheduleAction = new Action(ACTION_VIEW_SCHEDULE, + res.getString(R.string.dvr_detail_view_schedule), null, + res.getDrawable(getScheduleIconId())); + adapter.set(ACTION_VIEW_SCHEDULE, mScheduleAction); + } + adapter.set(ACTION_CANCEL, new Action(ACTION_CANCEL, + res.getString(R.string.epg_dvr_dialog_message_remove_recording_schedule), null, + res.getDrawable(R.drawable.ic_dvr_cancel_32dp))); + return adapter; + } + + @Override + protected OnActionClickedListener onCreateOnActionClickedListener() { + return new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + long actionId = action.getId(); + if (actionId == ACTION_VIEW_SCHEDULE) { + DvrUiHelper.startSchedulesActivity(getContext(), getRecording()); + } else if (actionId == ACTION_CANCEL) { + mDvrManager.removeScheduledRecording(getRecording()); + getActivity().finish(); + } + } + }; + } + + private int getScheduleIconId() { + if (mDvrManager.isConflicting(getRecording())) { + return R.drawable.ic_warning_white_36dp; + } else { + return R.drawable.ic_schedule_32dp; + } + } +} diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java index 533a4882..1f67bbe3 100644 --- a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java +++ b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java @@ -16,156 +16,165 @@ package com.android.tv.dvr.ui; -import android.app.AlertDialog; +import android.app.Activity; import android.content.Context; -import android.content.DialogInterface; import android.media.tv.TvContract; -import android.support.annotation.Nullable; +import android.os.Handler; import android.support.v17.leanback.widget.Presenter; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; import android.view.View; import android.view.ViewGroup; -import android.widget.Toast; import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.Program; -import com.android.tv.data.ProgramDataManager; -import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; import com.android.tv.dvr.ScheduledRecording; import com.android.tv.util.Utils; +import java.util.concurrent.TimeUnit; + /** * Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}. */ public class ScheduledRecordingPresenter extends Presenter { + private static final long PROGRESS_UPDATE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(5); + private final ChannelDataManager mChannelDataManager; + private final Context mContext; + private final int mProgressBarColor; private static final class ScheduledRecordingViewHolder extends ViewHolder { - private ProgramDataManager.QueryProgramTask mQueryProgramTask; + private final Handler mHandler = new Handler(); + private ScheduledRecording mScheduledRecording; + private final Runnable mProgressBarUpdater = new Runnable() { + @Override + public void run() { + updateProgressBar(); + mHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS); + } + }; - ScheduledRecordingViewHolder(RecordingCardView view) { + ScheduledRecordingViewHolder(RecordingCardView view, int progressBarColor) { super(view); + view.setProgressBarColor(progressBarColor); + } + + private void updateProgressBar() { + if (mScheduledRecording == null) { + return; + } + int recordingState = mScheduledRecording.getState(); + RecordingCardView cardView = (RecordingCardView) view; + if (recordingState == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + cardView.setProgressBar(Math.max(0, Math.min((int) (100 * + (System.currentTimeMillis() - mScheduledRecording.getStartTimeMs()) + / mScheduledRecording.getDuration()), 100))); + } else if (recordingState == ScheduledRecording.STATE_RECORDING_FINISHED) { + cardView.setProgressBar(100); + } else { + // Hides progress bar. + cardView.setProgressBar(null); + } + } + + private void startUpdateProgressBar() { + mHandler.post(mProgressBarUpdater); + } + + private void stopUpdateProgressBar() { + mHandler.removeCallbacks(mProgressBarUpdater); } } public ScheduledRecordingPresenter(Context context) { ApplicationSingletons singletons = TvApplication.getSingletons(context); mChannelDataManager = singletons.getChannelDataManager(); + mContext = context; + mProgressBarColor = context.getResources() + .getColor(R.color.play_controls_recording_icon_color_on_focus); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent) { Context context = parent.getContext(); RecordingCardView view = new RecordingCardView(context); - return new ScheduledRecordingViewHolder(view); + return new ScheduledRecordingViewHolder(view, mProgressBarColor); } @Override public void onBindViewHolder(ViewHolder baseHolder, Object o) { - ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; + final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; final ScheduledRecording recording = (ScheduledRecording) o; final RecordingCardView cardView = (RecordingCardView) viewHolder.view; final Context context = viewHolder.view.getContext(); - long programId = recording.getProgramId(); - if (programId == ScheduledRecording.ID_NOT_SET) { - setTitleAndImage(cardView, recording, null); + setTitleAndImage(cardView, recording); + int dateDifference = Utils.computeDateDifference(System.currentTimeMillis(), + recording.getStartTimeMs()); + if (dateDifference <= 0) { + cardView.setContent(mContext.getString(R.string.dvr_date_today_time, + Utils.getDurationString(context, recording.getStartTimeMs(), + recording.getEndTimeMs(), false, false, true, 0)), null); + } else if (dateDifference == 1) { + cardView.setContent(mContext.getString(R.string.dvr_date_tomorrow_time, + Utils.getDurationString(context, recording.getStartTimeMs(), + recording.getEndTimeMs(), false, false, true, 0)), null); } else { - viewHolder.mQueryProgramTask = new ProgramDataManager.QueryProgramTask( - context.getContentResolver(), programId) { - @Override - protected void onPostExecute(Program program) { - super.onPostExecute(program); - setTitleAndImage(cardView, recording, program); - } - }; - viewHolder.mQueryProgramTask.executeOnDbThread(); - + cardView.setContent(Utils.getDurationString(context, recording.getStartTimeMs(), + recording.getStartTimeMs(), false, true, false, 0), null); } - cardView.setContent(Utils.getDurationString(context, recording.getStartTimeMs(), - recording.getEndTimeMs(), true)); - //TODO: replace with a detail card + viewHolder.updateProgressBar(); View.OnClickListener clickListener = new View.OnClickListener() { @Override public void onClick(View v) { - switch (recording.getState()) { - case ScheduledRecording.STATE_RECORDING_NOT_STARTED: { - showScheduledRecordingDialog(v.getContext(), recording); - break; - } - case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: { - showCurrentlyRecordingDialog(v.getContext(), recording); - break; - } + if (v instanceof RecordingCardView) { + DvrUiHelper.startDetailsActivity((Activity) v.getContext(), recording, + ((RecordingCardView) v).getImageView(), false); } } }; baseHolder.view.setOnClickListener(clickListener); - } - - private void setTitleAndImage(RecordingCardView cardView, ScheduledRecording recording, - @Nullable Program program) { - if (program != null) { - cardView.setTitle(program.getTitle()); - cardView.setImageUri(program.getPosterArtUri()); - } else { - cardView.setTitle( - cardView.getResources().getString(R.string.dvr_msg_program_title_unknown)); - Channel channel = mChannelDataManager.getChannel(recording.getChannelId()); - if (channel != null) { - cardView.setImageUri(TvContract.buildChannelLogoUri(channel.getId()).toString()); - } - } + viewHolder.mScheduledRecording = recording; + viewHolder.startUpdateProgressBar(); } @Override public void onUnbindViewHolder(ViewHolder baseHolder) { ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; + viewHolder.stopUpdateProgressBar(); final RecordingCardView cardView = (RecordingCardView) viewHolder.view; - if (viewHolder.mQueryProgramTask != null) { - viewHolder.mQueryProgramTask.cancel(true); - viewHolder.mQueryProgramTask = null; - } + viewHolder.mScheduledRecording = null; cardView.reset(); } - private void showScheduledRecordingDialog(final Context context, - final ScheduledRecording recording) { - DialogInterface.OnClickListener removeScheduleListener - = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // TODO(DVR) handle success/failure. - DvrManager dvrManager = TvApplication.getSingletons(context) - .getDvrManager(); - dvrManager.removeScheduledRecording((ScheduledRecording) recording); - } - }; - new AlertDialog.Builder(context) - .setMessage(R.string.epg_dvr_dialog_message_remove_recording_schedule) - .setNegativeButton(android.R.string.no, null) - .setPositiveButton(android.R.string.yes, removeScheduleListener) - .show(); - } - - private void showCurrentlyRecordingDialog(final Context context, - final ScheduledRecording recording) { - DialogInterface.OnClickListener stopRecordingListener - = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - DvrManager dvrManager = TvApplication.getSingletons(context) - .getDvrManager(); - dvrManager.stopRecording((ScheduledRecording) recording); - } - }; - new AlertDialog.Builder(context) - .setMessage(R.string.epg_dvr_dialog_message_stop_recording) - .setNegativeButton(android.R.string.no, null) - .setPositiveButton(android.R.string.yes, stopRecordingListener) - .show(); + private void setTitleAndImage(RecordingCardView cardView, ScheduledRecording recording) { + Channel channel = mChannelDataManager.getChannel(recording.getChannelId()); + SpannableString title = recording.getProgramTitleWithEpisodeNumber(mContext) == null ? + null : new SpannableString(recording.getProgramTitleWithEpisodeNumber(mContext)); + if (TextUtils.isEmpty(title)) { + title = new SpannableString(channel != null ? channel.getDisplayName() + : mContext.getResources().getString(R.string.no_program_information)); + } else { + String programTitle = recording.getProgramTitle(); + title.setSpan(new TextAppearanceSpan(mContext, + R.style.text_appearance_card_view_episode_number), + programTitle == null ? 0 : programTitle.length(), title.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + String imageUri = recording.getProgramPosterArtUri(); + boolean isChannelLogo = false; + if (TextUtils.isEmpty(imageUri)) { + imageUri = channel != null ? + TvContract.buildChannelLogoUri(channel.getId()).toString() : null; + isChannelLogo = true; + } + cardView.setTitle(title); + cardView.setImageUri(imageUri, isChannelLogo); } } diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java b/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java deleted file mode 100644 index 65955276..00000000 --- a/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.tv.dvr.ui; - -import android.support.v17.leanback.widget.PresenterSelector; - -import com.android.tv.dvr.DvrDataManager; -import com.android.tv.dvr.ScheduledRecording; - -/** - * Adapter for {@link ScheduledRecording} filtered by - * {@link com.android.tv.dvr.ScheduledRecording.RecordingState}. - */ -final class ScheduledRecordingsAdapter extends SortedArrayAdapter<ScheduledRecording> - implements DvrDataManager.ScheduledRecordingListener { - private final int mState; - private final DvrDataManager mDataManager; - - ScheduledRecordingsAdapter(DvrDataManager dataManager, int state, - PresenterSelector presenterSelector) { - super(presenterSelector, ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR); - mDataManager = dataManager; - mState = state; - } - - public void start() { - clear(); - switch (mState) { - case ScheduledRecording.STATE_RECORDING_NOT_STARTED: - addAll(mDataManager.getNonStartedScheduledRecordings()); - break; - case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: - addAll(mDataManager.getStartedRecordings()); - break; - default: - throw new IllegalStateException("Unknown recording state " + mState); - - } - mDataManager.addScheduledRecordingListener(this); - } - - public void stop() { - mDataManager.removeScheduledRecordingListener(this); - } - - @Override - long getId(ScheduledRecording item) { - return item.getId(); - } - - @Override //DvrDataManager.ScheduledRecordingListener - public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) { - if (scheduledRecording.getState() == mState) { - add(scheduledRecording); - } - } - - @Override //DvrDataManager.ScheduledRecordingListener - public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) { - remove(scheduledRecording); - } - - @Override //DvrDataManager.ScheduledRecordingListener - public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) { - if (scheduledRecording.getState() == mState) { - change(scheduledRecording); - } else { - remove(scheduledRecording); - } - } -} diff --git a/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java b/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java new file mode 100644 index 00000000..c29d62ae --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.content.Context; +import android.media.tv.TvInputManager; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v17.leanback.widget.GuidedActionsStylist; +import android.text.TextUtils; +import android.view.ViewGroup.LayoutParams; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.ui.GuidedActionsStylistWithDivider; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Fragment for DVR series recording settings. + */ +public class SeriesDeletionFragment extends GuidedStepFragment { + private static final long WATCHED_TIME_UNIT_THRESHOLD = TimeUnit.MINUTES.toMillis(2); + + // Since recordings' IDs are used as its check actions' IDs, which are random positive numbers, + // negative values are used by other actions to prevent duplicated IDs. + private static final long ACTION_ID_SELECT_WATCHED = -110; + private static final long ACTION_ID_SELECT_ALL = -111; + private static final long ACTION_ID_DELETE = -112; + + private DvrDataManager mDvrDataManager; + private DvrWatchedPositionManager mDvrWatchedPositionManager; + private List<RecordedProgram> mRecordings; + private final Set<Long> mWatchedRecordings = new HashSet<>(); + private boolean mAllSelected; + private long mSeriesRecordingId; + private int mOneLineActionHeight; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mSeriesRecordingId = getArguments() + .getLong(DvrSeriesDeletionActivity.SERIES_RECORDING_ID, -1); + SoftPreconditions.checkArgument(mSeriesRecordingId != -1); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mDvrWatchedPositionManager = + TvApplication.getSingletons(context).getDvrWatchedPositionManager(); + mRecordings = mDvrDataManager.getRecordedPrograms(mSeriesRecordingId); + mOneLineActionHeight = getResources().getDimensionPixelSize( + R.dimen.dvr_settings_one_line_action_container_height); + if (mRecordings.isEmpty()) { + Toast.makeText(getActivity(), getString(R.string.dvr_series_deletion_no_recordings), + Toast.LENGTH_LONG).show(); + finishGuidedStepFragments(); + return; + } + Collections.sort(mRecordings, RecordedProgram.EPISODE_COMPARATOR); + } + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String breadcrumb = null; + SeriesRecording series = mDvrDataManager.getSeriesRecording(mSeriesRecordingId); + if (series != null) { + breadcrumb = series.getTitle(); + } + return new Guidance(getString(R.string.dvr_series_deletion_title), + getString(R.string.dvr_series_deletion_description), breadcrumb, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_SELECT_WATCHED) + .title(getString(R.string.dvr_series_select_watched)) + .build()); + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_SELECT_ALL) + .title(getString(R.string.dvr_series_select_all)) + .build()); + actions.add(GuidedActionsStylistWithDivider.createDividerAction(getContext())); + for (RecordedProgram recording : mRecordings) { + long watchedPositionMs = + mDvrWatchedPositionManager.getWatchedPosition(recording.getId()); + String title = recording.getEpisodeDisplayTitle(getContext()); + if (TextUtils.isEmpty(title)) { + title = TextUtils.isEmpty(recording.getTitle()) ? + getString(R.string.channel_banner_no_title) : recording.getTitle(); + } + String description; + if (watchedPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + description = getWatchedString(watchedPositionMs, recording.getDurationMillis()); + mWatchedRecordings.add(recording.getId()); + } else { + description = getString(R.string.dvr_series_never_watched); + } + actions.add(new GuidedAction.Builder(getActivity()) + .id(recording.getId()) + .title(title) + .description(description) + .checkSetId(GuidedAction.CHECKBOX_CHECK_SET_ID) + .build()); + } + } + + @Override + public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_DELETE) + .title(getString(R.string.dvr_detail_delete)) + .build()); + actions.add(new GuidedAction.Builder(getActivity()) + .clickAction(GuidedAction.ACTION_ID_CANCEL) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_ID_DELETE) { + int deletionCount = 0; + DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); + for (GuidedAction guidedAction : getActions()) { + if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID + && guidedAction.isChecked()) { + dvrManager.removeRecordedProgram(guidedAction.getId()); + deletionCount++; + } + } + Toast.makeText(getContext(), getResources().getQuantityString( + R.plurals.dvr_msg_episodes_deleted, deletionCount, deletionCount, + mRecordings.size()), Toast.LENGTH_LONG).show(); + finishGuidedStepFragments(); + } else if (actionId == GuidedAction.ACTION_ID_CANCEL) { + finishGuidedStepFragments(); + } else if (actionId == ACTION_ID_SELECT_WATCHED) { + for (GuidedAction guidedAction : getActions()) { + if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) { + long recordingId = guidedAction.getId(); + if (mWatchedRecordings.contains(recordingId)) { + guidedAction.setChecked(true); + } else { + guidedAction.setChecked(false); + } + notifyActionChanged(findActionPositionById(recordingId)); + } + } + mAllSelected = updateSelectAllState(); + } else if (actionId == ACTION_ID_SELECT_ALL) { + mAllSelected = !mAllSelected; + for (GuidedAction guidedAction : getActions()) { + if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) { + guidedAction.setChecked(mAllSelected); + notifyActionChanged(findActionPositionById(guidedAction.getId())); + } + } + updateSelectAllState(action, mAllSelected); + } else { + mAllSelected = updateSelectAllState(); + } + } + + @Override + public GuidedActionsStylist onCreateButtonActionsStylist() { + return new DvrGuidedActionsStylist(true); + } + + @Override + public GuidedActionsStylist onCreateActionsStylist() { + return new GuidedActionsStylistWithDivider() { + @Override + public void onBindViewHolder(ViewHolder vh, GuidedAction action) { + super.onBindViewHolder(vh, action); + if (action.getId() == ACTION_DIVIDER) { + return; + } + LayoutParams lp = vh.itemView.getLayoutParams(); + if (action.getCheckSetId() != GuidedAction.CHECKBOX_CHECK_SET_ID) { + lp.height = mOneLineActionHeight; + } else { + vh.itemView.setLayoutParams( + new LayoutParams(lp.width, LayoutParams.WRAP_CONTENT)); + } + } + }; + } + + private String getWatchedString(long watchedPositionMs, long durationMs) { + if (durationMs > WATCHED_TIME_UNIT_THRESHOLD) { + return getResources().getString(R.string.dvr_series_watched_info_minutes, + Math.max(1, TimeUnit.MILLISECONDS.toMinutes(watchedPositionMs)), + TimeUnit.MILLISECONDS.toMinutes(durationMs)); + } else { + return getResources().getString(R.string.dvr_series_watched_info_seconds, + Math.max(1, TimeUnit.MILLISECONDS.toSeconds(watchedPositionMs)), + TimeUnit.MILLISECONDS.toSeconds(durationMs)); + } + } + + private boolean updateSelectAllState() { + for (GuidedAction guidedAction : getActions()) { + if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) { + if (!guidedAction.isChecked()) { + if (mAllSelected) { + updateSelectAllState(findActionById(ACTION_ID_SELECT_ALL), false); + } + return false; + } + } + } + if (!mAllSelected) { + updateSelectAllState(findActionById(ACTION_ID_SELECT_ALL), true); + } + return true; + } + + private void updateSelectAllState(GuidedAction selectAll, boolean select) { + selectAll.setTitle(select ? getString(R.string.dvr_series_deselect_all) + : getString(R.string.dvr_series_select_all)); + notifyActionChanged(findActionPositionById(ACTION_ID_SELECT_ALL)); + } +} diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java new file mode 100644 index 00000000..0156e9d9 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui; + +import android.content.res.Resources; +import android.os.Bundle; +import android.support.v17.leanback.app.DetailsFragment; +import android.support.v17.leanback.widget.Action; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.support.v17.leanback.widget.DetailsOverviewRow; +import android.support.v17.leanback.widget.DetailsOverviewRowPresenter; +import android.support.v17.leanback.widget.HeaderItem; +import android.support.v17.leanback.widget.ListRow; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.support.v17.leanback.widget.OnActionClickedListener; +import android.support.v17.leanback.widget.PresenterSelector; +import android.support.v17.leanback.widget.SparseArrayObjectAdapter; +import android.text.TextUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.BaseProgram; +import com.android.tv.data.Channel; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.SeriesRecording; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * {@link DetailsFragment} for series recording in DVR. + */ +public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implements + DvrDataManager.SeriesRecordingListener, DvrDataManager.RecordedProgramListener { + private static final int ACTION_SERIES_SCHEDULES = 1; + private static final int ACTION_DELETE = 2; + + private DvrDataManager mDvrDataManager; + + private SeriesRecording mSeries; + // NOTICE: mRecordedPrograms should only be used in creating details fragments. + // After fragments are created, it should be cleared to save resources. + private List<RecordedProgram> mRecordedPrograms; + private DetailsContent mDetailsContent; + private int mSeasonRowCount; + private SparseArrayObjectAdapter mActionsAdapter; + private Action mDeleteAction; + + @Override + public void onCreate(Bundle savedInstanceState) { + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); + super.onCreate(savedInstanceState); + setDetailsOverviewRow(mDetailsContent); + setupRecordedProgramsRow(); + mDvrDataManager.addSeriesRecordingListener(this); + mDvrDataManager.addRecordedProgramListener(this); + mRecordedPrograms = null; + } + + @Override + protected boolean onLoadRecordingDetails(Bundle args) { + long recordId = args.getLong(DvrDetailsActivity.RECORDING_ID); + mSeries = TvApplication.getSingletons(getActivity()).getDvrDataManager() + .getSeriesRecording(recordId); + if (mSeries == null) { + return false; + } + mRecordedPrograms = mDvrDataManager.getRecordedPrograms(mSeries.getId()); + Collections.sort(mRecordedPrograms, RecordedProgram.SEASON_REVERSED_EPISODE_COMPARATOR); + mDetailsContent = createDetailsContent(); + return true; + } + + @Override + protected PresenterSelector onCreatePresenterSelector( + DetailsOverviewRowPresenter rowPresenter) { + ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); + presenterSelector.addClassPresenter(DetailsOverviewRow.class, rowPresenter); + presenterSelector.addClassPresenter(ListRow.class, new ListRowPresenter()); + return presenterSelector; + } + + private DetailsContent createDetailsContent() { + Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager() + .getChannel(mSeries.getChannelId()); + String description = TextUtils.isEmpty(mSeries.getLongDescription()) + ? mSeries.getDescription() : mSeries.getLongDescription(); + return new DetailsContent.Builder() + .setTitle(mSeries.getTitle()) + .setDescription(description) + .setImageUris(mSeries.getPosterUri(), mSeries.getPhotoUri(), channel) + .build(); + } + + @Override + protected SparseArrayObjectAdapter onCreateActionsAdapter() { + mActionsAdapter = new SparseArrayObjectAdapter(new ActionPresenterSelector()); + Resources res = getResources(); + mActionsAdapter.set(ACTION_SERIES_SCHEDULES, new Action(ACTION_SERIES_SCHEDULES, + getString(R.string.dvr_detail_view_schedule), null, + res.getDrawable(R.drawable.ic_schedule_32dp, null))); + mDeleteAction = new Action(ACTION_DELETE, + getString(R.string.dvr_detail_series_delete), null, + res.getDrawable(R.drawable.ic_delete_32dp, null)); + if (!mRecordedPrograms.isEmpty()) { + mActionsAdapter.set(ACTION_DELETE, mDeleteAction); + } + return mActionsAdapter; + } + + private void setupRecordedProgramsRow() { + for (RecordedProgram program : mRecordedPrograms) { + addProgram(program); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mDvrDataManager.removeSeriesRecordingListener(this); + mDvrDataManager.removeRecordedProgramListener(this); + if (mSeries.getState() == SeriesRecording.STATE_SERIES_CANCELED + && mDvrDataManager.getRecordedPrograms(mSeries.getId()).isEmpty()) { + TvApplication.getSingletons(getActivity()).getDvrManager() + .removeSeriesRecording(mSeries.getId()); + } + } + + @Override + protected OnActionClickedListener onCreateOnActionClickedListener() { + return new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_SERIES_SCHEDULES) { + DvrUiHelper.startSchedulesActivityForSeries(getContext(), mSeries); + } else if (action.getId() == ACTION_DELETE) { + DvrUiHelper.startSeriesDeletionActivity(getContext(), mSeries.getId()); + } + } + }; + } + + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + for (SeriesRecording series : seriesRecordings) { + if (mSeries.getId() == series.getId()) { + mSeries = series; + // TODO: change action label. + } + } + } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { } + + @Override + public void onRecordedProgramAdded(RecordedProgram recordedProgram) { + if (TextUtils.equals(recordedProgram.getSeriesId(), mSeries.getSeriesId())) { + addProgram(recordedProgram); + if (mActionsAdapter.lookup(ACTION_DELETE) == null) { + mActionsAdapter.set(ACTION_DELETE, mDeleteAction); + } + } + } + + @Override + public void onRecordedProgramChanged(RecordedProgram recordedProgram) { + // Do nothing + } + + @Override + public void onRecordedProgramRemoved(RecordedProgram recordedProgram) { + if (TextUtils.equals(recordedProgram.getSeriesId(), mSeries.getSeriesId())) { + ListRow row = getSeasonRow(recordedProgram.getSeasonNumber(), false); + if (row != null) { + SeasonRowAdapter adapter = (SeasonRowAdapter) row.getAdapter(); + adapter.remove(recordedProgram); + if (adapter.isEmpty()) { + getRowsAdapter().remove(row); + if (getRowsAdapter().size() == 1) { + // No season rows left. Only DetailsOverviewRow + mActionsAdapter.clear(ACTION_DELETE); + } + } + } + } + } + + private void addProgram(RecordedProgram program) { + String programSeasonNumber = + TextUtils.isEmpty(program.getSeasonNumber()) ? "" : program.getSeasonNumber(); + getOrCreateSeasonRowAdapter(programSeasonNumber).add(program); + } + + private SeasonRowAdapter getOrCreateSeasonRowAdapter(String seasonNumber) { + ListRow row = getSeasonRow(seasonNumber, true); + return (SeasonRowAdapter) row.getAdapter(); + } + + private ListRow getSeasonRow(String seasonNumber, boolean createNewRow) { + seasonNumber = TextUtils.isEmpty(seasonNumber) ? "" : seasonNumber; + ArrayObjectAdapter rowsAdaptor = getRowsAdapter(); + for (int i = rowsAdaptor.size() - 1; i >= 0; i--) { + Object row = rowsAdaptor.get(i); + if (row instanceof ListRow) { + int compareResult = RecordedProgram.numberCompare(seasonNumber, + ((SeasonRowAdapter) ((ListRow) row).getAdapter()).mSeasonNumber); + if (compareResult == 0) { + return (ListRow) row; + } else if (compareResult < 0) { + return createNewRow ? createNewSeasonRow(seasonNumber, i + 1) : null; + } + } + } + return createNewRow ? createNewSeasonRow(seasonNumber, rowsAdaptor.size()) : null; + } + + private ListRow createNewSeasonRow(String seasonNumber, int position) { + String seasonTitle = seasonNumber.isEmpty() ? mSeries.getTitle() + : getString(R.string.dvr_detail_series_season_title, seasonNumber); + HeaderItem header = new HeaderItem(mSeasonRowCount++, seasonTitle); + ClassPresenterSelector selector = new ClassPresenterSelector(); + selector.addClassPresenter(RecordedProgram.class, + new RecordedProgramPresenter(getContext(), true)); + ListRow row = new ListRow(header, new SeasonRowAdapter(selector, + new Comparator<RecordedProgram>() { + @Override + public int compare(RecordedProgram lhs, RecordedProgram rhs) { + return BaseProgram.EPISODE_COMPARATOR.compare(lhs, rhs); + } + }, seasonNumber)); + getRowsAdapter().add(position, row); + return row; + } + + private class SeasonRowAdapter extends SortedArrayAdapter<RecordedProgram> { + private String mSeasonNumber; + + SeasonRowAdapter(PresenterSelector selector, Comparator<RecordedProgram> comparator, + String seasonNumber) { + super(selector, comparator); + mSeasonNumber = seasonNumber; + } + + @Override + public long getId(RecordedProgram program) { + return program.getId(); + } + } +} diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java b/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java new file mode 100644 index 00000000..d2f26dd1 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.os.Bundle; +import android.support.v17.leanback.widget.Presenter; +import android.support.v4.app.ActivityOptionsCompat; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; + +import java.util.List; + +/** + * Presents a {@link SeriesRecording} in the {@link DvrBrowseFragment}. + */ +public class SeriesRecordingPresenter extends Presenter { + private final ChannelDataManager mChannelDataManager; + private final DvrDataManager mDvrDataManager; + private final DvrWatchedPositionManager mWatchedPositionManager; + + private static final class SeriesRecordingViewHolder extends ViewHolder implements + WatchedPositionChangedListener, ScheduledRecordingListener, RecordedProgramListener { + private SeriesRecording mSeriesRecording; + private RecordingCardView mCardView; + private DvrDataManager mDvrDataManager; + private DvrWatchedPositionManager mWatchedPositionManager; + + SeriesRecordingViewHolder(RecordingCardView view, DvrDataManager dvrDataManager, + DvrWatchedPositionManager watchedPositionManager) { + super(view); + mCardView = view; + mDvrDataManager = dvrDataManager; + mWatchedPositionManager = watchedPositionManager; + } + + @Override + public void onWatchedPositionChanged(long recordedProgramId, long positionMs) { + if (positionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + mWatchedPositionManager.removeListener(this, recordedProgramId); + updateCardViewContent(); + } + } + + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording scheduledRecording : scheduledRecordings) { + if (scheduledRecording.getSeriesRecordingId() == mSeriesRecording.getId()) { + updateCardViewContent(); + return; + } + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording scheduledRecording : scheduledRecordings) { + if (scheduledRecording.getSeriesRecordingId() == mSeriesRecording.getId()) { + updateCardViewContent(); + return; + } + } + } + + @Override + public void onRecordedProgramAdded(RecordedProgram recordedProgram) { + if (TextUtils.equals(recordedProgram.getTitle(), mSeriesRecording.getTitle())) { + mDvrDataManager.removeScheduledRecordingListener(this); + mWatchedPositionManager.addListener(this, recordedProgram.getId()); + updateCardViewContent(); + } + } + + @Override + public void onRecordedProgramRemoved(RecordedProgram recordedProgram) { + if (TextUtils.equals(recordedProgram.getTitle(), mSeriesRecording.getTitle())) { + if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId()) + == TvInputManager.TIME_SHIFT_INVALID_TIME) { + mWatchedPositionManager.removeListener(this, recordedProgram.getId()); + } + updateCardViewContent(); + } + } + + @Override + public void onRecordedProgramChanged(RecordedProgram recordedProgram) { + // Do nothing + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + // Do nothing + } + + public void onBound(SeriesRecording seriesRecording) { + mSeriesRecording = seriesRecording; + mDvrDataManager.addScheduledRecordingListener(this); + mDvrDataManager.addRecordedProgramListener(this); + for (RecordedProgram recordedProgram : + mDvrDataManager.getRecordedPrograms(mSeriesRecording.getId())) { + if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId()) + == TvInputManager.TIME_SHIFT_INVALID_TIME) { + mWatchedPositionManager.addListener(this, recordedProgram.getId()); + } + } + updateCardViewContent(); + } + + public void onUnbound() { + mDvrDataManager.removeScheduledRecordingListener(this); + mDvrDataManager.removeRecordedProgramListener(this); + mWatchedPositionManager.removeListener(this); + } + + private void updateCardViewContent() { + int count = 0; + int quantityStringID; + List<RecordedProgram> recordedPrograms = + mDvrDataManager.getRecordedPrograms(mSeriesRecording.getId()); + if (recordedPrograms.size() == 0) { + count = mDvrDataManager.getScheduledRecordings(mSeriesRecording.getId()).size(); + quantityStringID = R.plurals.dvr_count_scheduled_recordings; + } else { + for (RecordedProgram recordedProgram : recordedPrograms) { + if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId()) + == TvInputManager.TIME_SHIFT_INVALID_TIME) { + count++; + } + } + if (count == 0) { + count = recordedPrograms.size(); + quantityStringID = R.plurals.dvr_count_recordings; + } else { + quantityStringID = R.plurals.dvr_count_new_recordings; + } + } + mCardView.setContent(mCardView.getResources() + .getQuantityString(quantityStringID, count, count), null); + } + } + + public SeriesRecordingPresenter(Context context) { + ApplicationSingletons singletons = TvApplication.getSingletons(context); + mChannelDataManager = singletons.getChannelDataManager(); + mDvrDataManager = singletons.getDvrDataManager(); + mWatchedPositionManager = singletons.getDvrWatchedPositionManager(); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + Context context = parent.getContext(); + RecordingCardView view = new RecordingCardView(context); + return new SeriesRecordingViewHolder(view, mDvrDataManager, mWatchedPositionManager); + } + + @Override + public void onBindViewHolder(ViewHolder baseHolder, Object o) { + final SeriesRecordingViewHolder viewHolder = (SeriesRecordingViewHolder) baseHolder; + final SeriesRecording seriesRecording = (SeriesRecording) o; + final RecordingCardView cardView = (RecordingCardView) viewHolder.view; + viewHolder.onBound(seriesRecording); + setTitleAndImage(cardView, seriesRecording); + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + showSeriesRecordingDetails(v, seriesRecording); + } + }; + baseHolder.view.setOnClickListener(clickListener); + } + + private void showSeriesRecordingDetails(View view, SeriesRecording seriesRecording) { + if (view instanceof RecordingCardView) { + Context context = view.getContext(); + Intent intent = new Intent(context, DvrDetailsActivity.class); + intent.putExtra(DvrDetailsActivity.RECORDING_ID, seriesRecording.getId()); + intent.putExtra(DvrDetailsActivity.DETAILS_VIEW_TYPE, + DvrDetailsActivity.SERIES_RECORDING_VIEW); + Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation((Activity) context, + ((RecordingCardView) view).getImageView(), + DvrDetailsActivity.SHARED_ELEMENT_NAME).toBundle(); + context.startActivity(intent, bundle); + } + } + + @Override + public void onUnbindViewHolder(ViewHolder viewHolder) { + ((RecordingCardView) viewHolder.view).reset(); + ((SeriesRecordingViewHolder) viewHolder).onUnbound(); + } + + private void setTitleAndImage(RecordingCardView cardView, SeriesRecording recording) { + cardView.setTitle(recording.getTitle()); + if (recording.getPosterUri() != null) { + cardView.setImageUri(recording.getPosterUri(), false); + } else { + Channel channel = mChannelDataManager.getChannel(recording.getChannelId()); + String imageUri = null; + if (channel != null) { + imageUri = TvContract.buildChannelLogoUri(channel.getId()).toString(); + } + cardView.setImageUri(imageUri, true); + } + } +} diff --git a/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java b/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java new file mode 100644 index 00000000..c550935c --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui; + +import android.app.FragmentManager; +import android.content.Context; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v17.leanback.widget.GuidedActionsStylist; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.SeriesRecording.ChannelOption; + +import java.util.ArrayList; +import java.util.List; + +/** + * Fragment for DVR series recording settings. + */ +public class SeriesSettingsFragment extends GuidedStepFragment + implements DvrDataManager.SeriesRecordingListener { + /** + * Name of series recording id added to the bundle. + * Type: Long + */ + public static final String SERIES_RECORDING_ID = "series_recording_id"; + + private static final long ACTION_ID_PRIORITY = 10; + private static final long ACTION_ID_CHANNEL = 11; + + private static final long SUB_ACTION_ID_CHANNEL_ONE = 101; + private static final long SUB_ACTION_ID_CHANNEL_ALL = 102; + + private DvrDataManager mDvrDataManager; + private SeriesRecording mSeriesRecording; + private Channel mChannel; + private long mSeriesRecordingId; + @ChannelOption int mChannelOption; + + private String mFragmentTitle; + private String mProrityActionTitle; + private String mProrityActionHighestText; + private String mProrityActionLowestText; + private String mChannelsActionTitle; + private String mChannelsActionAllText; + + private GuidedAction mPriorityGuidedAction; + private GuidedAction mChannelsGuidedAction; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mSeriesRecordingId = getArguments().getLong(SERIES_RECORDING_ID); + mSeriesRecording = mDvrDataManager.getSeriesRecording(mSeriesRecordingId); + mDvrDataManager.addSeriesRecordingListener(this); + mChannelOption = mSeriesRecording.getChannelOption(); + mChannel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(mSeriesRecording.getChannelId()); + // TODO: Handle when channel is null. + mFragmentTitle = getString(R.string.dvr_series_settings_title); + mProrityActionTitle = getString(R.string.dvr_series_settings_priority); + mProrityActionHighestText = getString(R.string.dvr_series_settings_priority_highest); + mProrityActionLowestText = getString(R.string.dvr_series_settings_priority_lowest); + mChannelsActionTitle = getString(R.string.dvr_series_settings_channels); + mChannelsActionAllText = getString(R.string.dvr_series_settings_channels_all); + } + + @Override + public void onDetach() { + super.onDetach(); + mDvrDataManager.removeSeriesRecordingListener(this); + } + + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String breadcrumb = mSeriesRecording.getTitle(); + String title = mFragmentTitle; + return new Guidance(title, null, breadcrumb, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + mPriorityGuidedAction = new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_PRIORITY) + .title(mProrityActionTitle) + .build(); + updatePriorityGuidedAction(false); + actions.add(mPriorityGuidedAction); + + List<GuidedAction> channelSubActions = new ArrayList<GuidedAction>(); + channelSubActions.add(new GuidedAction.Builder(getActivity()) + .id(SUB_ACTION_ID_CHANNEL_ONE) + .title(mChannel.getDisplayText()) + .build()); + channelSubActions.add(new GuidedAction.Builder(getActivity()) + .id(SUB_ACTION_ID_CHANNEL_ALL) + .title(mChannelsActionAllText) + .build()); + mChannelsGuidedAction = new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_CHANNEL) + .title(mChannelsActionTitle) + .subActions(channelSubActions) + .build(); + actions.add(mChannelsGuidedAction); + updateChannelsGuidedAction(false); + } + + @Override + public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) { + actions.add(new GuidedAction.Builder(getActivity()) + .clickAction(GuidedAction.ACTION_ID_OK) + .build()); + actions.add(new GuidedAction.Builder(getActivity()) + .clickAction(GuidedAction.ACTION_ID_CANCEL) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + long actionId = action.getId(); + if (actionId == GuidedAction.ACTION_ID_OK) { + if (mChannelOption != mSeriesRecording.getChannelOption()) { + TvApplication.getSingletons(getContext()).getDvrManager() + .updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording) + .setChannelOption(mChannelOption) + .build()); + } + finishGuidedStepFragments(); + } else if (actionId == GuidedAction.ACTION_ID_CANCEL) { + finishGuidedStepFragments(); + } else if (actionId == ACTION_ID_PRIORITY) { + FragmentManager fragmentManager = getFragmentManager(); + PrioritySettingsFragment fragment = new PrioritySettingsFragment(); + Bundle args = new Bundle(); + args.putLong(PrioritySettingsFragment.COME_FROM_SERIES_RECORDING_ID, + mSeriesRecording.getId()); + fragment.setArguments(args); + GuidedStepFragment.add(fragmentManager, fragment, R.id.dvr_settings_view_frame); + } + } + + @Override + public boolean onSubGuidedActionClicked(GuidedAction action) { + long actionId = action.getId(); + if (actionId == SUB_ACTION_ID_CHANNEL_ALL) { + mChannelOption = SeriesRecording.OPTION_CHANNEL_ALL; + updateChannelsGuidedAction(true); + return true; + } else if (actionId == SUB_ACTION_ID_CHANNEL_ONE) { + mChannelOption = SeriesRecording.OPTION_CHANNEL_ONE; + updateChannelsGuidedAction(true); + return true; + } + return false; + } + + @Override + public GuidedActionsStylist onCreateButtonActionsStylist() { + return new DvrGuidedActionsStylist(true); + } + + private void updateChannelsGuidedAction(boolean notifyActionChanged) { + if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ALL) { + mChannelsGuidedAction.setDescription(mChannelsActionAllText); + } else { + mChannelsGuidedAction.setDescription(mChannel.getDisplayText()); + } + if (notifyActionChanged) { + notifyActionChanged(findActionPositionById(ACTION_ID_CHANNEL)); + } + } + + private void updatePriorityGuidedAction(boolean notifyActionChanged) { + int totalSeriesCount = 0; + int priorityOrder = 0; + for (SeriesRecording seriesRecording : mDvrDataManager.getSeriesRecordings()) { + if (seriesRecording.getState() == SeriesRecording.STATE_SERIES_NORMAL + || seriesRecording.getId() == mSeriesRecording.getId()) { + ++totalSeriesCount; + } + if (seriesRecording.getState() == SeriesRecording.STATE_SERIES_NORMAL + && seriesRecording.getId() != mSeriesRecording.getId() + && seriesRecording.getPriority() > mSeriesRecording.getPriority()) { + ++priorityOrder; + } + } + if (priorityOrder == 0) { + mPriorityGuidedAction.setDescription(mProrityActionHighestText); + } else if (priorityOrder >= totalSeriesCount - 1) { + mPriorityGuidedAction.setDescription(mProrityActionLowestText); + } else { + mPriorityGuidedAction.setDescription(Integer.toString(priorityOrder + 1)); + } + if (notifyActionChanged) { + notifyActionChanged(findActionPositionById(ACTION_ID_PRIORITY)); + } + } + + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + if (seriesRecording.getId() == mSeriesRecordingId) { + mSeriesRecording = seriesRecording; + updatePriorityGuidedAction(true); + return; + } + } + } +} diff --git a/src/com/android/tv/dvr/ui/SortedArrayAdapter.java b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java index 8a8bcdeb..3a57d72e 100644 --- a/src/com/android/tv/dvr/ui/SortedArrayAdapter.java +++ b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java @@ -16,7 +16,8 @@ package com.android.tv.dvr.ui; -import android.support.v17.leanback.widget.ObjectAdapter; +import android.support.annotation.VisibleForTesting; +import android.support.v17.leanback.widget.ArrayObjectAdapter; import android.support.v17.leanback.widget.PresenterSelector; import java.util.ArrayList; @@ -26,168 +27,152 @@ import java.util.Comparator; import java.util.List; /** - * Keeps a set of {@code T} items sorted, but leaving a {@link EmptyHolder} - * if there is no items. + * Keeps a set of items sorted * * <p>{@code T} must have stable IDs. */ -abstract class SortedArrayAdapter<T> extends ObjectAdapter { - private final List<T> mItems = new ArrayList<>(); +public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { private final Comparator<T> mComparator; + private final int mMaxItemCount; + private int mExtraItemCount; SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator) { - super(presenterSelector); - mComparator = comparator; - setHasStableIds(true); - } - - @Override - public final int size() { - return mItems.isEmpty() ? 1 : mItems.size(); - } - - @Override - public final Object get(int position) { - return isEmpty() ? EmptyHolder.EMPTY_HOLDER : getItem(position); + this(presenterSelector, comparator, Integer.MAX_VALUE); } - @Override - public final long getId(int position) { - if (isEmpty()) { - return NO_ID; - } - T item = mItems.get(position); - return item == null ? NO_ID : getId(item); - } - - /** - * Returns the id of the the given {@code item}. - * - * The id must be stable. - */ - abstract long getId(T item); - - /** - * Returns the item at the given {@code position}. - * - * @throws IndexOutOfBoundsException if the position is out of range - * (<tt>position < 0 || position >= size()</tt>) - */ - final T getItem(int position) { - return mItems.get(position); + SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator, + int maxItemCount) { + super(presenterSelector); + mComparator = comparator; + mMaxItemCount = maxItemCount; } /** - * Returns {@code true} if the list of items is empty. + * Sets the objects in the given collection to the adapter keeping the elements sorted. * - * <p><b>NOTE</b> when the item list is empty the adapter has a size of 1 and - * {@link EmptyHolder#EMPTY_HOLDER} at position 0; + * @param items A {@link Collection} of items to be set. */ - final boolean isEmpty() { - return mItems.isEmpty(); + @VisibleForTesting + final void setInitialItems(List<T> items) { + List<T> itemsCopy = new ArrayList<>(items); + Collections.sort(itemsCopy, mComparator); + addAll(0, itemsCopy.subList(0, Math.min(mMaxItemCount, itemsCopy.size()))); } /** - * Removes all elements from the list. + * Adds an item in sorted order to the adapter. * - * <p><b>NOTE</b> when the item list is empty the adapter has a size of 1 and - * {@link EmptyHolder#EMPTY_HOLDER} at position 0; + * @param item The item to add in sorted order to the adapter. */ - final void clear() { - mItems.clear(); - notifyChanged(); + @Override + public final void add(Object item) { + add((T) item, false); } - /** - * Adds the objects in the given collection to the adapter keeping the elements sorted. - * If the index is >= {@link #size} an exception will be thrown. - * - * @param items A {@link Collection} of items to insert. - */ - final void addAll(Collection<T> items) { - mItems.addAll(items); - Collections.sort(mItems, mComparator); - notifyChanged(); + public boolean isEmpty() { + return size() == 0; } /** * Adds an item in sorted order to the adapter. * * @param item The item to add in sorted order to the adapter. + * @param insertToEnd If items are inserted in a more or less sorted fashion, + * sets this parameter to {@code true} to search insertion position from + * the end to save search time. */ - final void add(T item) { - int i = findWhereToInsert(item); - mItems.add(i, item); - if (mItems.size() == 1) { - notifyItemRangeChanged(0, 1); + public final void add(T item, boolean insertToEnd) { + int i; + if (insertToEnd) { + i = findInsertPosition(item); } else { - notifyItemRangeInserted(i, 1); + i = findInsertPositionBinary(item); + } + super.add(i, item); + if (size() > mMaxItemCount + mExtraItemCount) { + removeItems(mMaxItemCount, size() - mMaxItemCount - mExtraItemCount); } } /** - * Remove an item from the list - * - * @param item The item to remove from the adapter. + * Adds an extra item to the end of the adapter. The items will not be subjected to the sorted + * order or the maximum number of items. One or more extra items can be added to the adapter. + * They will be presented in their insertion order. */ - final void remove(T item) { - int index = indexOf(item); - if (index != -1) { - mItems.remove(index); - if (mItems.isEmpty()) { - notifyItemRangeChanged(0, 1); - } else { - notifyItemRangeRemoved(index, 1); - } - } + public int addExtraItem(T item) { + super.add(item); + return ++mExtraItemCount; + } + + /** + * Removes an item which has the same ID as {@code item}. + */ + public boolean removeWithId(T item) { + int index = indexWithTypeAndId(item); + return index >= 0 && index < size() && remove(get(index)); } /** * Change an item in the list. * @param item The item to change. */ - final void change(T item) { - int oldIndex = indexOf(item); + public final void change(T item) { + int oldIndex = indexWithTypeAndId(item); if (oldIndex != -1) { - T old = mItems.get(oldIndex); + T old = (T) get(oldIndex); if (mComparator.compare(old, item) == 0) { - mItems.set(oldIndex, item); - notifyItemRangeChanged(oldIndex, 1); + replace(oldIndex, item); return; } - mItems.remove(oldIndex); - } - int newIndex = findWhereToInsert(item); - mItems.add(newIndex, item); - - if (oldIndex != -1) { - notifyItemRangeRemoved(oldIndex, 1); - } - if (newIndex != -1) { - notifyItemRangeInserted(newIndex, 1); + removeItems(oldIndex, 1); } + add(item); } - private int indexOf(T item) { + /** + * Returns the id of the the given {@code item}, which will be used in {@link #change} to + * decide if the given item is already existed in the adapter. + * + * The id must be stable. + */ + abstract long getId(T item); + + private int indexWithTypeAndId(T item) { long id = getId(item); - for (int i = 0; i < mItems.size(); i++) { - T r = mItems.get(i); - if (getId(r) == id) { + for (int i = 0; i < size() - mExtraItemCount; i++) { + T r = (T) get(i); + if (r.getClass() == item.getClass() && getId(r) == id) { return i; } } return -1; } - private int findWhereToInsert(T item) { - int i; - int size = mItems.size(); - for (i = 0; i < size; i++) { - T r = mItems.get(i); - if (mComparator.compare(r, item) > 0) { - return i; + private int findInsertPosition(T item) { + for (int i = size() - mExtraItemCount - 1; i >=0; i--) { + T r = (T) get(i); + if (mComparator.compare(r, item) <= 0) { + return i + 1; + } + } + return 0; + } + + private int findInsertPositionBinary(T item) { + int lb = 0; + int ub = size() - mExtraItemCount - 1; + while (lb <= ub) { + int mid = (lb + ub) / 2; + T r = (T) get(mid); + int compareResult = mComparator.compare(item, r); + if (compareResult == 0) { + return mid; + } else if (compareResult > 0) { + lb = mid + 1; + } else { + ub = mid - 1; } } - return size; + return lb; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java new file mode 100644 index 00000000..61de5764 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java @@ -0,0 +1,209 @@ +/* +* Copyright (C) 2016 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License +*/ + +package com.android.tv.dvr.ui.list; + +import android.os.Bundle; +import android.support.v17.leanback.app.DetailsFragment; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.TextView; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.ScheduledRecording; + +/** + * A base fragment to show the list of schedule recordings. + */ +public abstract class BaseDvrSchedulesFragment extends DetailsFragment + implements DvrDataManager.ScheduledRecordingListener, + SchedulesHeaderRowPresenter.SchedulesHeaderRowListener, + ScheduleRowPresenter.ScheduleRowClickListener { + /** + * The key for scheduled recording which has be selected in the list. + */ + public static String SCHEDULES_KEY_SCHEDULED_RECORDING = "schedules_key_scheduled_recording"; + + private SchedulesHeaderRowPresenter mHeaderRowPresenter; + private ScheduleRowPresenter mRowPresenter; + private ScheduleRowAdapter mRowsAdapter; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); + mHeaderRowPresenter = onCreateHeaderRowPresenter(); + mHeaderRowPresenter.addListener(this); + mRowPresenter = onCreateRowPresenter(); + mRowPresenter.addListener(this); + presenterSelector.addClassPresenter(SchedulesHeaderRow.class, mHeaderRowPresenter); + presenterSelector.addClassPresenter(ScheduleRow.class, mRowPresenter); + mRowsAdapter = onCreateRowsAdapter(presenterSelector); + setAdapter(mRowsAdapter); + mRowsAdapter.start(); + TvApplication.getSingletons(getContext()).getDvrDataManager() + .addScheduledRecordingListener(this); + } + + /** + * Returns rows adapter. + */ + protected ScheduleRowAdapter getRowsAdapter() { + return mRowsAdapter; + } + + /** + * Shows the empty message. + */ + protected void showEmptyMessage(int message) { + TextView emptyInfoScreenView = (TextView) getActivity().findViewById( + R.id.empty_info_screen); + emptyInfoScreenView.setText(message); + emptyInfoScreenView.setVisibility(View.VISIBLE); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + // setSelectedPosition works only after the view is attached to a window. + view.getViewTreeObserver().addOnWindowAttachListener( + new ViewTreeObserver.OnWindowAttachListener() { + @Override + public void onWindowAttached() { + int firstItemPosition = getFirstItemPosition(); + if (firstItemPosition != -1) { + setSelectedPosition(firstItemPosition, false); + } + view.getViewTreeObserver().removeOnWindowAttachListener(this); + } + + @Override + public void onWindowDetached() { + } + }); + return view; + } + + @Override + public View onInflateTitleView(LayoutInflater inflater, ViewGroup parent, + Bundle savedInstanceState) { + // Workaround of b/31046014 + return null; + } + + @Override + public void onDestroy() { + TvApplication.getSingletons(getContext()).getDvrDataManager() + .removeScheduledRecordingListener(this); + mHeaderRowPresenter.removeListener(this); + mRowPresenter.removeListener(this); + mRowsAdapter.stop(); + super.onDestroy(); + } + + /** + * Creates header row presenter. + */ + public abstract SchedulesHeaderRowPresenter onCreateHeaderRowPresenter(); + + /** + * Creates rows presenter. + */ + public abstract ScheduleRowPresenter onCreateRowPresenter(); + + /** + * Creates rows adapter. + */ + public abstract ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelecor); + + /** + * Gets the first focus position in schedules list. + */ + protected int getFirstItemPosition() { + Bundle args = getArguments(); + ScheduledRecording recording = null; + if (args != null) { + recording = args.getParcelable(SCHEDULES_KEY_SCHEDULED_RECORDING); + } + final int selectedPostion = mRowsAdapter.indexOf( + mRowsAdapter.findRowByScheduledRecording(recording)); + if (selectedPostion != -1) { + return selectedPostion; + } + for (int i = 0; i < mRowsAdapter.size(); i++) { + if (mRowsAdapter.get(i) instanceof ScheduleRow) { + return i; + } + } + return -1; + } + + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording recording : scheduledRecordings) { + if (mRowPresenter != null) { + mRowPresenter.onScheduledRecordingAdded(recording); + } + if (mRowsAdapter != null) { + mRowsAdapter.onScheduledRecordingAdded(recording); + } + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording recording : scheduledRecordings) { + if (mRowPresenter != null) { + mRowPresenter.onScheduledRecordingRemoved(recording); + } + if (mRowsAdapter != null) { + mRowsAdapter.onScheduledRecordingRemoved(recording); + } + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording recording : scheduledRecordings) { + if (mRowPresenter != null) { + mRowPresenter.onScheduledRecordingUpdated(recording); + } + if (mRowsAdapter != null) { + mRowsAdapter.onScheduledRecordingUpdated(recording); + } + } + } + + @Override + public void onUpdateAllScheduleRows() { + if (getRowsAdapter() != null) { + getRowsAdapter().notifyArrayItemRangeChanged(0, getRowsAdapter().size()); + } + } + + @Override + public void onDeleteClicked(ScheduleRow scheduleRow) { + if (mRowsAdapter != null) { + mRowsAdapter.notifyArrayItemRangeChanged(0, mRowsAdapter.size()); + } + } +} diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java new file mode 100644 index 00000000..c906c62a --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import com.android.tv.R; + +/** + * A view used for focus in schedules list. + */ +public class DvrSchedulesFocusView extends View { + private final Paint mPaint; + private final RectF mRoundRectF = new RectF(); + private final int mRoundRectRadius; + + private final String mViewTag; + private final String mHeaderFocusViewTag; + private final String mItemFocusViewTag; + + public DvrSchedulesFocusView(Context context) { + this(context, null, 0); + } + + public DvrSchedulesFocusView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public DvrSchedulesFocusView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mHeaderFocusViewTag = getContext().getString(R.string.dvr_schedules_header_focus_view); + mItemFocusViewTag = getContext().getString(R.string.dvr_schedules_item_focus_view); + mViewTag = (String) getTag(); + mPaint = createPaint(context); + mRoundRectRadius = getRoundRectRadius(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (TextUtils.equals(mViewTag, mHeaderFocusViewTag)) { + mRoundRectF.set(0, 0, getWidth(), getHeight()); + } else if (TextUtils.equals(mViewTag, mItemFocusViewTag)) { + int drawHeight = 2 * mRoundRectRadius; + int drawOffset = (drawHeight - getHeight()) / 2; + mRoundRectF.set(0, -drawOffset, getWidth(), getHeight() + drawOffset); + } + canvas.drawRoundRect(mRoundRectF, mRoundRectRadius, mRoundRectRadius, mPaint); + } + + private Paint createPaint(Context context) { + Paint paint = new Paint(); + paint.setColor(context.getColor(R.color.dvr_schedules_list_item_selector)); + return paint; + } + + private int getRoundRectRadius() { + if (TextUtils.equals(mViewTag, mHeaderFocusViewTag)) { + return getResources().getDimensionPixelSize( + R.dimen.dvr_schedules_header_selector_radius); + } else if (TextUtils.equals(mViewTag, mItemFocusViewTag)) { + return getResources().getDimensionPixelSize(R.dimen.dvr_schedules_selector_radius); + } + return 0; + } +} + + diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java new file mode 100644 index 00000000..f361ede3 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.os.Bundle; +import android.support.v17.leanback.widget.ClassPresenterSelector; + +import com.android.tv.R; +import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.DateHeaderRowPresenter; + +/** + * A fragment to show the list of schedule recordings. + */ +public class DvrSchedulesFragment extends BaseDvrSchedulesFragment { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getRowsAdapter().size() == 0) { + showEmptyMessage(R.string.dvr_schedules_empty_state); + } + } + + @Override + public SchedulesHeaderRowPresenter onCreateHeaderRowPresenter() { + return new DateHeaderRowPresenter(getContext()); + } + + @Override + public ScheduleRowPresenter onCreateRowPresenter() { + return new ScheduleRowPresenter(getContext()); + } + + @Override + public ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelecor) { + return new ScheduleRowAdapter(getContext(), presenterSelecor); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java new file mode 100644 index 00000000..ba8b0c36 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java @@ -0,0 +1,112 @@ +/* +* Copyright (C) 2016 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License +*/ + +package com.android.tv.dvr.ui.list; + +import android.os.Bundle; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.ui.DvrSchedulesActivity; +import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; +import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.SeriesRecordingHeaderRowPresenter; + +/** + * A fragment to show the list of series schedule recordings. + */ +public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { + /** + * The key for series recording whose scheduled recording list will be displayed. + */ + public static String SERIES_SCHEDULES_KEY_SERIES_RECORDING = + "series_schedules_key_series_recording"; + + private static String TAG = "DvrSeriesSchedulesFragment"; + + private SeriesRecording mSeries; + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mSeries = args.getParcelable(SERIES_SCHEDULES_KEY_SERIES_RECORDING); + } + super.onCreate(savedInstanceState); + // "1" means there is only title row in series schedules list. So we should show an empty + // state info view. + if (getRowsAdapter().size() == 1) { + showEmptyMessage(R.string.dvr_series_schedules_empty_state); + } + ((DvrSchedulesActivity) getActivity()).setCancelAllClickedRunnable(new Runnable() { + @Override + public void run() { + SoftPreconditions.checkState(getRowsAdapter().get(0) instanceof + SeriesRecordingHeaderRow, TAG, "First row is not SchedulesHeaderRow"); + SeriesRecordingHeaderRow headerRow = + (SeriesRecordingHeaderRow) getRowsAdapter().get(0); + headerRow.setCancelAllChecked(true); + if (headerRow.getSeriesRecording() != null) { + TvApplication.getSingletons(getContext()).getDvrManager() + .updateSeriesRecording(SeriesRecording.buildFrom( + headerRow.getSeriesRecording()).setState( + SeriesRecording.STATE_SERIES_CANCELED).build()); + } + onUpdateAllScheduleRows(); + } + }); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return super.onCreateView(inflater, container, savedInstanceState); + } + + @Override + public SchedulesHeaderRowPresenter onCreateHeaderRowPresenter() { + return new SeriesRecordingHeaderRowPresenter(getContext()); + } + + @Override + public ScheduleRowPresenter onCreateRowPresenter() { + return new SeriesScheduleRowPresenter(getContext()); + } + + @Override + public ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelector) { + return new SeriesScheduleRowAdapter(getContext(), presenterSelector, mSeries); + } + + @Override + protected int getFirstItemPosition() { + if (mSeries != null && mSeries.getState() == SeriesRecording.STATE_SERIES_CANCELED) { + return -1; + } + return super.getFirstItemPosition(); + } + + @Override + public void onDestroy() { + ((DvrSchedulesActivity) getActivity()).setCancelAllClickedRunnable(null); + super.onDestroy(); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRow.java b/src/com/android/tv/dvr/ui/list/ScheduleRow.java new file mode 100644 index 00000000..1e258d2d --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRow.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import com.android.tv.dvr.ScheduledRecording; + +/** + * A class for schedule recording row. + */ +public class ScheduleRow { + private ScheduledRecording mRecording; + private boolean mRemoveScheduleChecked; + private SchedulesHeaderRow mHeaderRow; + + public ScheduleRow(ScheduledRecording recording, SchedulesHeaderRow headerRow) { + mRecording = recording; + mRemoveScheduleChecked = false; + mHeaderRow = headerRow; + } + + /** + * Sets scheduled recording. + */ + public void setRecording(ScheduledRecording recording) { + mRecording = recording; + } + + /** + * Sets remove schedule checked status. + */ + public void setRemoveScheduleChecked(boolean checked) { + mRemoveScheduleChecked = checked; + } + + /** + * Gets scheduled recording. + */ + public ScheduledRecording getRecording() { + return mRecording; + } + + /** + * Gets remove schedule checked status. + */ + public boolean isRemoveScheduleChecked() { + return mRemoveScheduleChecked; + } + + /** + * Gets which {@link SchedulesHeaderRow} this schedule row belongs to. + */ + public SchedulesHeaderRow getHeaderRow() { + return mHeaderRow; + } +} diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java new file mode 100644 index 00000000..3e2630c7 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.content.Context; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.text.format.DateUtils; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * An adapter for {@link ScheduleRow}. + */ +public class ScheduleRowAdapter extends ArrayObjectAdapter { + private final static long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); + + private Context mContext; + private final List<String> mTitles = new ArrayList<>(); + + public ScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector) { + super(classPresenterSelector); + mContext = context; + mTitles.add(mContext.getString(R.string.dvr_date_today)); + mTitles.add(mContext.getString(R.string.dvr_date_tomorrow)); + } + + /** + * Returns context. + */ + protected Context getContext() { + return mContext; + } + + /** + * Starts schedule row adapter. + */ + public void start() { + clear(); + List<ScheduledRecording> recordingList = TvApplication.getSingletons(mContext) + .getDvrDataManager().getNonStartedScheduledRecordings(); + recordingList.addAll(TvApplication.getSingletons(mContext).getDvrDataManager() + .getStartedRecordings()); + Collections.sort(recordingList, ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR); + long deadLine = Utils.getLastMillisecondOfDay(System.currentTimeMillis()); + for (int i = 0; i < recordingList.size();) { + ArrayList<ScheduledRecording> section = new ArrayList<>(); + while (i < recordingList.size() && recordingList.get(i).getStartTimeMs() < deadLine) { + section.add(recordingList.get(i++)); + } + if (!section.isEmpty()) { + SchedulesHeaderRow headerRow = new DateHeaderRow(calculateHeaderDate(deadLine), + mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_section_subtitle, section.size(), section.size()), + section.size(), deadLine); + add(headerRow); + for(ScheduledRecording recording : section){ + add(new ScheduleRow(recording, headerRow)); + } + } + deadLine += ONE_DAY_MS; + } + } + + private String calculateHeaderDate(long deadLine) { + int titleIndex = (int) ((deadLine - + Utils.getLastMillisecondOfDay(System.currentTimeMillis())) / ONE_DAY_MS); + String headerDate; + if (titleIndex < mTitles.size()) { + headerDate = mTitles.get(titleIndex); + } else { + headerDate = DateUtils.formatDateTime(getContext(), deadLine, + DateUtils.FORMAT_SHOW_WEEKDAY| DateUtils.FORMAT_ABBREV_WEEKDAY + | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_MONTH); + } + return headerDate; + } + + /** + * Stops schedules row adapter. + */ + public void stop() { + // TODO: Deal with other type of operation. + for (int i = 0; i < size(); i++) { + if (get(i) instanceof ScheduleRow) { + ScheduleRow scheduleRow = (ScheduleRow) get(i); + if (scheduleRow.isRemoveScheduleChecked()) { + TvApplication.getSingletons(mContext).getDvrManager() + .removeScheduledRecording(scheduleRow.getRecording()); + } + } + } + } + + /** + * Gets which {@link ScheduleRow} the {@link ScheduledRecording} belongs to. + */ + public ScheduleRow findRowByScheduledRecording(ScheduledRecording recording) { + if (recording == null) { + return null; + } + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (item instanceof ScheduleRow) { + if (((ScheduleRow) item).getRecording().getId() == recording.getId()) { + return (ScheduleRow) item; + } + } + } + return null; + } + + /** + * Adds a {@link ScheduleRow} by {@link ScheduledRecording} and update + * {@link SchedulesHeaderRow} information. + */ + protected void addScheduleRow(ScheduledRecording recording) { + if (recording != null) { + int pre = -1; + int index = 0; + for (; index < size(); index++) { + if (get(index) instanceof ScheduleRow) { + ScheduleRow scheduleRow = (ScheduleRow) get(index); + if (ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR.compare( + scheduleRow.getRecording(), recording) > 0) { + break; + } + pre = index; + } + } + long deadLine = Utils.getLastMillisecondOfDay(recording.getStartTimeMs()); + if (pre >= 0 && getHeaderRow(pre).getDeadLineMs() == deadLine) { + SchedulesHeaderRow headerRow = ((ScheduleRow) get(pre)).getHeaderRow(); + headerRow.setItemCount(headerRow.getItemCount() + 1); + ScheduleRow addedRow = new ScheduleRow(recording, headerRow); + add(++pre, addedRow); + } else if (index < size() && getHeaderRow(index).getDeadLineMs() == deadLine) { + SchedulesHeaderRow headerRow = ((ScheduleRow) get(index)).getHeaderRow(); + headerRow.setItemCount(headerRow.getItemCount() + 1); + ScheduleRow addedRow = new ScheduleRow(recording, headerRow); + add(index, addedRow); + } else { + SchedulesHeaderRow headerRow = new DateHeaderRow(calculateHeaderDate(deadLine), + mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_section_subtitle, 1, 1), 1, deadLine); + add(++pre, headerRow); + ScheduleRow addedRow = new ScheduleRow(recording, headerRow); + add(pre, addedRow); + } + } + } + + private DateHeaderRow getHeaderRow(int index) { + return ((DateHeaderRow) ((ScheduleRow) get(index)).getHeaderRow()); + } + + /** + * Removes {@link ScheduleRow} and update {@link SchedulesHeaderRow} information. + */ + protected void removeScheduleRow(ScheduleRow scheduleRow) { + if (scheduleRow != null) { + SchedulesHeaderRow headerRow = scheduleRow.getHeaderRow(); + remove(scheduleRow); + // Changes the count information of header which the removed row belongs to. + if (headerRow != null) { + int currentCount = headerRow.getItemCount(); + headerRow.setItemCount(--currentCount); + if (headerRow.getItemCount() == 0) { + remove(headerRow); + } else { + headerRow.setDescription(mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_section_subtitle, + headerRow.getItemCount(), headerRow.getItemCount())); + replace(indexOf(headerRow), headerRow); + } + } + } + } + + /** + * Called when a schedule recording is added to dvr date manager. + */ + public void onScheduledRecordingAdded(ScheduledRecording recording) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + addScheduleRow(recording); + notifyArrayItemRangeChanged(0, size()); + } + } + + /** + * Called when a schedule recording is removed from dvr date manager. + */ + public void onScheduledRecordingRemoved(ScheduledRecording recording) { + ScheduleRow scheduleRow = findRowByScheduledRecording(recording); + if (scheduleRow != null) { + removeScheduleRow(scheduleRow); + } + notifyArrayItemRangeChanged(0, size()); + } + + /** + * Called when a schedule recording is updated in dvr date manager. + */ + public void onScheduledRecordingUpdated(ScheduledRecording recording) { + ScheduleRow scheduleRow = findRowByScheduledRecording(recording); + if (scheduleRow != null) { + scheduleRow.setRecording(recording); + if (!willBeKept(recording)) { + removeScheduleRow(scheduleRow); + } + } else if (willBeKept(recording)) { + addScheduleRow(recording); + } + notifyArrayItemRangeChanged(0, size()); + } + + /** + * To check whether the recording should be kept or not. + */ + protected boolean willBeKept(ScheduledRecording recording) { + return recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS + || recording.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED; + } +} diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java new file mode 100644 index 00000000..23aaf4c3 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java @@ -0,0 +1,760 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.animation.ValueAnimator; +import android.app.Activity; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.media.tv.TvInputInfo; +import android.support.v17.leanback.widget.RowPresenter; +import android.text.TextUtils; +import android.util.ArraySet; +import android.util.Range; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * A RowPresenter for {@link ScheduleRow}. + */ +public class ScheduleRowPresenter extends RowPresenter { + private Context mContext; + private Set<ScheduleRowClickListener> mListeners = new ArraySet<>(); + private final Drawable mBeingRecordedDrawable; + + private final Map<String, HashMap<Long, ScheduledRecording>> mInputScheduleMap = new + HashMap<>(); + private final List<ScheduledRecording> mConflicts = new ArrayList<>(); + // TODO: Handle input schedule map and conflicts info in the adapter. + + private final Drawable mOnAirDrawable; + private final Drawable mCancelDrawable; + private final Drawable mScheduleDrawable; + + private final String mTunerConflictWillNotBeRecordedInfo; + private final String mTunerConflictWillBePartiallyRecordedInfo; + private final String mInfoSeparator; + + /** + * A ViewHolder for {@link ScheduleRow} + */ + public static class ScheduleRowViewHolder extends RowPresenter.ViewHolder { + private boolean mLtr; + private LinearLayout mInfoContainer; + private RelativeLayout mScheduleActionContainer; + private RelativeLayout mDeleteActionContainer; + private View mSelectorView; + private TextView mTimeView; + private TextView mProgramTitleView; + private TextView mInfoSeparatorView; + private TextView mChannelNameView; + private TextView mConflictInfoView; + private ImageView mScheduleActionView; + private ImageView mDeleteActionView; + + private ScheduledRecording mRecording; + + public ScheduleRowViewHolder(View view) { + super(view); + mLtr = view.getContext().getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_LTR; + mInfoContainer = (LinearLayout) view.findViewById(R.id.info_container); + mScheduleActionContainer = (RelativeLayout) view.findViewById( + R.id.action_schedule_container); + mScheduleActionView = (ImageView) view.findViewById(R.id.action_schedule); + mDeleteActionContainer = (RelativeLayout) view.findViewById( + R.id.action_delete_container); + mDeleteActionView = (ImageView) view.findViewById(R.id.action_delete); + mSelectorView = view.findViewById(R.id.selector); + mTimeView = (TextView) view.findViewById(R.id.time); + mProgramTitleView = (TextView) view.findViewById(R.id.program_title); + mInfoSeparatorView = (TextView) view.findViewById(R.id.info_separator); + mChannelNameView = (TextView) view.findViewById(R.id.channel_name); + mConflictInfoView = (TextView) view.findViewById(R.id.conflict_info); + + mInfoContainer.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + view.post(new Runnable() { + @Override + public void run() { + updateSelector(); + } + }); + } + }); + + mDeleteActionContainer.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + view.post(new Runnable() { + @Override + public void run() { + updateSelector(); + } + }); + } + }); + + mScheduleActionContainer.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + view.post(new Runnable() { + @Override + public void run() { + updateSelector(); + } + }); + } + }); + } + + /** + * Sets scheduled recording. + */ + public void setRecording(ScheduledRecording recording) { + mRecording = recording; + } + + /** + * Returns Info container. + */ + public LinearLayout getInfoContainer() { + return mInfoContainer; + } + + /** + * Returns schedule action container. + */ + public RelativeLayout getScheduleActionContainer() { + return mScheduleActionContainer; + } + + /** + * Returns delete action container. + */ + public RelativeLayout getDeleteActionContainer() { + return mDeleteActionContainer; + } + + /** + * Returns time view. + */ + public TextView getTimeView() { + return mTimeView; + } + + /** + * Returns title view. + */ + public TextView getProgramTitleView() { + return mProgramTitleView; + } + + /** + * Returns subtitle view. + */ + public TextView getChannelNameView() { + return mChannelNameView; + } + + /** + * Returns conflict information view. + */ + public TextView getConflictInfoView() { + return mConflictInfoView; + } + + /** + * Returns schedule action view. + */ + public ImageView getScheduleActionView() { + return mScheduleActionView; + } + + /** + * Returns delete action view. + */ + public ImageView getDeleteActionView() { + return mDeleteActionView; + } + + /** + * Returns scheduled recording. + */ + public ScheduledRecording getRecording() { + return mRecording; + } + + private void updateSelector() { + // TODO: Support RTL language + int animationDuration = mSelectorView.getResources().getInteger( + android.R.integer.config_shortAnimTime); + DecelerateInterpolator interpolator = new DecelerateInterpolator(); + int roundRectRadius = view.getResources().getDimensionPixelSize( + R.dimen.dvr_schedules_selector_radius); + + if (mInfoContainer.isFocused() || mScheduleActionContainer.isFocused() + || mDeleteActionContainer.isFocused()) { + final ViewGroup.LayoutParams lp = mSelectorView.getLayoutParams(); + final int targetWidth; + if (mInfoContainer.isFocused()) { + if (mScheduleActionContainer.getVisibility() == View.GONE + && mDeleteActionContainer.getVisibility() == View.GONE) { + targetWidth = mInfoContainer.getWidth() + 2 * roundRectRadius; + } else { + targetWidth = mInfoContainer.getWidth() + roundRectRadius; + } + } else if (mScheduleActionContainer.isFocused()) { + if (mScheduleActionContainer.getWidth() > 2 * roundRectRadius) { + targetWidth = mScheduleActionContainer.getWidth(); + } else { + targetWidth = 2 * roundRectRadius; + } + } else { + targetWidth = mDeleteActionContainer.getWidth() + roundRectRadius; + } + + float targetTranslationX; + if (mInfoContainer.isFocused()) { + targetTranslationX = mLtr ? mInfoContainer.getLeft() - roundRectRadius + - mSelectorView.getLeft() : + mInfoContainer.getRight() + roundRectRadius - mInfoContainer.getRight(); + } else if (mScheduleActionContainer.isFocused()) { + if (mScheduleActionContainer.getWidth() > 2 * roundRectRadius) { + targetTranslationX = mLtr ? mScheduleActionContainer.getLeft() - + mSelectorView.getLeft() + : mScheduleActionContainer.getRight() - mSelectorView.getRight(); + } else { + targetTranslationX = mLtr ? mScheduleActionContainer.getLeft() - + (roundRectRadius - mScheduleActionContainer.getWidth() / 2) - + mSelectorView.getLeft() + : mScheduleActionContainer.getRight() + + (roundRectRadius - mScheduleActionContainer.getWidth() / 2) - + mSelectorView.getRight(); + } + } else { + targetTranslationX = mLtr ? mDeleteActionContainer.getLeft() + - mSelectorView.getLeft() + : mDeleteActionContainer.getRight() - mSelectorView.getRight(); + } + + if (mSelectorView.getAlpha() == 0) { + mSelectorView.setTranslationX(targetTranslationX); + lp.width = targetWidth; + mSelectorView.requestLayout(); + } + + // animate the selector in and to the proper width and translation X. + final float deltaWidth = lp.width - targetWidth; + mSelectorView.animate().cancel(); + mSelectorView.animate().translationX(targetTranslationX).alpha(1f) + .setUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + // Set width to the proper width for this animation step. + lp.width = targetWidth + Math.round( + deltaWidth * (1f - animation.getAnimatedFraction())); + mSelectorView.requestLayout(); + } + }).setDuration(animationDuration).setInterpolator(interpolator).start(); + } else { + mSelectorView.animate().cancel(); + mSelectorView.animate().alpha(0f).setDuration(animationDuration) + .setInterpolator(interpolator).start(); + } + } + + /** + * Grey out the information body. + */ + public void greyOutInfo() { + mTimeView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info_grey, null)); + mProgramTitleView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info_grey, null)); + mInfoSeparatorView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info_grey, null)); + mChannelNameView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info_grey, null)); + mConflictInfoView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info_grey, null)); + } + + /** + * Reverse grey out operation. + */ + public void whiteBackInfo() { + mTimeView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info, null)); + mProgramTitleView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_main, null)); + mInfoSeparatorView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info, null)); + mChannelNameView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info, null)); + mConflictInfoView.setTextColor(mInfoContainer.getResources().getColor(R.color + .dvr_schedules_item_info, null)); + } + } + + public ScheduleRowPresenter(Context context) { + setHeaderPresenter(null); + setSelectEffectEnabled(false); + mContext = context; + mBeingRecordedDrawable = mContext.getDrawable(R.drawable.ic_record_stop); + mOnAirDrawable = mContext.getDrawable(R.drawable.ic_record_start); + mCancelDrawable = mContext.getDrawable(R.drawable.ic_dvr_cancel); + mScheduleDrawable = mContext.getDrawable(R.drawable.ic_scheduled_recording); + mTunerConflictWillNotBeRecordedInfo = mContext.getString( + R.string.dvr_schedules_tuner_conflict_will_not_be_recorded_info); + mTunerConflictWillBePartiallyRecordedInfo = mContext.getString( + R.string.dvr_schedules_tuner_conflict_will_be_partially_recorded); + mInfoSeparator = mContext.getString(R.string.dvr_schedules_information_separator); + updateInputScheduleMap(); + } + + @Override + public ViewHolder createRowViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(mContext).inflate(R.layout.dvr_schedules_item, + parent, false); + return onGetScheduleRowViewHolder(view); + } + + /** + * Returns context. + */ + protected Context getContext() { + return mContext; + } + + /** + * Returns be recorded drawable which is for being recorded scheduled recordings. + */ + protected Drawable getBeingRecordedDrawable() { + return mBeingRecordedDrawable; + } + + /** + * Returns on air drawable which is for on air but not being recorded scheduled recordings. + */ + protected Drawable getOnAirDrawable() { + return mOnAirDrawable; + } + + /** + * Returns cancel drawable which is for cancelling scheduled recording. + */ + protected Drawable getCancelDrawable() { + return mCancelDrawable; + } + + /** + * Returns schedule drawable which is for scheduling. + */ + protected Drawable getScheduleDrawable() { + return mScheduleDrawable; + } + + /** + * Returns conflicting scheduled recordings. + */ + protected List<ScheduledRecording> getConflicts() { + return mConflicts; + } + + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) { + super.onBindRowViewHolder(vh, item); + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + ScheduleRow scheduleRow = (ScheduleRow) item; + ScheduledRecording recording = scheduleRow.getRecording(); + // TODO: Do not show separator in the first row. + viewHolder.mInfoContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onInfoClicked(scheduleRow); + } + }); + + viewHolder.mDeleteActionContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onDeleteClicked(scheduleRow, viewHolder); + } + }); + + viewHolder.mScheduleActionContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onScheduleClicked(scheduleRow); + } + }); + + viewHolder.mTimeView.setText(onGetRecordingTimeText(recording)); + Channel channel = TvApplication.getSingletons(mContext).getChannelDataManager() + .getChannel(recording.getChannelId()); + String programInfoText = onGetProgramInfoText(recording); + if (TextUtils.isEmpty(programInfoText)) { + int durationMins = + Math.max((int) TimeUnit.MILLISECONDS.toMinutes(recording.getDuration()), 1); + programInfoText = mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_recording_duration, durationMins, durationMins); + } + String channelName = channel != null ? channel.getDisplayName() : null; + viewHolder.mProgramTitleView.setText(programInfoText); + viewHolder.mInfoSeparatorView.setVisibility((!TextUtils.isEmpty(programInfoText) + && !TextUtils.isEmpty(channelName)) ? View.VISIBLE : View.GONE); + viewHolder.mChannelNameView.setText(channelName); + if (!scheduleRow.isRemoveScheduleChecked()) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + viewHolder.mDeleteActionView.setImageDrawable(mBeingRecordedDrawable); + } else { + viewHolder.mDeleteActionView.setImageDrawable(mCancelDrawable); + } + } else { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + viewHolder.mDeleteActionView.setImageDrawable(mOnAirDrawable); + } else { + viewHolder.mDeleteActionView.setImageDrawable(mScheduleDrawable); + } + viewHolder.mProgramTitleView.setTextColor( + mContext.getResources().getColor(R.color.dvr_schedules_item_info, null)); + } + viewHolder.mRecording = recording; + onBindRowViewHolderInternal(viewHolder, scheduleRow); + } + + /** + * Returns view holder for schedule row. + */ + protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) { + return new ScheduleRowViewHolder(view); + } + + /** + * Returns time text for time view from scheduled recording. + */ + protected String onGetRecordingTimeText(ScheduledRecording recording) { + return Utils.getDurationString(mContext, recording.getStartTimeMs(), + recording.getEndTimeMs(), true, false, true, 0); + } + + /** + * Returns program info text for program title view. + */ + protected String onGetProgramInfoText(ScheduledRecording recording) { + if (recording != null) { + return recording.getProgramTitle(); + } + return null; + } + + /** + * Internal method for onBindRowViewHolder, can be customized by subclass. + */ + protected void onBindRowViewHolderInternal(ScheduleRowViewHolder viewHolder, ScheduleRow + scheduleRow) { + if (mConflicts.contains(scheduleRow.getRecording())) { + viewHolder.mScheduleActionView.setImageDrawable(mScheduleDrawable); + String conflictInfo = mTunerConflictWillNotBeRecordedInfo; + // TODO: It's also possible for the NonStarted schedules to be partially recorded. + if (viewHolder.mRecording.getState() + == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + conflictInfo = mTunerConflictWillBePartiallyRecordedInfo; + } + viewHolder.mConflictInfoView.setText(conflictInfo); + // TODO: Add 12dp warning icon to conflict info. + viewHolder.mConflictInfoView.setVisibility(View.VISIBLE); + viewHolder.greyOutInfo(); + } else { + viewHolder.mScheduleActionContainer.setVisibility(View.GONE); + viewHolder.mConflictInfoView.setVisibility(View.GONE); + if (!scheduleRow.isRemoveScheduleChecked()) { + viewHolder.whiteBackInfo(); + } + } + } + + /** + * Updates input schedule map. + */ + private void updateInputScheduleMap() { + mInputScheduleMap.clear(); + List<ScheduledRecording> allRecordings = TvApplication.getSingletons(getContext()) + .getDvrDataManager().getAvailableScheduledRecordings(); + for(ScheduledRecording recording : allRecordings) { + addScheduledRecordingToMap(recording); + } + updateConflicts(); + } + + /** + * Updates conflicting scheduled recordings. + */ + private void updateConflicts() { + mConflicts.clear(); + for (String inputId : mInputScheduleMap.keySet()) { + TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId); + if (input == null) { + continue; + } + mConflicts.addAll(DvrScheduleManager.getConflictingSchedules( + new ArrayList<>(mInputScheduleMap.get(inputId).values()), + input.getTunerCount())); + } + } + + /** + * Adds a scheduled recording to the map, it happens when user undo cancel. + */ + private void addScheduledRecordingToMap(ScheduledRecording recording) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, + recording.getChannelId()); + if (input == null) { + return; + } + String inputId = input.getId(); + HashMap<Long, ScheduledRecording> schedulesMap = mInputScheduleMap.get(inputId); + if (schedulesMap == null) { + schedulesMap = new HashMap<>(); + mInputScheduleMap.put(inputId, schedulesMap); + } + schedulesMap.put(recording.getId(), recording); + } + + /** + * Called when a scheduled recording is added into dvr date manager. + */ + public void onScheduledRecordingAdded(ScheduledRecording recording) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED || recording + .getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + addScheduledRecordingToMap(recording); + updateConflicts(); + } + } + + /** + * Adds a scheduled recording to the map, it happens when user undo cancel. + */ + private void updateScheduledRecordingToMap(ScheduledRecording recording) { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED || + recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, + recording.getChannelId()); + if (input == null) { + return; + } + String inputId = input.getId(); + HashMap<Long, ScheduledRecording> schedulesMap = mInputScheduleMap.get(inputId); + if (schedulesMap == null) { + addScheduledRecordingToMap(recording); + return; + } + schedulesMap.put(recording.getId(), recording); + } else { + removeScheduledRecordingFromMap(recording); + } + } + + /** + * Called when a scheduled recording is updated in dvr date manager. + */ + public void onScheduledRecordingUpdated(ScheduledRecording recording) { + updateScheduledRecordingToMap(recording); + updateConflicts(); + } + + /** + * Removes a scheduled recording from the map, it happens when user cancel schedule. + */ + private void removeScheduledRecordingFromMap(ScheduledRecording recording) { + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, recording.getChannelId()); + if (input == null) { + return; + } + String inputId = input.getId(); + HashMap<Long, ScheduledRecording> schedulesMap = mInputScheduleMap.get(inputId); + if (schedulesMap == null) { + return; + } + schedulesMap.remove(recording.getId()); + if (schedulesMap.isEmpty()) { + mInputScheduleMap.remove(inputId); + } + } + + /** + * Called when a scheduled recording is removed from dvr date manager. + */ + public void onScheduledRecordingRemoved(ScheduledRecording recording) { + removeScheduledRecordingFromMap(recording); + updateConflicts(); + } + + /** + * Called when user click Info in {@link ScheduleRow}. + */ + protected void onInfoClicked(ScheduleRow scheduleRow) { + DvrUiHelper.startDetailsActivity((Activity) mContext, + scheduleRow.getRecording(), null, true); + } + + /** + * Called when user click schedule in {@link ScheduleRow}. + */ + protected void onScheduleClicked(ScheduleRow scheduleRow) { + ScheduledRecording scheduledRecording = scheduleRow.getRecording(); + TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, + scheduledRecording.getChannelId()); + if (input == null) { + return; + } + List<ScheduledRecording> allScheduledRecordings = new ArrayList<ScheduledRecording>( + mInputScheduleMap.get(input.getId()).values()); + long maxPriority = scheduledRecording.getPriority(); + for (ScheduledRecording recording : allScheduledRecordings) { + if (scheduledRecording.isOverLapping( + new Range<>(recording.getStartTimeMs(), recording.getEndTimeMs()))) { + if (maxPriority < recording.getPriority()) { + maxPriority = recording.getPriority(); + } + } + } + TvApplication.getSingletons(getContext()).getDvrManager() + .updateScheduledRecording(ScheduledRecording.buildFrom(scheduledRecording) + .setPriority(maxPriority + 1).build()); + updateConflicts(); + } + + /** + * Called when user click delete in {@link ScheduleRow}. + */ + protected void onDeleteClicked(ScheduleRow scheduleRow, ViewHolder vh) { + ScheduledRecording recording = scheduleRow.getRecording(); + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + if (!scheduleRow.isRemoveScheduleChecked()) { + if (mConflicts.contains(recording)) { + TvApplication.getSingletons(mContext) + .getDvrManager().removeScheduledRecording(recording); + } + + if (recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + viewHolder.mDeleteActionView.setImageDrawable(mOnAirDrawable); + // TODO: Replace an icon whose size is the same as scheudle. + } else { + viewHolder.getDeleteActionView().setImageDrawable(mScheduleDrawable); + } + viewHolder.greyOutInfo(); + scheduleRow.setRemoveScheduleChecked(true); + CharSequence deletedInfo = viewHolder.getProgramTitleView().getText(); + if (TextUtils.isEmpty(deletedInfo)) { + deletedInfo = viewHolder.getChannelNameView().getText(); + } + Toast.makeText(mContext, mContext.getResources() + .getString(R.string.dvr_schedules_deletion_info, deletedInfo), + Toast.LENGTH_SHORT).show(); + removeScheduledRecordingFromMap(recording); + } else { + if (recording.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + viewHolder.mDeleteActionView.setImageDrawable(mBeingRecordedDrawable); + // TODO: Replace an icon whose size is the same as scheudle. + } else { + viewHolder.getDeleteActionView().setImageDrawable(mCancelDrawable); + } + viewHolder.whiteBackInfo(); + scheduleRow.setRemoveScheduleChecked(false); + addScheduledRecordingToMap(recording); + } + updateConflicts(); + for (ScheduleRowClickListener l : mListeners) { + l.onDeleteClicked(scheduleRow); + } + } + + /** + * Adds {@link ScheduleRowClickListener}. + */ + public void addListener(ScheduleRowClickListener scheduleRowClickListener) { + mListeners.add(scheduleRowClickListener); + } + + /** + * Removes {@link ScheduleRowClickListener}. + */ + public void removeListener(ScheduleRowClickListener + scheduleRowClickListener) { + mListeners.remove(scheduleRowClickListener); + } + + @Override + protected void onRowViewSelected(ViewHolder vh, boolean selected) { + super.onRowViewSelected(vh, selected); + onRowViewSelectedInternal(vh, selected); + } + + /** + * Internal method for onRowViewSelected, can be customized by subclass. + */ + protected void onRowViewSelectedInternal(ViewHolder vh, boolean selected) { + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + boolean isRecordingConflicting = mConflicts.contains(viewHolder.mRecording); + if (selected) { + viewHolder.mDeleteActionContainer.setVisibility(View.VISIBLE); + if (isRecordingConflicting) { + viewHolder.mScheduleActionContainer.setVisibility(View.VISIBLE); + } + } else { + viewHolder.mDeleteActionContainer.setVisibility(View.GONE); + if (isRecordingConflicting) { + viewHolder.mScheduleActionContainer.setVisibility(View.GONE); + } + } + } + + /** + * A listener for clicking {@link ScheduleRow}. + */ + public interface ScheduleRowClickListener{ + /** + * To notify other observers that delete button has been clicked. + */ + void onDeleteClicked(ScheduleRow scheduleRow); + } +} diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java new file mode 100644 index 00000000..d103a533 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.support.annotation.Nullable; + +import com.android.tv.dvr.SeriesRecording; + +/** + * A base class for the rows for schedules' header. + */ +public abstract class SchedulesHeaderRow { + private String mTitle; + private String mDescription; + private int mItemCount; + + public SchedulesHeaderRow(String title, String description, int itemCount) { + mTitle = title; + mItemCount = itemCount; + mDescription = description; + } + + /** + * Sets title. + */ + public void setTitle(String title) { + mTitle = title; + } + + /** + * Sets description. + */ + public void setDescription(String description) { + mDescription = description; + } + + /** + * Sets count of items. + */ + public void setItemCount(int itemCount) { + mItemCount = itemCount; + } + + /** + * Returns title. + */ + public String getTitle() { + return mTitle; + } + + /** + * Returns description. + */ + public String getDescription() { + return mDescription; + } + + /** + * Returns count of items. + */ + public int getItemCount() { + return mItemCount; + } + + /** + * The header row which represent the date. + */ + public static class DateHeaderRow extends SchedulesHeaderRow { + private long mDeadLineMs; + + public DateHeaderRow(String title, String description, int itemCount, long deadLineMs) { + super(title, description, itemCount); + mDeadLineMs = deadLineMs; + } + + /** + * Sets the latest time of the list which belongs to the header row. + */ + public void setDeadLineMs(long deadLineMs) { + mDeadLineMs = deadLineMs; + } + + /** + * Returns the latest time of the list which belongs to the header row. + */ + public long getDeadLineMs() { + return mDeadLineMs; + } + } + + /** + * The header row which represent the series recording. + */ + public static class SeriesRecordingHeaderRow extends SchedulesHeaderRow { + private SeriesRecording mSeries; + private boolean mCancelAllChecked; + + public SeriesRecordingHeaderRow(String title, String description, int itemCount, + SeriesRecording series) { + super(title, description, itemCount); + mSeries = series; + mCancelAllChecked = series.getState() == SeriesRecording.STATE_SERIES_CANCELED; + } + + /** + * Sets cancel all checked status. + */ + public void setCancelAllChecked(boolean checked) { + mCancelAllChecked = checked; + } + + /** + * Returns cancel all checked status. + */ + public boolean isCancelAllChecked() { + return mCancelAllChecked; + } + + /** + * Returns the series recording, it is for series schedules list. + */ + public SeriesRecording getSeriesRecording() { + return mSeries; + } + } +} diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java new file mode 100644 index 00000000..483962e7 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.list; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.v17.leanback.widget.RowPresenter; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnFocusChangeListener; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.TextView; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.ui.DvrSchedulesActivity; +import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; + +import java.util.Set; + +/** + * A base class for RowPresenter for {@link SchedulesHeaderRow} + */ +public abstract class SchedulesHeaderRowPresenter extends RowPresenter { + private Context mContext; + private Set<SchedulesHeaderRowListener> mListeners = new ArraySet<>(); + + public SchedulesHeaderRowPresenter(Context context) { + setHeaderPresenter(null); + setSelectEffectEnabled(false); + mContext = context; + } + + /** + * Returns the context. + */ + Context getContext() { + return mContext; + } + + /** + * Adds {@link SchedulesHeaderRowListener}. + */ + public void addListener(SchedulesHeaderRowListener listener) { + mListeners.add(listener); + } + + /** + * Removes {@link SchedulesHeaderRowListener}. + */ + public void removeListener(SchedulesHeaderRowListener listener) { + mListeners.remove(listener); + } + + void notifyUpdateAllScheduleRows() { + for (SchedulesHeaderRowListener listener : mListeners) { + listener.onUpdateAllScheduleRows(); + } + } + + /** + * A ViewHolder for {@link SchedulesHeaderRow}. + */ + public static class SchedulesHeaderRowViewHolder extends RowPresenter.ViewHolder { + private TextView mTitle; + private TextView mDescription; + + public SchedulesHeaderRowViewHolder(Context context, ViewGroup parent) { + super(LayoutInflater.from(context).inflate(R.layout.dvr_schedules_header, parent, + false)); + mTitle = (TextView) view.findViewById(R.id.header_title); + mDescription = (TextView) view.findViewById(R.id.header_description); + } + } + + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder viewHolder, Object item) { + super.onBindRowViewHolder(viewHolder, item); + SchedulesHeaderRowViewHolder headerViewHolder = (SchedulesHeaderRowViewHolder) viewHolder; + SchedulesHeaderRow header = (SchedulesHeaderRow) item; + headerViewHolder.mTitle.setText(header.getTitle()); + headerViewHolder.mDescription.setText(header.getDescription()); + } + + /** + * A presenter for {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}. + */ + public static class DateHeaderRowPresenter extends SchedulesHeaderRowPresenter { + public DateHeaderRowPresenter(Context context) { + super(context); + } + + @Override + protected ViewHolder createRowViewHolder(ViewGroup parent) { + return new DateHeaderRowViewHolder(getContext(), parent); + } + + /** + * A ViewHolder for + * {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}. + */ + public static class DateHeaderRowViewHolder extends SchedulesHeaderRowViewHolder { + public DateHeaderRowViewHolder(Context context, ViewGroup parent) { + super(context, parent); + } + } + } + + /** + * A presenter for {@link SeriesRecordingHeaderRow}. + */ + public static class SeriesRecordingHeaderRowPresenter extends SchedulesHeaderRowPresenter { + private final boolean mLtr; + private final Drawable mSettingsDrawable; + private final Drawable mCancelDrawable; + private final Drawable mResumeDrawable; + + private final String mSettingsInfo; + private final String mCancelAllInfo; + private final String mResumeInfo; + + public SeriesRecordingHeaderRowPresenter(Context context) { + super(context); + mLtr = context.getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_LTR; + mSettingsDrawable = context.getDrawable(R.drawable.ic_settings); + mCancelDrawable = context.getDrawable(R.drawable.ic_dvr_cancel_large); + mResumeDrawable = context.getDrawable(R.drawable.ic_record_start); + mSettingsInfo = context.getString(R.string.dvr_series_schedules_settings); + mCancelAllInfo = context.getString(R.string.dvr_series_schedules_cancel_all); + mResumeInfo = context.getString(R.string.dvr_series_schedules_resume); + } + + @Override + protected ViewHolder createRowViewHolder(ViewGroup parent) { + return new SeriesRecordingRowViewHolder(getContext(), parent); + } + + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder viewHolder, Object item) { + super.onBindRowViewHolder(viewHolder, item); + SeriesRecordingRowViewHolder headerViewHolder = + (SeriesRecordingRowViewHolder) viewHolder; + SeriesRecordingHeaderRow header = (SeriesRecordingHeaderRow) item; + headerViewHolder.mSeriesSettingsButton.setVisibility( + isSeriesScheduleCanceled(getContext(), header) ? View.INVISIBLE : View.VISIBLE); + headerViewHolder.mSeriesSettingsButton.setText(mSettingsInfo); + setTextDrawable(headerViewHolder.mSeriesSettingsButton, mSettingsDrawable); + if (header.isCancelAllChecked()) { + headerViewHolder.mTogglePauseButton.setText(mResumeInfo); + setTextDrawable(headerViewHolder.mTogglePauseButton, mResumeDrawable); + } else { + headerViewHolder.mTogglePauseButton.setText(mCancelAllInfo); + setTextDrawable(headerViewHolder.mTogglePauseButton, mCancelDrawable); + } + headerViewHolder.mSeriesSettingsButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + DvrUiHelper.startSeriesSettingsActivity(getContext(), + header.getSeriesRecording().getId()); + } + }); + headerViewHolder.mTogglePauseButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + if (!header.isCancelAllChecked()) { + DvrUiHelper.showCancelAllSeriesRecordingDialog((DvrSchedulesActivity) view + .getContext()); + } else { + if (isSeriesScheduleCanceled(getContext(), header)) { + TvApplication.getSingletons(getContext()).getDvrManager() + .updateSeriesRecording(SeriesRecording.buildFrom(header + .getSeriesRecording()).setState(SeriesRecording + .STATE_SERIES_NORMAL).build()); + } + header.setCancelAllChecked(false); + notifyUpdateAllScheduleRows(); + } + } + }); + } + + private void setTextDrawable(TextView textView, Drawable drawableStart) { + if (mLtr) { + textView.setCompoundDrawablesWithIntrinsicBounds(drawableStart, null, null, null); + } else { + textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawableStart, null); + } + } + + private static boolean isSeriesScheduleCanceled(Context context, + SeriesRecordingHeaderRow header) { + return TvApplication.getSingletons(context).getDvrDataManager() + .getSeriesRecording(header.getSeriesRecording().getId()).getState() + == SeriesRecording.STATE_SERIES_CANCELED; + } + + /** + * A ViewHolder for {@link SeriesRecordingHeaderRow}. + */ + public static class SeriesRecordingRowViewHolder extends SchedulesHeaderRowViewHolder { + private final TextView mSeriesSettingsButton; + private final TextView mTogglePauseButton; + private final boolean mLtr; + + private final View mSelector; + + private View mLastFocusedView; + public SeriesRecordingRowViewHolder(Context context, ViewGroup parent) { + super(context, parent); + mLtr = context.getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_LTR; + view.findViewById(R.id.button_container).setVisibility(View.VISIBLE); + mSeriesSettingsButton = (TextView) view.findViewById(R.id.series_settings); + mTogglePauseButton = (TextView) view.findViewById(R.id.series_toggle_pause); + mSelector = view.findViewById(R.id.selector); + OnFocusChangeListener onFocusChangeListener = new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + onIconFouseChange(view); + } + }; + mSeriesSettingsButton.setOnFocusChangeListener(onFocusChangeListener); + mTogglePauseButton.setOnFocusChangeListener(onFocusChangeListener); + } + + void onIconFouseChange(View focusedView) { + updateSelector(focusedView, mSelector); + } + + private void updateSelector(View focusedView, final View selectorView) { + int animationDuration = selectorView.getContext().getResources() + .getInteger(android.R.integer.config_shortAnimTime); + DecelerateInterpolator interpolator = new DecelerateInterpolator(); + + if (focusedView.hasFocus()) { + final ViewGroup.LayoutParams lp = selectorView.getLayoutParams(); + final int targetWidth = focusedView.getWidth(); + float targetTranslationX; + if (mLtr) { + targetTranslationX = focusedView.getLeft() - selectorView.getLeft(); + } else { + targetTranslationX = focusedView.getRight() - selectorView.getRight(); + } + + // if the selector is invisible, set the width and translation X directly - + // don't animate. + if (selectorView.getAlpha() == 0) { + selectorView.setTranslationX(targetTranslationX); + lp.width = targetWidth; + selectorView.requestLayout(); + } + + // animate the selector in and to the proper width and translation X. + final float deltaWidth = lp.width - targetWidth; + selectorView.animate().cancel(); + selectorView.animate().translationX(targetTranslationX).alpha(1f) + .setUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + // Set width to the proper width for this animation step. + lp.width = targetWidth + Math.round( + deltaWidth * (1f - animation.getAnimatedFraction())); + selectorView.requestLayout(); + } + }).setDuration(animationDuration).setInterpolator(interpolator).start(); + mLastFocusedView = focusedView; + } else if (mLastFocusedView == focusedView) { + selectorView.animate().cancel(); + selectorView.animate().alpha(0f).setDuration(animationDuration) + .setInterpolator(interpolator).start(); + mLastFocusedView = null; + } + } + } + } + + public interface SchedulesHeaderRowListener { + /** + * Updates all schedule rows. + */ + void onUpdateAllScheduleRows(); + } +} diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java new file mode 100644 index 00000000..8b162c54 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java @@ -0,0 +1,156 @@ +/* +* Copyright (C) 2016 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License +*/ + +package com.android.tv.dvr.ui.list; + +import android.content.Context; +import android.support.v17.leanback.widget.ClassPresenterSelector; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * An adapter for series schedule row. + */ +public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { + private static final String TAG = "SeriesScheduleRowAdapter"; + + private SeriesRecording mSeriesRecording; + + public SeriesScheduleRowAdapter(Context context, + ClassPresenterSelector classPresenterSelector, SeriesRecording seriesRecording) { + super(context, classPresenterSelector); + mSeriesRecording = seriesRecording; + } + + @Override + public void start() { + List<ScheduledRecording> recordings = TvApplication.getSingletons(getContext()) + .getDvrDataManager().getAvailableAndCanceledScheduledRecordings(); + List<ScheduledRecording> seriesScheduledRecordings = new ArrayList<>(); + if (mSeriesRecording == null) { + return; + } + for (ScheduledRecording recording : recordings) { + if (recording.getSeriesRecordingId() == mSeriesRecording.getId()) { + seriesScheduledRecordings.add(recording); + } + } + Collections.sort(seriesScheduledRecordings, + ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR); + int dayCountToLastRecording = 0; + if (!seriesScheduledRecordings.isEmpty()) { + long lastRecordingStartTimeMs = seriesScheduledRecordings + .get(seriesScheduledRecordings.size() - 1).getStartTimeMs(); + dayCountToLastRecording = Utils.computeDateDifference(System.currentTimeMillis(), + lastRecordingStartTimeMs) + 1; + } + SchedulesHeaderRow headerRow = new SeriesRecordingHeaderRow(mSeriesRecording.getTitle(), + getContext().getResources().getQuantityString( + R.plurals.dvr_series_schedules_header_description, dayCountToLastRecording, + dayCountToLastRecording), seriesScheduledRecordings.size(), mSeriesRecording); + add(headerRow); + for (ScheduledRecording recording : seriesScheduledRecordings) { + add(new ScheduleRow(recording, headerRow)); + } + } + + @Override + public void stop() { + SoftPreconditions.checkState(get(0) instanceof SchedulesHeaderRow, TAG, + "First row is not SchedulesHeaderRow"); + boolean cancelAll = size() > 0 && ((SeriesRecordingHeaderRow) get(0)).isCancelAllChecked(); + if (!cancelAll) { + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + for (int i = 0; i < size(); i++) { + if (get(i) instanceof ScheduleRow) { + ScheduleRow scheduleRow = (ScheduleRow) get(i); + if (scheduleRow.isRemoveScheduleChecked()) { + dvrManager.removeScheduledRecording(scheduleRow.getRecording()); + } + } + } + } + } + + @Override + protected void addScheduleRow(ScheduledRecording recording) { + if (recording != null && recording.getSeriesRecordingId() == mSeriesRecording.getId()) { + int index = 0; + for (; index < size(); index++) { + if (get(index) instanceof ScheduleRow) { + ScheduleRow scheduleRow = (ScheduleRow) get(index); + if (ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR.compare( + scheduleRow.getRecording(), recording) > 0) { + break; + } + } + } + SoftPreconditions.checkState(get(0) instanceof SchedulesHeaderRow, TAG, + "First row is not SchedulesHeaderRow"); + if (index == 0) { + index++; + } + SchedulesHeaderRow headerRow = (SchedulesHeaderRow) get(0); + headerRow.setItemCount(headerRow.getItemCount() + 1); + ScheduleRow addedRow = new ScheduleRow(recording, headerRow); + add(index, addedRow); + updateHeaderRowDescription(headerRow); + } + } + + @Override + protected void removeScheduleRow(ScheduleRow scheduleRow) { + if (scheduleRow != null) { + remove(scheduleRow); + SchedulesHeaderRow headerRow = scheduleRow.getHeaderRow(); + // Changes the count information of header which the removed row belongs to. + if (headerRow != null) { + headerRow.setItemCount(headerRow.getItemCount() - 1); + if (headerRow.getItemCount() == 0) { + // TODO: Add a emtpy view. + } else if (get(size() - 1) instanceof ScheduleRow) { + updateHeaderRowDescription(headerRow); + } + } + } + } + + @Override + protected boolean willBeKept(ScheduledRecording recording) { + return super.willBeKept(recording) + || recording.getState() == ScheduledRecording.STATE_RECORDING_CANCELED; + } + + private void updateHeaderRowDescription(SchedulesHeaderRow headerRow) { + int nextDays = Utils.computeDateDifference(System.currentTimeMillis(), + ((ScheduleRow) get(size() - 1)).getRecording().getStartTimeMs()) + 1; + headerRow.setDescription(getContext().getResources() + .getQuantityString(R.plurals.dvr_series_schedules_header_description, + nextDays, nextDays)); + replace(indexOf(headerRow), headerRow); + } +} diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java new file mode 100644 index 00000000..4f31528c --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java @@ -0,0 +1,117 @@ +/* +* Copyright (C) 2016 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License +*/ + +package com.android.tv.dvr.ui.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; +import com.android.tv.util.Utils; + +/** + * A RowPresenter for series schedule row. + */ +public class SeriesScheduleRowPresenter extends ScheduleRowPresenter { + private boolean mIsCancelAll; + private boolean mLtr; + + public SeriesScheduleRowPresenter(Context context) { + super(context); + mLtr = context.getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_LTR; + } + + public static class SeriesScheduleRowViewHolder extends ScheduleRowViewHolder { + public SeriesScheduleRowViewHolder(View view) { + super(view); + ViewGroup.LayoutParams lp = getTimeView().getLayoutParams(); + lp.width = view.getResources().getDimensionPixelSize( + R.dimen.dvr_series_schedules_item_time_width); + getTimeView().setLayoutParams(lp); + } + } + + @Override + protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) { + return new SeriesScheduleRowViewHolder(view); + } + + @Override + protected String onGetRecordingTimeText(ScheduledRecording recording) { + return Utils.getDurationString(getContext(), + recording.getStartTimeMs(), recording.getEndTimeMs(), false, true, true, 0); + } + + @Override + protected String onGetProgramInfoText(ScheduledRecording recording) { + if (recording != null) { + return recording.getEpisodeDisplayTitle(getContext()); + } + return null; + } + + @Override + protected void onBindRowViewHolderInternal(ScheduleRowViewHolder viewHolder, + ScheduleRow scheduleRow) { + mIsCancelAll = ((SeriesRecordingHeaderRow) scheduleRow.getHeaderRow()).isCancelAllChecked(); + boolean isConflicting = getConflicts().contains(scheduleRow.getRecording()); + if (mIsCancelAll || isConflicting || scheduleRow.isRemoveScheduleChecked()) { + viewHolder.greyOutInfo(); + } else { + viewHolder.whiteBackInfo(); + } + if (!mIsCancelAll && isConflicting) { + viewHolder.getProgramTitleView().setCompoundDrawablePadding(getContext() + .getResources().getDimensionPixelOffset( + R.dimen.dvr_schedules_warning_icon_padding)); + if (mLtr) { + viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds( + R.drawable.ic_warning_gray600_36dp, 0, 0, 0); + } else { + viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds( + 0, 0, R.drawable.ic_warning_gray600_36dp, 0); + } + } else { + viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + } + if (mIsCancelAll) { + viewHolder.getInfoContainer().setClickable(false); + viewHolder.getDeleteActionContainer().setVisibility(View.GONE); + } + } + + @Override + protected void onRowViewSelectedInternal(ViewHolder vh, boolean selected) { + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + if (!mIsCancelAll) { + if (selected) { + viewHolder.getDeleteActionContainer().setVisibility(View.VISIBLE); + } else { + viewHolder.getDeleteActionContainer().setVisibility(View.GONE); + } + } + } + + @Override + protected void onInfoClicked(ScheduleRow scheduleRow) { + DvrUiHelper.startSchedulesActivity(getContext(), scheduleRow.getRecording()); + } +} |