diff options
author | Youngsang Cho <youngsang@google.com> | 2016-10-31 15:28:42 -0700 |
---|---|---|
committer | Youngsang Cho <youngsang@google.com> | 2016-10-31 15:28:42 -0700 |
commit | 919e1ed7e914029a1a0054237d86dc7b19ced898 (patch) | |
tree | cb30cfbafd80e01d314868cdc36e783d39981119 /src/com/android/tv | |
parent | 2933fcfd17f59c086436b270e7c01f2afcd54aa5 (diff) | |
download | TV-919e1ed7e914029a1a0054237d86dc7b19ced898.tar.gz |
Sync to ub-tv-killing at 6f6e46557accb62c9548e4177d6005aa944dbf33
Change-Id: I873644d6d9d0110c981ef6075cb4019c16bbb94b
Diffstat (limited to 'src/com/android/tv')
280 files changed, 46705 insertions, 4729 deletions
diff --git a/src/com/android/tv/ApplicationSingletons.java b/src/com/android/tv/ApplicationSingletons.java index 5198f7fd..fd125d52 100644 --- a/src/com/android/tv/ApplicationSingletons.java +++ b/src/com/android/tv/ApplicationSingletons.java @@ -18,11 +18,15 @@ package com.android.tv; import com.android.tv.analytics.Analytics; import com.android.tv.analytics.Tracker; +import com.android.tv.config.RemoteConfig; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.DvrSessionManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.util.AccountHelper; import com.android.tv.util.TvInputManagerHelper; /** @@ -36,9 +40,15 @@ public interface ApplicationSingletons { DvrDataManager getDvrDataManager(); + DvrStorageStatusManager getDvrStorageStatusManager(); + + DvrScheduleManager getDvrScheduleManager(); + DvrManager getDvrManager(); - DvrSessionManager getDvrSessionManger(); + DvrWatchedPositionManager getDvrWatchedPositionManager(); + + InputSessionManager getInputSessionManager(); ProgramDataManager getProgramDataManager(); @@ -47,4 +57,8 @@ public interface ApplicationSingletons { TvInputManagerHelper getTvInputManagerHelper(); MainActivityWrapper getMainActivityWrapper(); + + AccountHelper getAccountHelper(); + + RemoteConfig getRemoteConfig(); } diff --git a/src/com/android/tv/Features.java b/src/com/android/tv/Features.java index 6a78b632..7e8e3689 100644 --- a/src/com/android/tv/Features.java +++ b/src/com/android/tv/Features.java @@ -18,17 +18,18 @@ package com.android.tv; import static com.android.tv.common.feature.EngOnlyFeature.ENG_ONLY_FEATURE; import static com.android.tv.common.feature.FeatureUtils.AND; +import static com.android.tv.common.feature.FeatureUtils.OFF; import static com.android.tv.common.feature.FeatureUtils.ON; import static com.android.tv.common.feature.FeatureUtils.OR; import android.content.Context; +import android.content.pm.PackageManager; import android.os.Build; import android.support.annotation.VisibleForTesting; import android.support.v4.os.BuildCompat; import com.android.tv.common.feature.Feature; import com.android.tv.common.feature.GServiceFeature; -import com.android.tv.common.feature.PackageVersionFeature; import com.android.tv.common.feature.PropertyFeature; import com.android.tv.util.PermissionUtils; @@ -56,52 +57,56 @@ public final class Features { public static final Feature EPG_SEARCH = new PropertyFeature("feature_tv_use_epg_search", false); - public static final Feature USB_TUNER = new Feature() { - - /** - * This is special handling just for USB Tuner. - * It does not require any N API's but relies on a improvements in N for AC3 support - * After release, change class to this to just be - * {@link BuildCompat#isAtLeastN()}. - */ + public static final Feature TUNER = new Feature() { @Override public boolean isEnabled(Context context) { + + // This is special handling just for USB Tuner. + // It does not require any N API's but relies on a improvements in N for AC3 support + // After release, change class to this to just be {@link BuildCompat#isAtLeastN()}. return Build.VERSION.SDK_INT > Build.VERSION_CODES.M || BuildCompat.isAtLeastN(); } }; - private static final String PLAY_STORE_PACKAGE_NAME = "com.android.vending"; - private static final int PLAY_STORE_ZIMA_VERSION_CODE = 80441186; - private static final Feature PLAY_STORE_LINK = - new PackageVersionFeature(PLAY_STORE_PACKAGE_NAME, PLAY_STORE_ZIMA_VERSION_CODE); - - public static final Feature ONBOARDING_PLAY_STORE = PLAY_STORE_LINK; - - /** - * A flag which indicates that the on-boarding experience is used or not. - * - * <p>See <a href="http://b/24070322">b/24070322</a> - */ - public static final Feature ONBOARDING_EXPERIENCE = ONBOARDING_PLAY_STORE; - private static final String GSERVICE_KEY_UNHIDE = "live_channels_unhide"; /** * A flag which indicates that LC app is unhidden even when there is no input. */ - public static final Feature UNHIDE = AND(ONBOARDING_EXPERIENCE, + public static final Feature UNHIDE = OR(new GServiceFeature(GSERVICE_KEY_UNHIDE, false), new Feature() { @Override public boolean isEnabled(Context context) { // If LC app runs as non-system app, we unhide the app. return !PermissionUtils.hasAccessAllEpg(context); } - })); + }); - @VisibleForTesting - public static Feature TEST_FEATURE = new PropertyFeature("test_feature", false); + public static final Feature PICTURE_IN_PICTURE = new Feature() { + private Boolean mEnabled; - public static final Feature FETCH_EPG = new PropertyFeature("live_channels_fetch_epg", false); + @Override + public boolean isEnabled(Context context) { + if (mEnabled == null) { + mEnabled = context.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_PICTURE_IN_PICTURE); + } + return mEnabled; + } + }; + + /** + * Enable a conflict dialog between currently watched channel and upcoming recording. + */ + public static final Feature SHOW_UPCOMING_CONFLICT_DIALOG = OFF; + + /** + * Use input blacklist to disable partner's tuner input. + */ + public static final Feature USE_PARTNER_INPUT_BLACKLIST = ON; + + @VisibleForTesting + public static final Feature TEST_FEATURE = new PropertyFeature("test_feature", false); private Features() { } diff --git a/src/com/android/tv/InputSessionManager.java b/src/com/android/tv/InputSessionManager.java new file mode 100644 index 00000000..e4b0f456 --- /dev/null +++ b/src/com/android/tv/InputSessionManager.java @@ -0,0 +1,549 @@ +/* + * 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; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputInfo; +import android.media.tv.TvRecordingClient; +import android.media.tv.TvRecordingClient.RecordingCallback; +import android.media.tv.TvTrackInfo; +import android.media.tv.TvView; +import android.media.tv.TvView.TvInputCallback; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.ArraySet; +import android.util.Log; + +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.ui.TunableTvView; +import com.android.tv.ui.TunableTvView.OnTuneListener; +import com.android.tv.util.TvInputManagerHelper; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Manages input sessions. + * Responsible for: + * <ul> + * <li>Manage {@link TvView} sessions and recording sessions</li> + * <li>Manage capabilities (conflict)</li> + * </ul> + * <p> + * As TvView's methods should be called on the main thread and the {@link RecordingSession} should + * look at the state of the {@link TvViewSession} when it calls the framework methods, the framework + * calls in RecordingSession are made on the main thread not to introduce the multi-thread problems. + */ +@TargetApi(Build.VERSION_CODES.N) +public class InputSessionManager { + private static final String TAG = "InputSessionManager"; + private static final boolean DEBUG = false; + + private final Context mContext; + private final TvInputManagerHelper mInputManager; + private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); + private final Set<TvViewSession> mTvViewSessions = new ArraySet<>(); + private final Set<RecordingSession> mRecordingSessions = + Collections.synchronizedSet(new ArraySet<>()); + private final Set<OnTvViewChannelChangeListener> mOnTvViewChannelChangeListeners = + new ArraySet<>(); + + public InputSessionManager(Context context) { + mContext = context.getApplicationContext(); + mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper(); + } + + /** + * Creates the session for {@link TvView}. + * <p> + * Do not call {@link TvView#setCallback} after the session is created. + */ + @MainThread + @NonNull + public TvViewSession createTvViewSession(TvView tvView, TunableTvView tunableTvView, + TvInputCallback callback) { + TvViewSession session = new TvViewSession(tvView, tunableTvView, callback); + mTvViewSessions.add(session); + if (DEBUG) Log.d(TAG, "TvView session created: " + session); + return session; + } + + /** + * Releases the {@link TvView} session. + */ + @MainThread + public void releaseTvViewSession(TvViewSession session) { + mTvViewSessions.remove(session); + session.reset(); + if (DEBUG) Log.d(TAG, "TvView session released: " + session); + } + + /** + * Creates the session for recording. + */ + @NonNull + public RecordingSession createRecordingSession(String inputId, String tag, + RecordingCallback callback, Handler handler, long endTimeMs) { + RecordingSession session = new RecordingSession(inputId, tag, callback, handler, endTimeMs); + mRecordingSessions.add(session); + if (DEBUG) Log.d(TAG, "Recording session created: " + session); + return session; + } + + /** + * Releases the recording session. + */ + public void releaseRecordingSession(RecordingSession session) { + mRecordingSessions.remove(session); + session.release(); + if (DEBUG) Log.d(TAG, "Recording session released: " + session); + } + + /** + * Adds the {@link OnTvViewChannelChangeListener}. + */ + @MainThread + public void addOnTvViewChannelChangeListener(OnTvViewChannelChangeListener listener) { + mOnTvViewChannelChangeListeners.add(listener); + } + + /** + * Removes the {@link OnTvViewChannelChangeListener}. + */ + @MainThread + public void removeOnTvViewChannelChangeListener(OnTvViewChannelChangeListener listener) { + mOnTvViewChannelChangeListeners.remove(listener); + } + + @MainThread + void notifyTvViewChannelChange(Uri channelUri) { + for (OnTvViewChannelChangeListener l : mOnTvViewChannelChangeListeners) { + l.onTvViewChannelChange(channelUri); + } + } + + /** + * Returns the current {@link TvView} channel. + */ + @MainThread + public Uri getCurrentTvViewChannelUri() { + for (TvViewSession session : mTvViewSessions) { + if (session.mTuned) { + return session.mChannelUri; + } + } + return null; + } + + /** + * Retruns the earliest end time of recording sessions in progress of the certain TV input. + */ + @MainThread + public Long getEarliestRecordingSessionEndTimeMs(String inputId) { + long timeMs = Long.MAX_VALUE; + synchronized (mRecordingSessions) { + for (RecordingSession session : mRecordingSessions) { + if (session.mTuned && TextUtils.equals(inputId, session.mInputId)) { + if (session.mEndTimeMs < timeMs) { + timeMs = session.mEndTimeMs; + } + } + } + } + return timeMs == Long.MAX_VALUE ? null : timeMs; + } + + @MainThread + int getTunedTvViewSessionCount(String inputId) { + int tunedCount = 0; + for (TvViewSession session : mTvViewSessions) { + if (session.mTuned && Objects.equals(inputId, session.mInputId)) { + ++tunedCount; + } + } + return tunedCount; + } + + @MainThread + boolean isTunedForTvView(Uri channelUri) { + for (TvViewSession session : mTvViewSessions) { + if (session.mTuned && Objects.equals(channelUri, session.mChannelUri)) { + return true; + } + } + return false; + } + + int getTunedRecordingSessionCount(String inputId) { + synchronized (mRecordingSessions) { + int tunedCount = 0; + for (RecordingSession session : mRecordingSessions) { + if (session.mTuned && Objects.equals(inputId, session.mInputId)) { + ++tunedCount; + } + } + return tunedCount; + } + } + + boolean isTunedForRecording(Uri channelUri) { + synchronized (mRecordingSessions) { + for (RecordingSession session : mRecordingSessions) { + if (session.mTuned && Objects.equals(channelUri, session.mChannelUri)) { + return true; + } + } + return false; + } + } + + /** + * The session for {@link TvView}. + * <p> + * The methods which create or release session for the TV input should be called through this + * session. + */ + @MainThread + public class TvViewSession { + private final TvView mTvView; + private final TunableTvView mTunableTvView; + private final TvInputCallback mCallback; + private Channel mChannel; + private String mInputId; + private Uri mChannelUri; + private Bundle mParams; + private OnTuneListener mOnTuneListener; + private boolean mTuned; + private boolean mNeedToBeRetuned; + + TvViewSession(TvView tvView, TunableTvView tunableTvView, TvInputCallback callback) { + mTvView = tvView; + mTunableTvView = tunableTvView; + mCallback = callback; + mTvView.setCallback(new DelegateTvInputCallback(mCallback) { + @Override + public void onConnectionFailed(String inputId) { + if (DEBUG) Log.d(TAG, "TvViewSession: commection failed"); + mTuned = false; + mNeedToBeRetuned = false; + super.onConnectionFailed(inputId); + notifyTvViewChannelChange(null); + } + + @Override + public void onDisconnected(String inputId) { + if (DEBUG) Log.d(TAG, "TvViewSession: disconnected"); + mTuned = false; + mNeedToBeRetuned = false; + super.onDisconnected(inputId); + notifyTvViewChannelChange(null); + } + }); + } + + /** + * Tunes to the channel. + * <p> + * As this is called only for the warming up, there's no need to be retuned. + */ + public void tune(String inputId, Uri channelUri) { + if (DEBUG) { + Log.d(TAG, "warm-up tune: {input=" + inputId + ", channelUri=" + channelUri + "}"); + } + mInputId = inputId; + mChannelUri = channelUri; + mTuned = true; + mNeedToBeRetuned = false; + mTvView.tune(inputId, channelUri); + notifyTvViewChannelChange(channelUri); + } + + /** + * Tunes to the channel. + */ + public void tune(Channel channel, Bundle params, OnTuneListener listener) { + if (DEBUG) { + Log.d(TAG, "tune: {session=" + this + ", channel=" + channel + ", params=" + params + + ", listener=" + listener + ", mTuned=" + mTuned + "}"); + } + mChannel = channel; + mInputId = channel.getInputId(); + mChannelUri = channel.getUri(); + mParams = params; + mOnTuneListener = listener; + TvInputInfo input = mInputManager.getTvInputInfo(mInputId); + if (input == null || (input.canRecord() && !isTunedForRecording(mChannelUri) + && getTunedRecordingSessionCount(mInputId) >= input.getTunerCount())) { + if (DEBUG) { + if (input == null) { + Log.d(TAG, "Can't find input for input ID: " + mInputId); + } else { + Log.d(TAG, "No more tuners to tune for input: " + input); + } + } + mCallback.onConnectionFailed(mInputId); + // Release the previous session to not to hold the unnecessary session. + resetByRecording(); + return; + } + mTuned = true; + mNeedToBeRetuned = false; + mTvView.tune(mInputId, mChannelUri, params); + notifyTvViewChannelChange(mChannelUri); + } + + void retune() { + if (DEBUG) Log.d(TAG, "Retune requested."); + if (mNeedToBeRetuned) { + if (DEBUG) Log.d(TAG, "Retuning: {channel=" + mChannel + "}"); + mTunableTvView.tuneTo(mChannel, mParams, mOnTuneListener); + mNeedToBeRetuned = false; + } + } + + /** + * Plays a given recorded TV program. + * + * @see TvView#timeShiftPlay + */ + public void timeShiftPlay(String inputId, Uri recordedProgramUri) { + mTuned = false; + mNeedToBeRetuned = false; + mTvView.timeShiftPlay(inputId, recordedProgramUri); + notifyTvViewChannelChange(null); + } + + /** + * Resets this TvView. + */ + public void reset() { + if (DEBUG) Log.d(TAG, "Reset TvView session"); + mTuned = false; + mTvView.reset(); + mNeedToBeRetuned = false; + notifyTvViewChannelChange(null); + } + + void resetByRecording() { + mCallback.onVideoUnavailable(mInputId, + TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE); + if (mTuned) { + if (DEBUG) Log.d(TAG, "Reset TvView session by recording"); + mTunableTvView.resetByRecording(); + reset(); + } + mNeedToBeRetuned = true; + } + } + + /** + * The session for recording. + * <p> + * The caller is responsible for releasing the session when the error occurs. + */ + public class RecordingSession { + private final String mInputId; + private Uri mChannelUri; + private final RecordingCallback mCallback; + private final Handler mHandler; + private volatile long mEndTimeMs; + private TvRecordingClient mClient; + private boolean mTuned; + + RecordingSession(String inputId, String tag, RecordingCallback callback, + Handler handler, long endTimeMs) { + mInputId = inputId; + mCallback = callback; + mHandler = handler; + mClient = new TvRecordingClient(mContext, tag, callback, handler); + mEndTimeMs = endTimeMs; + } + + void release() { + if (DEBUG) Log.d(TAG, "Release of recording session requested."); + runOnHandler(mMainThreadHandler, new Runnable() { + @Override + public void run() { + if (DEBUG) Log.d(TAG, "Releasing of recording session."); + mTuned = false; + mClient.release(); + mClient = null; + for (TvViewSession session : mTvViewSessions) { + if (DEBUG) { + Log.d(TAG, "Finding TvView sessions for retune: {tuned=" + + session.mTuned + ", inputId=" + session.mInputId + + ", session=" + session + "}"); + } + if (!session.mTuned && Objects.equals(session.mInputId, mInputId)) { + session.retune(); + break; + } + } + } + }); + } + + /** + * Tunes to the channel for recording. + */ + public void tune(String inputId, Uri channelUri) { + runOnHandler(mMainThreadHandler, new Runnable() { + @Override + public void run() { + int tunedRecordingSessionCount = getTunedRecordingSessionCount(inputId); + TvInputInfo input = mInputManager.getTvInputInfo(inputId); + if (input == null || !input.canRecord() + || input.getTunerCount() <= tunedRecordingSessionCount) { + runOnHandler(mHandler, new Runnable() { + @Override + public void run() { + mCallback.onConnectionFailed(inputId); + } + }); + return; + } + mTuned = true; + int tunedTuneSessionCount = getTunedTvViewSessionCount(inputId); + if (!isTunedForTvView(channelUri) && tunedTuneSessionCount > 0 + && tunedRecordingSessionCount + tunedTuneSessionCount + >= input.getTunerCount()) { + for (TvViewSession session : mTvViewSessions) { + if (session.mTuned && Objects.equals(session.mInputId, inputId) + && !isTunedForRecording(session.mChannelUri)) { + session.resetByRecording(); + break; + } + } + } + mChannelUri = channelUri; + mClient.tune(inputId, channelUri); + } + }); + } + + /** + * Starts recording. + */ + public void startRecording(Uri programHintUri) { + mClient.startRecording(programHintUri); + } + + /** + * Stops recording. + */ + public void stopRecording() { + mClient.stopRecording(); + } + + /** + * Sets recording session's ending time. + */ + public void setEndTimeMs(long endTimeMs) { + mEndTimeMs = endTimeMs; + } + + private void runOnHandler(Handler handler, Runnable runnable) { + if (Looper.myLooper() == handler.getLooper()) { + runnable.run(); + } else { + handler.post(runnable); + } + } + } + + private static class DelegateTvInputCallback extends TvInputCallback { + private final TvInputCallback mDelegate; + + DelegateTvInputCallback(TvInputCallback delegate) { + mDelegate = delegate; + } + + @Override + public void onConnectionFailed(String inputId) { + mDelegate.onConnectionFailed(inputId); + } + + @Override + public void onDisconnected(String inputId) { + mDelegate.onDisconnected(inputId); + } + + @Override + public void onChannelRetuned(String inputId, Uri channelUri) { + mDelegate.onChannelRetuned(inputId, channelUri); + } + + @Override + public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) { + mDelegate.onTracksChanged(inputId, tracks); + } + + @Override + public void onTrackSelected(String inputId, int type, String trackId) { + mDelegate.onTrackSelected(inputId, type, trackId); + } + + @Override + public void onVideoSizeChanged(String inputId, int width, int height) { + mDelegate.onVideoSizeChanged(inputId, width, height); + } + + @Override + public void onVideoAvailable(String inputId) { + mDelegate.onVideoAvailable(inputId); + } + + @Override + public void onVideoUnavailable(String inputId, int reason) { + mDelegate.onVideoUnavailable(inputId, reason); + } + + @Override + public void onContentAllowed(String inputId) { + mDelegate.onContentAllowed(inputId); + } + + @Override + public void onContentBlocked(String inputId, TvContentRating rating) { + mDelegate.onContentBlocked(inputId, rating); + } + + @Override + public void onTimeShiftStatusChanged(String inputId, int status) { + mDelegate.onTimeShiftStatusChanged(inputId, status); + } + } + + /** + * Called when the {@link TvView} channel is changed. + */ + public interface OnTvViewChannelChangeListener { + void onTvViewChannelChange(@Nullable Uri channelUri); + } +} diff --git a/src/com/android/tv/MainActivity.java b/src/com/android/tv/MainActivity.java index 78fda42a..58850b5f 100644 --- a/src/com/android/tv/MainActivity.java +++ b/src/com/android/tv/MainActivity.java @@ -25,10 +25,10 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; -import android.graphics.PixelFormat; import android.graphics.Point; import android.hardware.display.DisplayManager; import android.media.AudioManager; @@ -44,6 +44,7 @@ import android.media.tv.TvInputManager.TvInputCallback; import android.media.tv.TvTrackInfo; import android.media.tv.TvView.OnUnhandledInputEventListener; import android.net.Uri; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -53,8 +54,8 @@ import android.provider.Settings; import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; import android.text.TextUtils; +import android.util.ArraySet; import android.util.Log; import android.view.Display; import android.view.Gravity; @@ -62,6 +63,7 @@ import android.view.InputEvent; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowManager; import android.view.accessibility.AccessibilityEvent; @@ -80,7 +82,7 @@ import com.android.tv.common.TvCommonUtils; import com.android.tv.common.TvContentRatingCache; import com.android.tv.common.WeakHandler; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.common.ui.setup.OnActionClickListener; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.OnCurrentProgramUpdatedListener; @@ -88,12 +90,16 @@ import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; import com.android.tv.data.StreamInfo; import com.android.tv.data.WatchedHistoryManager; +import com.android.tv.data.epg.EpgFetcher; import com.android.tv.dialog.PinDialogFragment; import com.android.tv.dialog.SafeDismissDialogFragment; -import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.ConflictChecker; import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.DvrPlayActivity; +import com.android.tv.dvr.DvrUiHelper; import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.DvrStopRecordingFragment; +import com.android.tv.dvr.ui.HalfSizedDialogFragment; +import com.android.tv.experiments.Experiments; import com.android.tv.menu.Menu; import com.android.tv.onboarding.OnboardingActivity; import com.android.tv.parental.ContentRatingsManager; @@ -101,24 +107,28 @@ import com.android.tv.parental.ParentalControlSettings; import com.android.tv.receiver.AudioCapabilitiesReceiver; import com.android.tv.recommendation.NotificationService; import com.android.tv.search.ProgramGuideSearchFragment; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.setup.TunerSetupActivity; +import com.android.tv.tuner.tvinput.TunerTvInputService; import com.android.tv.ui.AppLayerTvView; import com.android.tv.ui.ChannelBannerView; import com.android.tv.ui.InputBannerView; import com.android.tv.ui.KeypadChannelSwitchView; -import com.android.tv.ui.OverlayRootView; import com.android.tv.ui.SelectInputView; import com.android.tv.ui.SelectInputView.OnInputSelectedCallback; import com.android.tv.ui.TunableTvView; +import com.android.tv.ui.TunableTvView.BlockScreenType; import com.android.tv.ui.TunableTvView.OnTuneListener; import com.android.tv.ui.TvOverlayManager; import com.android.tv.ui.TvViewUiManager; import com.android.tv.ui.sidepanel.ClosedCaptionFragment; import com.android.tv.ui.sidepanel.CustomizeChannelListFragment; -import com.android.tv.ui.sidepanel.DebugOptionFragment; +import com.android.tv.ui.sidepanel.DeveloperOptionFragment; import com.android.tv.ui.sidepanel.DisplayModeFragment; import com.android.tv.ui.sidepanel.MultiAudioFragment; import com.android.tv.ui.sidepanel.SettingsFragment; import com.android.tv.ui.sidepanel.SideFragment; +import com.android.tv.util.AccountHelper; import com.android.tv.util.CaptionSettings; import com.android.tv.util.ImageCache; import com.android.tv.util.ImageLoader; @@ -133,9 +143,6 @@ import com.android.tv.util.SystemProperties; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.TvSettings; import com.android.tv.util.TvSettings.PipSound; -import com.android.usbtuner.UsbTunerPreferences; -import com.android.usbtuner.setup.TunerSetupActivity; -import com.android.usbtuner.tvinput.UsbTunerTvInputService; import com.android.tv.util.TvTrackInfoUtils; import com.android.tv.util.Utils; @@ -146,12 +153,14 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; /** * The main activity for the Live TV app. */ -public class MainActivity extends Activity implements AudioManager.OnAudioFocusChangeListener { +public class MainActivity extends Activity implements AudioManager.OnAudioFocusChangeListener, + OnActionClickListener { private static final String TAG = "MainActivity"; private static final boolean DEBUG = false; @@ -177,12 +186,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private static final int PERMISSIONS_REQUEST_READ_TV_LISTINGS = 1; + private static final int PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION = 2; private static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS"; - private static final String USB_TV_TUNER_INPUT_ID = - "com.android.tv/com.android.usbtuner.tvinput.UsbTunerTvInputService"; - private static final String DVR_TEST_INPUT_ID = USB_TV_TUNER_INPUT_ID; - // Tracker screen names. public static final String SCREEN_NAME = "Main"; private static final String SCREEN_BEHIND_NAME = "Behind"; @@ -201,8 +207,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_VOLUME_MUTE); BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_MUTE); BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_SEARCH); + BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_WINDOW); } + private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; private static final int REQUEST_CODE_START_SYSTEM_CAPTIONING_SETTINGS = 2; @@ -226,10 +234,25 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST, UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO, UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK}) private @interface ChannelBannerUpdateReason {} + /** + * Updates channel banner because the channel banner is forced to show. + */ private static final int UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW = 1; + /** + * Updates channel banner because of tuning. + */ private static final int UPDATE_CHANNEL_BANNER_REASON_TUNE = 2; + /** + * Updates channel banner because of fast tuning. + */ private static final int UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST = 3; + /** + * Updates channel banner because of info updating. + */ private static final int UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO = 4; + /** + * Updates channel banner because the current watched channel is locked or unlocked. + */ private static final int UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK = 5; private static final int TVVIEW_SET_MAIN_TIMEOUT_MS = 3000; @@ -251,11 +274,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private final DurationTimer mMainDurationTimer = new DurationTimer(); private final DurationTimer mTuneDurationTimer = new DurationTimer(); private DvrManager mDvrManager; - private DvrDataManager mDvrDataManager; + private ConflictChecker mDvrConflictChecker; + private View mContentView; private TunableTvView mTvView; private TunableTvView mPipView; - private OverlayRootView mOverlayRootView; private Bundle mTuneParams; private boolean mChannelBannerHiddenBySideFragment; // TODO: Move the scene views into TvTransitionManager or TvOverlayManager. @@ -295,8 +318,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private boolean mVisibleBehind; private boolean mAc3PassthroughSupported; private boolean mShowNewSourcesFragment = true; - private Uri mRecordingUri; - private String mUsbTunerInputId; + private String mTunerInputId; private boolean mOtherActivityLaunched; private boolean mIsFilmModeSet; @@ -332,6 +354,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private String mSource; private final Handler mHandler = new MainActivityHandler(this); + private final Set<OnActionClickListener> mOnActionClickListeners = new ArraySet<>(); private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { @Override @@ -390,12 +413,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC resumePipIfNeeded(); } mKeypadChannelSwitchView.setChannels(mChannelTuner.getBrowsableChannelList()); - mHandler.post(new Runnable() { - @Override - public void run() { - mOverlayManager.getMenu().setChannelTuner(mChannelTuner); - } - }); } @Override @@ -422,15 +439,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC }; private ProgramGuideSearchFragment mSearchFragment; - private TvInputCallback mTvInputCallback = new TvInputCallback() { + private final TvInputCallback mTvInputCallback = new TvInputCallback() { @Override public void onInputAdded(String inputId) { - if (mUsbTunerInputId.equals(inputId) - && UsbTunerPreferences.shouldShowSetupActivity(MainActivity.this)) { + if (Features.TUNER.isEnabled(MainActivity.this) && mTunerInputId.equals(inputId) + && TunerPreferences.shouldShowSetupActivity(MainActivity.this)) { Intent intent = TunerSetupActivity.createSetupActivity(MainActivity.this); startActivity(intent); - UsbTunerPreferences.setShouldShowSetupActivity(MainActivity.this, false); - SetupUtils.getInstance(MainActivity.this).markAsKnownInput(mUsbTunerInputId); + TunerPreferences.setShouldShowSetupActivity(MainActivity.this, false); + SetupUtils.getInstance(MainActivity.this).markAsKnownInput(mTunerInputId); } } }; @@ -445,17 +462,14 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override protected void onCreate(Bundle savedInstanceState) { if (DEBUG) Log.d(TAG,"onCreate()"); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M - && !PermissionUtils.hasAccessAllEpg(this)) { - Toast.makeText(this, R.string.msg_not_supported_device, Toast.LENGTH_LONG).show(); - finish(); - return; - } - boolean skipToShowOnboarding = getIntent().getAction() == Intent.ACTION_VIEW - && TvContract.isChannelUriForPassthroughInput(getIntent().getData()); - if (Features.ONBOARDING_EXPERIENCE.isEnabled(this) - && OnboardingUtils.needToShowOnboarding(this) && !skipToShowOnboarding + + boolean isPassthroughInput = TvContract.isChannelUriForPassthroughInput(getIntent() + .getData()); + boolean skipToShowOnboarding = Intent.ACTION_VIEW.equals(getIntent().getAction()) + && isPassthroughInput; + if (OnboardingUtils.needToShowOnboarding(this) && !skipToShowOnboarding && !TvCommonUtils.isRunningInTest()) { // TODO: The onboarding is turned off in test, because tests are broken by the // onboarding. We need to enable the feature for tests later. @@ -464,31 +478,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return; } - TvApplication tvApplication = (TvApplication) getApplication(); - tvApplication.getMainActivityWrapper().onMainActivityCreated(this); - if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) { - Toast.makeText(this, "Using Strict Mode for eng builds", Toast.LENGTH_SHORT).show(); - } - mTracker = tvApplication.getTracker(); - mTvInputManagerHelper = tvApplication.getTvInputManagerHelper(); - mTvInputManagerHelper.addCallback(mTvInputCallback); - mUsbTunerInputId = UsbTunerTvInputService.getInputId(this); - mChannelDataManager = tvApplication.getChannelDataManager(); - mProgramDataManager = tvApplication.getProgramDataManager(); - mProgramDataManager.addOnCurrentProgramUpdatedListener(Channel.INVALID_ID, - mOnCurrentProgramUpdatedListener); - mProgramDataManager.setPrefetchEnabled(true); - mChannelTuner = new ChannelTuner(mChannelDataManager, mTvInputManagerHelper); - mChannelTuner.addListener(mChannelTunerListener); - mChannelTuner.start(); - mPipInputManager = new PipInputManager(this, mTvInputManagerHelper, mChannelTuner); - mPipInputManager.start(); - mMemoryManageables.add(mProgramDataManager); - mMemoryManageables.add(ImageCache.getInstance()); - mMemoryManageables.add(TvContentRatingCache.getInstance()); - if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) { - mDvrManager = tvApplication.getDvrManager(); - mDvrDataManager = tvApplication.getDvrDataManager(); + // Check this permission for the EPG fetch. + // TODO: check {@link shouldShowRequestPermissionRationale}. + // While testing, no way to allow the permission when the dialog shows up. So we'd better + // not show the dialog. + if (!TvCommonUtils.isRunningInTest() && Utils.hasInternalTvInputs(this, true) + && checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION}, + PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION); } DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); @@ -499,9 +497,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC int screenHeight = size.y; mDefaultRefreshRate = display.getRefreshRate(); - mOverlayRootView = (OverlayRootView) getLayoutInflater().inflate( - R.layout.overlay_root_view, null, false); setContentView(R.layout.activity_tv); + mContentView = findViewById(android.R.id.content); mTvView = (TunableTvView) findViewById(R.id.main_tunable_tv_view); int shrunkenTvViewHeight = getResources().getDimensionPixelSize( R.dimen.shrunken_tvview_height); @@ -529,6 +526,41 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return false; } }); + + long channelId = Utils.getLastWatchedChannelId(this); + String inputId = Utils.getLastWatchedTunerInputId(this); + if (!isPassthroughInput && inputId != null + && channelId != Channel.INVALID_ID) { + mTvView.warmUpInput(inputId, TvContract.buildChannelUri(channelId)); + } + + TvApplication tvApplication = (TvApplication) getApplication(); + tvApplication.getMainActivityWrapper().onMainActivityCreated(this); + if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) { + Toast.makeText(this, "Using Strict Mode for eng builds", Toast.LENGTH_SHORT).show(); + } + mTracker = tvApplication.getTracker(); + mTvInputManagerHelper = tvApplication.getTvInputManagerHelper(); + if (Features.TUNER.isEnabled(this)) { + mTvInputManagerHelper.addCallback(mTvInputCallback); + } + mTunerInputId = TunerTvInputService.getInputId(this); + mChannelDataManager = tvApplication.getChannelDataManager(); + mProgramDataManager = tvApplication.getProgramDataManager(); + mProgramDataManager.addOnCurrentProgramUpdatedListener(Channel.INVALID_ID, + mOnCurrentProgramUpdatedListener); + mProgramDataManager.setPrefetchEnabled(true); + mChannelTuner = new ChannelTuner(mChannelDataManager, mTvInputManagerHelper); + mChannelTuner.addListener(mChannelTunerListener); + mChannelTuner.start(); + mPipInputManager = new PipInputManager(this, mTvInputManagerHelper, mChannelTuner); + mPipInputManager.start(); + mMemoryManageables.add(mProgramDataManager); + mMemoryManageables.add(ImageCache.getInstance()); + mMemoryManageables.add(TvContentRatingCache.getInstance()); + if (CommonFeatures.DVR.isEnabled(this)) { + mDvrManager = tvApplication.getDvrManager(); + } mTimeShiftManager = new TimeShiftManager(this, mTvView, mProgramDataManager, mTracker, new OnCurrentProgramUpdatedListener() { @Override @@ -542,6 +574,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC updateChannelBannerAndShowIfNeeded( UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); break; + case TimeShiftManager.TIME_SHIFT_ACTION_ID_PAUSE: + case TimeShiftManager.TIME_SHIFT_ACTION_ID_PLAY: default: updateChannelBannerAndShowIfNeeded( UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); @@ -587,7 +621,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } @Override - public void onPassthroughInputSelected(TvInputInfo input) { + public void onPassthroughInputSelected(@NonNull TvInputInfo input) { Channel currentChannel = mChannelTuner.getCurrentChannel(); String currentInputId = currentChannel == null ? null : currentChannel.getInputId(); if (TextUtils.equals(input.getId(), currentInputId)) { @@ -607,7 +641,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } }); mSearchFragment = new ProgramGuideSearchFragment(); - mOverlayManager = new TvOverlayManager(this, mChannelTuner, + mOverlayManager = new TvOverlayManager(this, mChannelTuner, mTvView, mKeypadChannelSwitchView, mChannelBannerView, inputBannerView, selectInputView, sceneContainer, mSearchFragment); @@ -617,7 +651,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mMediaSession = new MediaSession(this, MEDIA_SESSION_TAG); mMediaSession.setCallback(new MediaSession.Callback() { @Override - public boolean onMediaButtonEvent(Intent mediaButtonIntent) { + public boolean onMediaButtonEvent(@NonNull Intent mediaButtonIntent) { // Consume the media button event here. Should not send it to other apps. return true; } @@ -653,49 +687,54 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // To avoid not updating Rating systems when changing language. mTvInputManagerHelper.getContentRatingsManager().update(); - - initForTest(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, - int[] grantResults) { - if (requestCode == PERMISSIONS_REQUEST_READ_TV_LISTINGS) { - if (grantResults != null && grantResults.length > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Start reload of dependent data - mChannelDataManager.reload(); - mProgramDataManager.reload(); - - // Restart live channels. - Intent intent = getIntent(); - finish(); - startActivity(intent); - } else { - Toast.makeText(this, R.string.msg_read_tv_listing_permission_denied, - Toast.LENGTH_LONG).show(); - finish(); - } + if (CommonFeatures.DVR.isEnabled(this) + && Features.SHOW_UPCOMING_CONFLICT_DIALOG.isEnabled(this)) { + mDvrConflictChecker = new ConflictChecker(this); } + initForTest(); } @Override - public void onAttachedToWindow() { - super.onAttachedToWindow(); - WindowManager.LayoutParams windowParams = new WindowManager.LayoutParams( - WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL, 0, PixelFormat.TRANSPARENT); - windowParams.token = getWindow().getDecorView().getWindowToken(); - ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).addView(mOverlayRootView, - windowParams); + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + float density = getResources().getDisplayMetrics().density; + mTvViewUiManager.onConfigurationChanged((int) (newConfig.screenWidthDp * density), + (int) (newConfig.screenHeightDp * density)); } @Override - public void onDetachedFromWindow() { - super.onDetachedFromWindow(); - ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).removeView(mOverlayRootView); + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + switch (requestCode) { + case PERMISSIONS_REQUEST_READ_TV_LISTINGS: + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Start reload of dependent data + mChannelDataManager.reload(); + mProgramDataManager.reload(); + + // Restart live channels. + Intent intent = getIntent(); + finish(); + startActivity(intent); + } else { + Toast.makeText(this, R.string.msg_read_tv_listing_permission_denied, + Toast.LENGTH_LONG).show(); + finish(); + } + break; + case PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION: + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED + && Experiments.CLOUD_EPG.get()) { + EpgFetcher.getInstance(this).startImmediately(); + } else { + EpgFetcher.getInstance(this).stop(); + } + break; + } } - private int getDesiredBlockScreenType() { + @BlockScreenType private int getDesiredBlockScreenType() { if (!mActivityResumed) { return TunableTvView.BLOCK_SCREEN_TYPE_NO_UI; } @@ -727,6 +766,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override protected void onNewIntent(Intent intent) { + if (DEBUG) Log.d(TAG,"onNewIntent(): " + intent); + if (mOverlayManager == null) { + // It's called before onCreate. The intent will be handled at onCreate. b/30725058 + return; + } mOverlayManager.getSideFragmentManager().hideAll(false); if (!handleIntent(intent) && !mActivityStarted) { // If the activity is stopped and not destroyed, finish the activity. @@ -760,6 +804,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC protected void onResume() { if (DEBUG) Log.d(TAG, "onResume()"); super.onResume(); + // Refresh the remote config, it is throttled automatically. + TvApplication.getSingletons(this).getRemoteConfig().fetch(null); if (!PermissionUtils.hasAccessAllEpg(this) && checkSelfPermission(PERMISSION_READ_TV_LISTINGS) != PackageManager.PERMISSION_GRANTED) { @@ -784,6 +830,16 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // visible behind. requestVisibleBehind(true); } + if (Utils.hasRecordingFailedReason(getApplicationContext(), + TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE)) { + runAfterAttachedToWindow(new Runnable() { + @Override + public void run() { + DvrUiHelper.showDvrInsufficientSpaceErrorDialog(MainActivity.this); + } + }); + } + if (mChannelTuner.areAllChannelsLoaded()) { SetupUtils.getInstance(this).markNewChannelsBrowsable(); resumeTvIfNeeded(); @@ -822,11 +878,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } }); } + if (mDvrConflictChecker != null) { + mDvrConflictChecker.start(); + } } @Override protected void onPause() { if (DEBUG) Log.d(TAG, "onPause()"); + if (mDvrConflictChecker != null) { + mDvrConflictChecker.stop(); + } finishChannelChangeIfNeeded(); mActivityResumed = false; mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_DEFAULT); @@ -882,7 +944,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (input == null) { input = mTvInputManagerHelper.getTvInputInfo(mParentInputIdWhenScreenOff); if (input == null) { - SoftPreconditions.checkState(false, TAG, "Input disappear." + input); + SoftPreconditions.checkState(false, TAG, "Input disappear."); finish(); } else { mInitChannelUri = @@ -929,16 +991,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC TAG, "startTV assumes that ChannelDataManager is already loaded."); if (mTvView.isPlaying()) { // TV has already started. - if (channelUri == null) { + if (channelUri == null || channelUri.equals(mChannelTuner.getCurrentChannelUri())) { // Simply adjust the volume without tune. setVolumeByAudioFocusStatus(); return; } - if (channelUri.equals(mChannelTuner.getCurrentChannelUri())) { - // The requested channel is already tuned. - setVolumeByAudioFocusStatus(); - return; - } stopTv(); } if (mChannelTuner.getCurrentChannel() != null) { @@ -972,12 +1029,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mTvView.start(mTvInputManagerHelper); setVolumeByAudioFocusStatus(); - if (mRecordingUri != null) { - playRecording(mRecordingUri); - mRecordingUri = null; - } else { - tune(); - } + tune(); } @Override @@ -1116,16 +1168,19 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mOverlayManager; } + /** + * Returns the {@link ConflictChecker}. + */ + @Nullable + public ConflictChecker getDvrConflictChecker() { + return mDvrConflictChecker; + } + public Channel getCurrentChannel() { - return mTvView.isRecordingPlayback() ? mTvView.getCurrentChannel() - : mChannelTuner.getCurrentChannel(); + return mChannelTuner.getCurrentChannel(); } public long getCurrentChannelId() { - if (mTvView.isRecordingPlayback()) { - Channel channel = mTvView.getCurrentChannel(); - return channel == null ? Channel.INVALID_ID : channel.getId(); - } return mChannelTuner.getCurrentChannelId(); } @@ -1139,34 +1194,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC /** * Returns the current program which the user is watching right now.<p> * - * If the time shifting is available, it can be a past program. + * It might be a live program. If the time shifting is available, it can be a past program, too. */ public Program getCurrentProgram() { - return getCurrentProgram(true); - } - - /** - * Returns {@code true}, if this view is the recording playback mode. - */ - public boolean isRecordingPlayback() { - return mTvView.isRecordingPlayback(); - } - - /** - * Returns the recording which is being played right now. - */ - public RecordedProgram getPlayingRecordedProgram() { - return mTvView.getPlayingRecordedProgram(); - } - - /** - * Returns the current program which the user is watching right now.<p> - * - * @param applyTimeShifted If it is true and the time shifting is available, it can be - * a past program. - */ - public Program getCurrentProgram(boolean applyTimeShifted) { - if (applyTimeShifted && mTimeShiftManager.isAvailable()) { + if (!isChannelChangeKeyDownReceived() && mTimeShiftManager.isAvailable()) { + // We shouldn't get current program from TimeShiftManager during channel tunning return mTimeShiftManager.getCurrentProgram(); } return mProgramDataManager.getCurrentProgram(getCurrentChannelId()); @@ -1185,7 +1217,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return System.currentTimeMillis(); } - public Channel getBrowsableChannel() { + private Channel getBrowsableChannel() { // TODO: mChannelMap could be dirty for a while when the browsablity of channels // are changed. In that case, we shouldn't use the value from mChannelMap. Channel curChannel = mChannelTuner.getCurrentChannel(); @@ -1228,7 +1260,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } public void showMerchantCollection() { - startActivitySafe(OnboardingUtils.PLAY_STORE_INTENT); + startActivitySafe(OnboardingUtils.ONLINE_STORE_INTENT); } /** @@ -1353,15 +1385,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } @Override - public View findViewById(int id) { - // In order to locate fragments in non-application window, we should override findViewById. - // Internally, Activity.findViewById is called to attach a view of a fragment into its - // container. Without the override, we'll get crash during the fragment attachment. - View v = mOverlayRootView != null ? mOverlayRootView.findViewById(id) : null; - return v == null ? super.findViewById(id) : v; - } - - @Override public boolean dispatchKeyEvent(KeyEvent event) { if (SystemProperties.LOG_KEYEVENT.getValue()) Log.d(TAG, "dispatchKeyEvent(" + event + ")"); // If an activity is closed on a back key down event, back key down events with none zero @@ -1381,8 +1404,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // When side panel is closing, it has the focus. // Keep the focus, but just don't deliver the key events. - if ((mOverlayRootView.hasFocusable() - && !mOverlayManager.getSideFragmentManager().isHiding()) + if ((mContentView.hasFocusable() && !mOverlayManager.getSideFragmentManager().isHiding()) || mOverlayManager.getSideFragmentManager().isActive()) { return super.dispatchKeyEvent(event); } @@ -1454,13 +1476,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) { - mRecordingUri = intent.getParcelableExtra(Utils.EXTRA_KEY_RECORDING_URI); - if (mRecordingUri != null) { - return true; - } - } - // TODO: remove the checkState once N API is finalized. SoftPreconditions.checkState(TvInputManager.ACTION_SETUP_INPUTS.equals( "android.media.tv.action.SETUP_INPUTS")); @@ -1709,7 +1724,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC /** * Says {@code text} when accessibility is turned on. */ - public void sendAccessibilityText(String text) { + private void sendAccessibilityText(String text) { if (mAccessibilityManager.isEnabled()) { AccessibilityEvent event = AccessibilityEvent.obtain(); event.setClassName(getClass().getName()); @@ -1720,17 +1735,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - private void playRecording(Uri recordingUri) { - mTvView.playRecording(recordingUri, mOnTuneListener); - mOnTuneListener.onPlayRecording(); - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_TUNE); - } - private void tune() { if (DEBUG) Log.d(TAG, "tune()"); mTuneDurationTimer.start(); - lazyInitializeIfNeeded(LAZY_INITIALIZATION_DELAY); + lazyInitializeIfNeeded(); // Prerequisites to be able to tune. if (mInputIdUnderSetup != null) { @@ -1742,7 +1751,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!mChannelTuner.isCurrentChannelPassthrough()) { if (mTvInputManagerHelper.getTunerTvInputSize() == 0) { Toast.makeText(this, R.string.msg_no_input, Toast.LENGTH_SHORT).show(); - // TODO: Direct the user to a Play Store landing page for TvInputService apps. finish(); return; } @@ -1755,9 +1763,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } if (mChannelDataManager.getChannelCount() > 0) { mOverlayManager.showIntroDialog(); - } else if (!Features.ONBOARDING_EXPERIENCE.isEnabled(this)) { - mOverlayManager.showSetupFragment(); - return; } } if (!TvCommonUtils.isRunningInTest() && mShowNewSourcesFragment @@ -1821,23 +1826,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mLastAllowedRatingForCurrentChannel = null; } mHandler.removeMessages(MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE); - if (mAccessibilityManager.isEnabled()) { - // For every tune, we need to inform the tuned channel or input to a user, - // if Talkback is turned on. - AccessibilityEvent event = AccessibilityEvent.obtain(); - event.setClassName(getClass().getName()); - event.setPackageName(getPackageName()); - event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); - if (TvContract.isChannelUriForPassthroughInput(channel.getUri())) { - TvInputInfo input = mTvInputManagerHelper.getTvInputInfo(channel.getInputId()); - event.getText().add(Utils.loadLabel(this, input)); - } else if (TextUtils.isEmpty(channel.getDisplayName())) { - event.getText().add(channel.getDisplayNumber()); - } else { - event.getText().add(channel.getDisplayNumber() + " " + channel.getDisplayName()); - } - mAccessibilityManager.sendAccessibilityEvent(event); - } + // For every tune, we need to inform the tuned channel or input to a user, + // if Talkback is turned on. + sendAccessibilityText(!mChannelTuner.isCurrentChannelPassthrough() ? + Utils.loadLabel(this, mTvInputManagerHelper.getTvInputInfo(channel.getInputId())) + : channel.getDisplayText()); boolean success = mTvView.tuneTo(channel, mTuneParams, mOnTuneListener); mOnTuneListener.onTune(channel, isUnderShrunkenTvView()); @@ -1870,20 +1863,35 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC updateMediaSession(); } + // Runs the runnable after the activity is attached to window to show the fragment transition + // animation. + // The runnable runs asynchronously to show the animation a little better even when system is + // busy at the moment it is called. + // If the activity is paused shortly, runnable may not be called because all the fragments + // should be closed when the activity is paused. private void runAfterAttachedToWindow(final Runnable runnable) { - if (mOverlayRootView.isLaidOut()) { - runnable.run(); - } else { - mOverlayRootView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(View v) { - mOverlayRootView.removeOnAttachStateChangeListener(this); + final Runnable runOnlyIfActivityIsResumed = new Runnable() { + @Override + public void run() { + if (mActivityResumed) { runnable.run(); } + } + }; + if (mContentView.isAttachedToWindow()) { + mHandler.post(runOnlyIfActivityIsResumed); + } else { + mContentView.getViewTreeObserver().addOnWindowAttachListener( + new ViewTreeObserver.OnWindowAttachListener() { + @Override + public void onWindowAttached() { + mContentView.getViewTreeObserver().removeOnWindowAttachListener(this); + mHandler.post(runOnlyIfActivityIsResumed); + } - @Override - public void onViewDetachedFromWindow(View v) { } - }); + @Override + public void onWindowDetached() { } + }); } } @@ -1905,82 +1913,108 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return; } - final Program program = getCurrentProgram(); - String cardTitleText = program == null ? null : program.getTitle(); + final Program currentProgram = getCurrentProgram(); + String cardTitleText = null; + String posterArtUri = null; + if (currentProgram != null) { + cardTitleText = currentProgram.getTitle(); + posterArtUri = currentProgram.getPosterArtUri(); + } if (TextUtils.isEmpty(cardTitleText)) { - cardTitleText = getCurrentChannel().getDisplayName(); + cardTitleText = getCurrentChannelName(); } updateMediaMetadata(cardTitleText, null); setMediaSessionPlaybackState(true); - if (program != null && program.getPosterArtUri() != null) { - program.loadPosterArt(MainActivity.this, mNowPlayingCardWidth, mNowPlayingCardHeight, - createProgramPosterArtCallback(MainActivity.this, program)); - } else { - updateMediaMetadataWithAlternativeArt(program); + if (posterArtUri == null) { + posterArtUri = TvContract.buildChannelLogoUri(getCurrentChannelId()).toString(); } - + updatePosterArt(getCurrentChannel(), currentProgram, cardTitleText, null, posterArtUri); mMediaSession.setActive(true); } - private static ImageLoader.ImageLoaderCallback<MainActivity> createProgramPosterArtCallback( - MainActivity mainActivity, final Program program) { - return new ImageLoader.ImageLoaderCallback<MainActivity>(mainActivity) { - @Override - public void onBitmapLoaded(MainActivity mainActivity, @Nullable Bitmap posterArt) { - if (program != mainActivity.getCurrentProgram() - || mainActivity.getCurrentChannel() == null) { - return; - } - mainActivity.updateProgramPosterArt(program, posterArt); - } - }; - } - - private void updateProgramPosterArt(Program program, @Nullable Bitmap posterArt) { - if (getCurrentChannel() == null) { - return; - } + private void updatePosterArt(Channel currentChannel, Program currentProgram, + String cardTitleText, @Nullable Bitmap posterArt, @Nullable String posterArtUri) { if (posterArt != null) { - String cardTitleText = program == null ? null : program.getTitle(); - if (TextUtils.isEmpty(cardTitleText)) { - cardTitleText = getCurrentChannel().getDisplayName(); - } updateMediaMetadata(cardTitleText, posterArt); + } else if (posterArtUri != null) { + ImageLoader.loadBitmap(this, posterArtUri, mNowPlayingCardWidth, mNowPlayingCardHeight, + new ProgramPosterArtCallback(this, currentChannel, + currentProgram, cardTitleText)); } else { - updateMediaMetadataWithAlternativeArt(program); + updateMediaMetadata(cardTitleText, R.drawable.default_now_card); } } - private void updateMediaMetadata(String title, Bitmap posterArt) { - MediaMetadata.Builder builder = new MediaMetadata.Builder(); - builder.putString(MediaMetadata.METADATA_KEY_TITLE, title); - if (posterArt != null) { - builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt); + private static class ProgramPosterArtCallback extends + ImageLoader.ImageLoaderCallback<MainActivity> { + private final Channel mChannel; + private final Program mProgram; + private final String mCardTitleText; + + public ProgramPosterArtCallback(MainActivity mainActivity, Channel channel, Program program, + String cardTitleText) { + super(mainActivity); + mChannel = channel; + mProgram = program; + mCardTitleText = cardTitleText; } - mMediaSession.setMetadata(builder.build()); + + @Override + public void onBitmapLoaded(MainActivity mainActivity, @Nullable Bitmap posterArt) { + if (mainActivity.isNowPlayingProgram(mChannel, mProgram)) { + mainActivity.updatePosterArt(mChannel, mProgram, mCardTitleText, posterArt, null); + } + } + } + + private boolean isNowPlayingProgram(Channel channel, Program program) { + return program == null ? (channel != null && getCurrentProgram() == null + && channel.equals(getCurrentChannel())) : program.equals(getCurrentProgram()); } - private void updateMediaMetadataWithAlternativeArt(final Program program) { + private void updateMediaMetadata(final String title, final Bitmap posterArt) { + new AsyncTask<Void, Void, Void> () { + @Override + protected Void doInBackground(Void... arg0) { + MediaMetadata.Builder builder = new MediaMetadata.Builder(); + builder.putString(MediaMetadata.METADATA_KEY_TITLE, title); + if (posterArt != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt); + } + mMediaSession.setMetadata(builder.build()); + return null; + } + }.execute(); + } + + private void updateMediaMetadata(final String title, final int imageResId) { + new AsyncTask<Void, Void, Void> () { + @Override + protected Void doInBackground(Void... arg0) { + MediaMetadata.Builder builder = new MediaMetadata.Builder(); + builder.putString(MediaMetadata.METADATA_KEY_TITLE, title); + Bitmap posterArt = BitmapFactory.decodeResource(getResources(), imageResId); + if (posterArt != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt); + } + mMediaSession.setMetadata(builder.build()); + return null; + } + }.execute(); + } + + private String getCurrentChannelName() { Channel channel = getCurrentChannel(); - if (channel == null || program != getCurrentProgram()) { - return; + if (channel == null) { + return ""; } - - String cardTitleText; if (channel.isPassthrough()) { TvInputInfo input = getTvInputManagerHelper().getTvInputInfo(channel.getInputId()); - cardTitleText = Utils.loadLabel(this, input); + return Utils.loadLabel(this, input); } else { - cardTitleText = program == null ? null : program.getTitle(); - if (TextUtils.isEmpty(cardTitleText)) { - cardTitleText = channel.getDisplayName(); - } + return channel.getDisplayName(); } - - Bitmap posterArt = BitmapFactory.decodeResource( - getResources(), R.drawable.default_now_card); - updateMediaMetadata(cardTitleText, posterArt); } private void setMediaSessionPlaybackState(boolean isPlaying) { @@ -2055,7 +2089,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private void updateChannelBannerAndShowIfNeeded(@ChannelBannerUpdateReason int reason) { if(DEBUG) Log.d(TAG, "updateChannelBannerAndShowIfNeeded(reason=" + reason + ")"); - if (!mChannelTuner.isCurrentChannelPassthrough() || mTvView.isRecordingPlayback()) { + if (!mChannelTuner.isCurrentChannelPassthrough()) { int lockType = ChannelBannerView.LOCK_NONE; if (mTvView.isScreenBlocked()) { lockType = ChannelBannerView.LOCK_CHANNEL_INFO; @@ -2261,6 +2295,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override protected void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy()"); + SideFragment.releasePreloadedRecycledViews(); + if (mTvView != null) { + mTvView.release(); + } + if (mPipView != null) { + mPipView.release(); + } if (mChannelTuner != null) { mChannelTuner.removeListener(mChannelTunerListener); mChannelTuner.stop(); @@ -2299,7 +2340,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mChannelStatusRecurringRunner.stop(); mChannelStatusRecurringRunner = null; } - if (mTvInputManagerHelper != null) { + if (mTvInputManagerHelper != null && Features.TUNER.isEnabled(this)) { mTvInputManagerHelper.removeCallback(mTvInputCallback); } super.onDestroy(); @@ -2333,9 +2374,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC case KeyEvent.KEYCODE_DPAD_UP: if (event.getRepeatCount() == 0 && mChannelTuner.getBrowsableChannelCount() > 0) { - moveToAdjacentChannel(true, false); + // message sending should be done before moving channel, because we use the + // existence of message to decide if users are switching channel. mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHANNEL_UP_PRESSED, System.currentTimeMillis()), CHANNEL_CHANGE_INITIAL_DELAY_MILLIS); + moveToAdjacentChannel(true, false); mTracker.sendChannelUp(); } return true; @@ -2343,9 +2386,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC case KeyEvent.KEYCODE_DPAD_DOWN: if (event.getRepeatCount() == 0 && mChannelTuner.getBrowsableChannelCount() > 0) { - moveToAdjacentChannel(false, false); + // message sending should be done before moving channel, because we use the + // existence of message to decide if users are switching channel. mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHANNEL_DOWN_PRESSED, System.currentTimeMillis()), CHANNEL_CHANGE_INITIAL_DELAY_MILLIS); + moveToAdjacentChannel(false, false); mTracker.sendChannelDown(); } return true; @@ -2362,14 +2407,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC * A KEYCODE_MEDIA_AUDIO_TRACK * D debug: show debug options * E updateChannelBannerAndShowIfNeeded + * G debug: refresh cloud epg * I KEYCODE_TV_INPUT * O debug: show display mode option * P debug: togglePipView * S KEYCODE_CAPTIONS: select subtitle * W debug: toggle screen size * V KEYCODE_MEDIA_RECORD debug: record the current channel for 30 sec - * X KEYCODE_BUTTON_X KEYCODE_PROG_BLUE debug: record current channel for a few minutes - * Y KEYCODE_BUTTON_Y KEYCODE_PROG_GREEN debug: Play a recording */ if (SystemProperties.LOG_KEYEVENT.getValue()) { Log.d(TAG, "onKeyUp(" + keyCode + ", " + event + ")"); @@ -2428,6 +2472,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } switch (keyCode) { case KeyEvent.KEYCODE_DPAD_RIGHT: + if (!mTvView.isVideoAvailable() + && mTvView.getVideoUnavailableReason() + == TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE) { + DvrUiHelper.startSchedulesActivityForTuneConflict(this, + mChannelTuner.getCurrentChannel()); + return true; + } if (!PermissionUtils.hasModifyParentalControls(this)) { // TODO: support this feature for non-system LC app. b/23939816 return true; @@ -2464,7 +2515,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC false); } return true; - + case KeyEvent.KEYCODE_WINDOW: + enterPictureInPictureMode(); + return true; case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_NUMPAD_ENTER: case KeyEvent.KEYCODE_E: @@ -2481,8 +2534,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); } if (keyCode != KeyEvent.KEYCODE_E) { - mOverlayManager.showMenu(mTvView.isRecordingPlayback() - ? Menu.REASON_RECORDING_PLAYBACK : Menu.REASON_NONE); + mOverlayManager.showMenu(Menu.REASON_NONE); } return true; case KeyEvent.KEYCODE_CHANNEL_UP: @@ -2515,9 +2567,62 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mOverlayManager.showBanner(); return true; } + case KeyEvent.KEYCODE_MEDIA_RECORD: + case KeyEvent.KEYCODE_V: { + Channel currentChannel = getCurrentChannel(); + if (currentChannel != null && mDvrManager != null) { + boolean isRecording = + mDvrManager.getCurrentRecording(currentChannel.getId()) != null; + if (!isRecording) { + if (!mDvrManager.isChannelRecordable(currentChannel)) { + Toast.makeText(this, R.string.dvr_msg_cannot_record_program, + Toast.LENGTH_SHORT).show(); + } else { + if (!DvrUiHelper.checkStorageStatusAndShowErrorMessage(this, + currentChannel.getInputId())) { + return true; + } + Program program = mProgramDataManager + .getCurrentProgram(currentChannel.getId()); + if (program == null) { + DvrUiHelper + .showChannelRecordDurationOptions(this, currentChannel); + } else if (DvrUiHelper.handleCreateSchedule(this, program)) { + String msg = getString( + R.string.dvr_msg_current_program_scheduled, + program.getTitle(), Utils.toTimeString( + program.getEndTimeUtcMillis(), false)); + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); + } + } + } else { + DvrUiHelper.showStopRecordingDialog(this, currentChannel.getId(), + DvrStopRecordingFragment.REASON_USER_STOP, + new HalfSizedDialogFragment.OnActionClickListener() { + @Override + public void onActionClick(long actionId) { + if (actionId == DvrStopRecordingFragment.ACTION_STOP) { + ScheduledRecording currentRecording = + mDvrManager.getCurrentRecording( + currentChannel.getId()); + if (currentRecording != null) { + mDvrManager.stopRecording(currentRecording); + } + } + } + }); + } + } + return true; + } } } - if (SystemProperties.USE_DEBUG_KEYS.getValue()) { + if (keyCode == KeyEvent.KEYCODE_WINDOW) { + // Consumes the PIP button to prevent entering PIP mode + // in case that TV isn't showing properly (e.g. no browsable channel) + return true; + } + if (SystemProperties.USE_DEBUG_KEYS.getValue() || BuildConfig.ENG) { switch (keyCode) { case KeyEvent.KEYCODE_W: { mDebugNonFullSizeScreen = !mDebugNonFullSizeScreen; @@ -2551,57 +2656,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mOverlayManager.getSideFragmentManager().show(new DisplayModeFragment()); return true; } - case KeyEvent.KEYCODE_D: - mOverlayManager.getSideFragmentManager().show(new DebugOptionFragment()); - return true; - - case KeyEvent.KEYCODE_MEDIA_RECORD: // TODO(DVR) handle with debug_keys set - case KeyEvent.KEYCODE_V: { - DvrManager dvrManager = TvApplication.getSingletons(this).getDvrManager(); - long startTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5); - long endTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(35); - dvrManager.addSchedule(getCurrentChannel(), startTime, endTime); + mOverlayManager.getSideFragmentManager().show(new DeveloperOptionFragment()); return true; - } - case KeyEvent.KEYCODE_PROG_BLUE: - case KeyEvent.KEYCODE_BUTTON_X: - case KeyEvent.KEYCODE_X: { - if (CommonFeatures.DVR.isEnabled(this)) { - Channel channel = mTvView.getCurrentChannel(); - long channelId = channel.getId(); - Program p = mProgramDataManager.getCurrentProgram(channelId); - if (p == null) { - long now = System.currentTimeMillis(); - mDvrManager - .addSchedule(channel, now, now + TimeUnit.MINUTES.toMillis(1)); - } else { - mDvrManager.addSchedule(p, - mDvrManager.getScheduledRecordingsThatConflict(p)); - } - return true; - } - } - case KeyEvent.KEYCODE_PROG_YELLOW: - case KeyEvent.KEYCODE_BUTTON_Y: - case KeyEvent.KEYCODE_Y: { - if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) { - // TODO(DVR) only get finished recordings. - List<RecordedProgram> recordedPrograms = mDvrDataManager - .getRecordedPrograms(); - Log.d(TAG, "Found " + recordedPrograms.size() + " recordings"); - if (recordedPrograms.isEmpty()) { - Toast.makeText(this, "No finished recording to play", Toast.LENGTH_LONG) - .show(); - } else { - RecordedProgram r = recordedPrograms.get(0); - Intent intent = new Intent(this, DvrPlayActivity.class); - intent.putExtra(ScheduledRecording.RECORDING_ID_EXTRA, r.getId()); - startActivity(intent); - } - return true; - } - } } } return super.onKeyUp(keyCode, event); @@ -2661,6 +2718,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC }); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (!hasFocus) { + finishChannelChangeIfNeeded(); + } + } + public void togglePipView() { enablePipView(!mPipEnabled, true); mOverlayManager.getMenu().update(); @@ -2681,7 +2745,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC startPip(true); } - public void enablePipView(boolean enable, boolean fromUserInteraction) { + private void enablePipView(boolean enable, boolean fromUserInteraction) { if (enable == mPipEnabled) { return; } @@ -2774,7 +2838,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mChannelTuner.isCurrentChannelPassthrough() && !mPipEnabled; } - public void tuneToLastWatchedChannelForTunerInput() { + private void tuneToLastWatchedChannelForTunerInput() { if (!mChannelTuner.isCurrentChannelPassthrough()) { return; } @@ -2930,14 +2994,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } @Override - public void startActivity(Intent intent) { - mOtherActivityLaunched = true; - super.startActivity(intent); - } - - @Override public void startActivityForResult(Intent intent, int requestCode) { mOtherActivityLaunched = true; + if (intent.getCategories() == null + || !intent.getCategories().contains(Intent.CATEGORY_HOME)) { + // Workaround b/30150267 + requestVisibleBehind(false); + } super.startActivityForResult(intent, requestCode); } @@ -2949,7 +3012,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mTvView.getSelectedTrack(type); } - public void selectTrack(int type, TvTrackInfo track) { + private void selectTrack(int type, TvTrackInfo track) { mTvView.selectTrack(type, track == null ? null : track.getId()); if (type == TvTrackInfo.TYPE_AUDIO) { mTvOptionsManager.onMultiAudioChanged(track == null ? null : @@ -3021,6 +3084,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC int stringId; switch (info.getVideoUnavailableReason()) { case TunableTvView.VIDEO_UNAVAILABLE_REASON_NOT_TUNED: + case TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE: case TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING: case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING: case TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY: @@ -3050,6 +3114,31 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mCaptionSettings; } + /** + * Adds the {@link OnActionClickListener}. + */ + public void addOnActionClickListener(OnActionClickListener listener) { + mOnActionClickListeners.add(listener); + } + + /** + * Removes the {@link OnActionClickListener}. + */ + public void removeOnActionClickListener(OnActionClickListener listener) { + mOnActionClickListeners.remove(listener); + } + + @Override + public boolean onActionClick(String category, int actionId, Bundle params) { + // There should be only one action listener per an action. + for (OnActionClickListener l : mOnActionClickListeners) { + if (l.onActionClick(category, actionId, params)) { + return true; + } + } + return false; + } + // Initialize TV app for test. The setup process should be finished before the Live TV app is // started. We only enable all the channels here. private void initForTest() { @@ -3061,7 +3150,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } // Lazy initialization - private void lazyInitializeIfNeeded(long delay) { + private void lazyInitializeIfNeeded() { // Already initialized. if (mLazyInitialized) { return; @@ -3071,10 +3160,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mHandler.postDelayed(new Runnable() { @Override public void run() { - initAnimations(); - initSideFragments(); + if (mActivityStarted) { + initAnimations(); + initSideFragments(); + } } - }, delay); + }, LAZY_INITIALIZATION_DELAY); } private void initAnimations() { @@ -3104,13 +3195,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC switch (msg.what) { case MSG_CHANNEL_DOWN_PRESSED: long startTime = (Long) msg.obj; - mainActivity.moveToAdjacentChannel(false, true); + // message re-sending should be done before moving channel, because we use the + // existence of message to decide if users are switching channel. sendMessageDelayed(Message.obtain(msg), getDelay(startTime)); + mainActivity.moveToAdjacentChannel(false, true); break; case MSG_CHANNEL_UP_PRESSED: startTime = (Long) msg.obj; - mainActivity.moveToAdjacentChannel(true, true); + // message re-sending should be done before moving channel, because we use the + // existence of message to decide if users are switching channel. sendMessageDelayed(Message.obtain(msg), getDelay(startTime)); + mainActivity.moveToAdjacentChannel(true, true); break; case MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE: mainActivity.updateChannelBannerAndShowIfNeeded( @@ -3142,13 +3237,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mWasUnderShrunkenTvView = wasUnderShrukenTvView; } - private void onPlayRecording() { - mStreamInfoUpdateTimeThresholdMs = - System.currentTimeMillis() + FIRST_STREAM_INFO_UPDATE_DELAY_MILLIS; - mChannel = null; - mWasUnderShrunkenTvView = false; - } - @Override public void onUnexpectedStop(Channel channel) { stopTv(); @@ -3157,8 +3245,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override public void onTuneFailed(Channel channel) { - Log.w(TAG, "Failed to tune to channel " + channel.getId() - + "@" + channel.getInputId()); + Log.w(TAG, "onTuneFailed(" + channel + ")"); if (mTvView.isFadedOut()) { mTvView.removeFadeEffect(); } @@ -3232,7 +3319,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mUnlockAllowedRatingBeforeShrunken = isUnderShrunkenTvView(); mTvView.unblockContent(rating); } - + mChannelBannerView.setBlockingContentRating(rating); updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); mTvViewUiManager.fadeInTvView(); } @@ -3242,6 +3329,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!isUnderShrunkenTvView()) { mUnlockAllowedRatingBeforeShrunken = false; } + mChannelBannerView.setBlockingContentRating(null); updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); } } diff --git a/src/com/android/tv/MainActivityWrapper.java b/src/com/android/tv/MainActivityWrapper.java index 82e96d14..01733255 100644 --- a/src/com/android/tv/MainActivityWrapper.java +++ b/src/com/android/tv/MainActivityWrapper.java @@ -19,7 +19,6 @@ package com.android.tv; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.annotation.UiThread; import android.util.ArraySet; import com.android.tv.data.Channel; @@ -54,7 +53,6 @@ public final class MainActivityWrapper { /** * Sets the currently created main activity instance. */ - @UiThread public void onMainActivityCreated(@NonNull MainActivity activity) { mActivity = activity; } @@ -62,7 +60,6 @@ public final class MainActivityWrapper { /** * Unsets the main activity instance. */ - @UiThread public void onMainActivityDestroyed(@NonNull MainActivity activity) { if (mActivity != activity) { mActivity = null; @@ -104,7 +101,6 @@ public final class MainActivityWrapper { /** * Adds OnCurrentChannelChangeListener. */ - @UiThread public void addOnCurrentChannelChangeListener(OnCurrentChannelChangeListener listener) { mListeners.add(listener); } @@ -112,7 +108,6 @@ public final class MainActivityWrapper { /** * Removes OnCurrentChannelChangeListener. */ - @UiThread public void removeOnCurrentChannelChangeListener(OnCurrentChannelChangeListener listener) { mListeners.remove(listener); } diff --git a/src/com/android/tv/SetupPassthroughActivity.java b/src/com/android/tv/SetupPassthroughActivity.java index e6373505..8a263a26 100644 --- a/src/com/android/tv/SetupPassthroughActivity.java +++ b/src/com/android/tv/SetupPassthroughActivity.java @@ -25,8 +25,11 @@ import android.util.Log; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.TvCommonConstants; +import com.android.tv.data.epg.EpgFetcher; +import com.android.tv.experiments.Experiments; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; +import com.android.tv.util.Utils; /** * An activity to launch a TV input setup activity. @@ -74,7 +77,25 @@ public class SetupPassthroughActivity extends Activity { Bundle extras = intent.getExtras(); extras.remove(TvCommonConstants.EXTRA_SETUP_INTENT); setupIntent.putExtras(extras); - startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY); + try { + startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Can't find activity: " + setupIntent.getComponent()); + finish(); + return; + } + if (Utils.isInternalTvInput(this, mTvInputInfo.getId()) && Experiments.CLOUD_EPG.get()) { + EpgFetcher.getInstance(this).stop(); + } + } + + @Override + protected void onDestroy() { + if (mTvInputInfo != null && Utils.isInternalTvInput(this, mTvInputInfo.getId()) + && Experiments.CLOUD_EPG.get()) { + EpgFetcher.getInstance(this).start(); + } + super.onDestroy(); } @Override diff --git a/src/com/android/tv/TimeShiftManager.java b/src/com/android/tv/TimeShiftManager.java index a231c29d..2d6d45c4 100644 --- a/src/com/android/tv/TimeShiftManager.java +++ b/src/com/android/tv/TimeShiftManager.java @@ -16,6 +16,7 @@ package com.android.tv; +import android.annotation.SuppressLint; import android.content.ContentResolver; import android.content.Context; import android.os.Handler; @@ -30,7 +31,6 @@ import android.util.Range; import com.android.tv.analytics.Tracker; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.WeakHandler; -import com.android.tv.common.recording.RecordedProgram; import com.android.tv.data.Channel; import com.android.tv.data.OnCurrentProgramUpdatedListener; import com.android.tv.data.Program; @@ -38,6 +38,7 @@ import com.android.tv.data.ProgramDataManager; import com.android.tv.ui.TunableTvView; import com.android.tv.ui.TunableTvView.TimeShiftListener; import com.android.tv.util.AsyncDbTask; +import com.android.tv.util.TimeShiftUtils; import com.android.tv.util.Utils; import java.lang.annotation.Retention; @@ -77,10 +78,6 @@ public class TimeShiftManager { public static final int PLAY_SPEED_4X = 4; public static final int PLAY_SPEED_5X = 5; - private static final int SHORT_PROGRAM_THRESHOLD_MILLIS = 46 * 60 * 1000; // 46 mins. - private static final int[] SHORT_PROGRAM_SPEED_FACTORS = new int[] {2, 4, 12, 48}; - private static final int[] LONG_PROGRAM_SPEED_FACTORS = new int[] {2, 8, 32, 128}; - @Retention(RetentionPolicy.SOURCE) @IntDef({PLAY_DIRECTION_FORWARD, PLAY_DIRECTION_BACKWARD}) public @interface PlayDirection{} @@ -109,6 +106,9 @@ public class TimeShiftManager { private static final long PREFETCH_TIME_OFFSET_FROM_PROGRAM_END = TimeUnit.MINUTES.toMillis(1); private static final long PREFETCH_DURATION_FOR_NEXT = TimeUnit.HOURS.toMillis(2); + private static final long ALLOWED_START_TIME_OFFSET = TimeUnit.DAYS.toMillis(14); + private static final long TWO_WEEKS_MS = TimeUnit.DAYS.toMillis(14); + @VisibleForTesting static final long REQUEST_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(3); @@ -143,9 +143,9 @@ public class TimeShiftManager { * due to the elapsed time to pass the message from TIS to Live TV. * So the boundary threshold is necessary. * The same goes for the recording start time. - * It must be three times longer than {@link #REQUEST_CURRENT_POSITION_INTERVAL} at least. + * It's the same {@link #REQUEST_CURRENT_POSITION_INTERVAL}. */ - private static final long RECORDING_BOUNDARY_THRESHOLD = 3 * REQUEST_CURRENT_POSITION_INTERVAL; + private static final long RECORDING_BOUNDARY_THRESHOLD = REQUEST_CURRENT_POSITION_INTERVAL; private final PlayController mPlayController; private final ProgramManager mProgramManager; @@ -178,12 +178,6 @@ public class TimeShiftManager { mProgramManager = new ProgramManager(programDataManager); mTracker = tracker; mOnCurrentProgramUpdatedListener = onCurrentProgramUpdatedListener; - tvView.setOnScreenBlockedListener(new TunableTvView.OnScreenBlockingChangedListener() { - @Override - public void onScreenBlockingChanged(boolean blocked) { - mPlayController.onAvailabilityChanged(); - } - }); } /** @@ -448,6 +442,8 @@ public class TimeShiftManager { } private void updateCurrentProgram() { + SoftPreconditions.checkState(isAvailable(), TAG, "Time shift is not available"); + SoftPreconditions.checkState(mCurrentPositionMediator.mCurrentPositionMs != INVALID_TIME); Program currentProgram = getProgramAt(mCurrentPositionMediator.mCurrentPositionMs); if (!Program.isValid(currentProgram)) { currentProgram = null; @@ -467,13 +463,6 @@ public class TimeShiftManager { } /** - * Checks whether the TV is playing the recorded content. - */ - public boolean isRecordingPlayback() { - return mPlayController.mRecordingPlayback; - } - - /** * Returns {@code true} if the trick play is available and it's playing to the forward direction * with normal speed, otherwise {@code false}. */ @@ -506,9 +495,9 @@ public class TimeShiftManager { } void onAvailabilityChanged() { + mCurrentPositionMediator.initialize(mPlayController.mRecordStartTimeMs); mProgramManager.onAvailabilityChanged(mPlayController.mAvailable, - mPlayController.mRecordingPlayback ? null : mPlayController.getCurrentChannel(), - mPlayController.mRecordStartTimeMs); + mPlayController.getCurrentChannel(), mPlayController.mRecordStartTimeMs); updateActions(); // Availability change notification should be always sent // even if mNotificationEnabled is false. @@ -564,28 +553,19 @@ public class TimeShiftManager { } private int getPlaybackSpeed() { - int[] playbackSpeedList; - if (getCurrentProgram() == null || getCurrentProgram().getEndTimeUtcMillis() - - getCurrentProgram().getStartTimeUtcMillis() > SHORT_PROGRAM_THRESHOLD_MILLIS) { - playbackSpeedList = LONG_PROGRAM_SPEED_FACTORS; + if (mPlayController.mDisplayedPlaySpeed == PLAY_SPEED_1X) { + return 1; } else { - playbackSpeedList = SHORT_PROGRAM_SPEED_FACTORS; - } - switch (mPlayController.mDisplayedPlaySpeed) { - case PLAY_SPEED_1X: - return 1; - case PLAY_SPEED_2X: - return playbackSpeedList[0]; - case PLAY_SPEED_3X: - return playbackSpeedList[1]; - case PLAY_SPEED_4X: - return playbackSpeedList[2]; - case PLAY_SPEED_5X: - return playbackSpeedList[3]; - default: + long durationMs = + (getCurrentProgram() == null ? 0 : getCurrentProgram().getDurationMillis()); + if (mPlayController.mDisplayedPlaySpeed > PLAY_SPEED_5X) { Log.w(TAG, "Unknown displayed play speed is chosen : " + mPlayController.mDisplayedPlaySpeed); - return 1; + return TimeShiftUtils.getMaxPlaybackSpeed(durationMs); + } else { + return TimeShiftUtils.getPlaybackSpeed( + mPlayController.mDisplayedPlaySpeed - PLAY_SPEED_2X, durationMs); + } } } @@ -595,6 +575,7 @@ public class TimeShiftManager { private class PlayController { private final TunableTvView mTvView; + private long mAvailablityChangedTimeMs; private long mRecordStartTimeMs; private long mRecordEndTimeMs; @@ -603,7 +584,6 @@ public class TimeShiftManager { @PlayDirection private int mPlayDirection = PLAY_DIRECTION_FORWARD; private int mPlaybackSpeed; private boolean mAvailable; - private boolean mRecordingPlayback; /** * Indicates that the trick play is not playing the current time position. @@ -619,11 +599,25 @@ public class TimeShiftManager { mTvView.setTimeShiftListener(new TimeShiftListener() { @Override public void onAvailabilityChanged() { + if (DEBUG) { + Log.d(TAG, "onAvailabilityChanged(available=" + + mTvView.isTimeShiftAvailable() + ")"); + } PlayController.this.onAvailabilityChanged(); } @Override public void onRecordStartTimeChanged(long recordStartTimeMs) { + if (!SoftPreconditions.checkState(mAvailable, TAG, + "Trick play is not available.")) { + return; + } + if (recordStartTimeMs < mAvailablityChangedTimeMs - ALLOWED_START_TIME_OFFSET) { + Log.e(TAG, "The start time is too earlier than the time of availability: {" + + "startTime: " + recordStartTimeMs + ", availability: " + + mAvailablityChangedTimeMs); + return; + } if (mRecordStartTimeMs == recordStartTimeMs) { return; } @@ -649,7 +643,7 @@ public class TimeShiftManager { } void onAvailabilityChanged() { - boolean newAvailable = mTvView.isTimeShiftAvailable() && !mTvView.isScreenBlocked(); + boolean newAvailable = mTvView.isTimeShiftAvailable(); if (mAvailable == newAvailable) { return; } @@ -661,27 +655,22 @@ public class TimeShiftManager { mDisplayedPlaySpeed = PLAY_SPEED_1X; mPlaybackSpeed = 1; mPlayDirection = PLAY_DIRECTION_FORWARD; - mRecordingPlayback = mTvView.isRecordingPlayback(); - if (mRecordingPlayback) { - RecordedProgram recordedProgram = mTvView.getPlayingRecordedProgram(); - SoftPreconditions.checkNotNull(recordedProgram); - mIsPlayOffsetChanged = true; - mRecordStartTimeMs = 0; - mRecordEndTimeMs = recordedProgram.getDurationMillis(); - } else { - mIsPlayOffsetChanged = false; - mRecordStartTimeMs = System.currentTimeMillis(); - mRecordEndTimeMs = CURRENT_TIME; - } - mCurrentPositionMediator.initialize(mRecordStartTimeMs); mHandler.removeMessages(MSG_GET_CURRENT_POSITION); if (mAvailable) { + mAvailablityChangedTimeMs = System.currentTimeMillis(); + mIsPlayOffsetChanged = false; + mRecordStartTimeMs = mAvailablityChangedTimeMs; + mRecordEndTimeMs = CURRENT_TIME; // When the media availability message has come. mPlayController.setPlayStatus(PLAY_STATUS_PLAYING); mHandler.sendEmptyMessageDelayed(MSG_GET_CURRENT_POSITION, REQUEST_CURRENT_POSITION_INTERVAL); } else { + mAvailablityChangedTimeMs = INVALID_TIME; + mIsPlayOffsetChanged = false; + mRecordStartTimeMs = INVALID_TIME; + mRecordEndTimeMs = INVALID_TIME; // When the tune command is sent. mPlayController.setPlayStatus(PLAY_STATUS_PAUSED); } @@ -806,6 +795,7 @@ public class TimeShiftManager { } } + @SuppressLint("SwitchIntDef") private void increaseDisplayedPlaySpeed() { switch (mDisplayedPlaySpeed) { case PLAY_SPEED_1X: @@ -867,7 +857,7 @@ public class TimeShiftManager { mPrograms.clear(); mEmptyFetchCount = 0; mChannel = channel; - if (channel == null || channel.isPassthrough()) { + if (channel == null || channel.isPassthrough() || currentPositionMs == INVALID_TIME) { return; } if (available) { @@ -913,32 +903,14 @@ public class TimeShiftManager { if (mProgramLoadTask == null || mProgramLoadTask.isCancelled()) { startNext(); } else { - switch (mProgramLoadTask.getStatus()) { - case PENDING: - if (mProgramLoadTask.overlaps(mProgramLoadQueue)) { - if (mProgramLoadTask.cancel(true)) { - mProgramLoadQueue.add(mProgramLoadTask.getPeriod()); - mProgramLoadTask = null; - startNext(); - } - } - break; - case RUNNING: - // Remove pending task fully satisfied by the current - Range<Long> current = mProgramLoadTask.getPeriod(); - Iterator<Range<Long>> i = mProgramLoadQueue.iterator(); - while (i.hasNext()) { - Range<Long> r = i.next(); - if (current.contains(r)) { - i.remove(); - } - } - break; - case FINISHED: - // The task should have already cleared it self, clear and restart anyways. - Log.w(TAG, mProgramLoadTask + " is finished, but was not cleared"); - startNext(); - break; + // Remove pending task fully satisfied by the current + Range<Long> current = mProgramLoadTask.getPeriod(); + Iterator<Range<Long>> i = mProgramLoadQueue.iterator(); + while (i.hasNext()) { + Range<Long> r = i.next(); + if (current.contains(r)) { + i.remove(); + } } } } @@ -1025,10 +997,9 @@ public class TimeShiftManager { } private void removeDummyPrograms() { - for (int i = 0; i < mPrograms.size(); ++i) { - Program program = mPrograms.get(i); - if (!program.isValid()) { - mPrograms.remove(i--); + for (Iterator<Program> it = mPrograms.listIterator(); it.hasNext(); ) { + if (!it.next().isValid()) { + it.remove(); } } } @@ -1068,6 +1039,10 @@ public class TimeShiftManager { // to show the time-line duration of {@link MAX_DUMMY_PROGRAM_DURATION} at most // for a dummy program. private List<Program> createDummyPrograms(long startTimeMs, long endTimeMs) { + SoftPreconditions.checkArgument(endTimeMs - startTimeMs <= TWO_WEEKS_MS, TAG, + "createDummyProgram: long duration of dummy programs are requested (" + + Utils.toTimeString(startTimeMs) + ", " + + Utils.toTimeString(endTimeMs)); if (startTimeMs >= endTimeMs) { return Collections.emptyList(); } @@ -1162,7 +1137,7 @@ public class TimeShiftManager { if (DEBUG) Log.d(TAG, "Scheduling with " + delay + "(ms) delays."); } - // Prefecth programs within PREFETCH_DURATION_FOR_NEXT from now. + // Prefetch programs within PREFETCH_DURATION_FOR_NEXT from now. private void prefetchPrograms() { long startTimeMs; Program lastValidProgram = getLastValidProgram(); @@ -1185,7 +1160,7 @@ public class TimeShiftManager { private class LoadProgramsForCurrentChannelTask extends AsyncDbTask.LoadProgramsForChannelTask { - public LoadProgramsForCurrentChannelTask(ContentResolver contentResolver, + LoadProgramsForCurrentChannelTask(ContentResolver contentResolver, Range<Long> period) { super(contentResolver, mChannel.getId(), period); } @@ -1252,7 +1227,9 @@ public class TimeShiftManager { } private void startNextLoadingIfNeeded() { - mProgramLoadTask = null; + if (mProgramLoadTask == this) { + mProgramLoadTask = null; + } // Need to post to handler, because the task is still running. mHandler.post(new Runnable() { @Override @@ -1262,7 +1239,7 @@ public class TimeShiftManager { }); } - public boolean overlaps(Queue<Range<Long>> programLoadQueue) { + boolean overlaps(Queue<Range<Long>> programLoadQueue) { for (Range<Long> r : programLoadQueue) { if (mPeriod.contains(r.getLower()) || mPeriod.contains(r.getUpper())) { return true; @@ -1281,7 +1258,9 @@ public class TimeShiftManager { void initialize(long timeMs) { mSeekRequestTimeMs = INVALID_TIME; mCurrentPositionMs = timeMs; - TimeShiftManager.this.onCurrentPositionChanged(); + if (timeMs != INVALID_TIME) { + TimeShiftManager.this.onCurrentPositionChanged(); + } } void onSeekRequested(long seekTimeMs) { @@ -1357,7 +1336,7 @@ public class TimeShiftManager { } private static class TimeShiftHandler extends WeakHandler<TimeShiftManager> { - public TimeShiftHandler(TimeShiftManager ref) { + TimeShiftHandler(TimeShiftManager ref) { super(ref); } diff --git a/src/com/android/tv/TvApplication.java b/src/com/android/tv/TvApplication.java index ef105c94..0e18a259 100644 --- a/src/com/android/tv/TvApplication.java +++ b/src/com/android/tv/TvApplication.java @@ -22,9 +22,9 @@ import android.app.Application; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; -import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; @@ -32,7 +32,7 @@ import android.os.Build; import android.os.Bundle; import android.os.StrictMode; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; +import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; @@ -42,37 +42,47 @@ import com.android.tv.analytics.StubAnalytics; import com.android.tv.analytics.Tracker; import com.android.tv.common.BuildConfig; import com.android.tv.common.SharedPreferencesUtils; +import com.android.tv.common.SoftPreconditions; import com.android.tv.common.TvCommonUtils; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.common.ui.setup.animation.SetupAnimationHelper; +import com.android.tv.config.DefaultConfigManager; +import com.android.tv.config.RemoteConfig; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrDataManagerImpl; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.DvrRecordingService; -import com.android.tv.dvr.DvrSessionManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.TunerInputInfoUtils; +import com.android.tv.util.AccountHelper; import com.android.tv.util.Clock; import com.android.tv.util.SetupUtils; import com.android.tv.util.SystemProperties; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; -import com.android.usbtuner.UsbTunerPreferences; -import com.android.usbtuner.setup.TunerSetupActivity; -import com.android.usbtuner.tvinput.UsbTunerTvInputService; import java.util.List; public class TvApplication extends Application implements ApplicationSingletons { private static final String TAG = "TvApplication"; private static final boolean DEBUG = false; + private RemoteConfig mRemoteConfig; /** - * Returns the @{@link ApplicationSingletons} using the application context. + * Broadcast Action: The user has updated LC to a new version that supports tuner input. + * {@link TunerInputController} will recevice this intent to check the existence of tuner + * input when the new version is first launched. */ - public static ApplicationSingletons getSingletons(Context context) { - return (ApplicationSingletons) context.getApplicationContext(); - } + public static final String ACTION_APPLICATION_FIRST_LAUNCHED = + "com.android.tv.action.APPLICATION_FIRST_LAUNCHED"; + private static final String PREFERENCE_IS_FIRST_LAUNCH = "is_first_launch"; + private String mVersionName = ""; private final MainActivityWrapper mMainActivityWrapper = new MainActivityWrapper(); @@ -84,14 +94,30 @@ public class TvApplication extends Application implements ApplicationSingletons private ChannelDataManager mChannelDataManager; private ProgramDataManager mProgramDataManager; private DvrManager mDvrManager; + private DvrScheduleManager mDvrScheduleManager; private DvrDataManager mDvrDataManager; + private DvrStorageStatusManager mDvrStorageStatusManager; + private DvrWatchedPositionManager mDvrWatchedPositionManager; @Nullable - private DvrSessionManager mDvrSessionManager; + private InputSessionManager mInputSessionManager; + private AccountHelper mAccountHelper; + // When this variable is null, we don't know in which process TvApplication runs. + private Boolean mRunningInMainProcess; @Override public void onCreate() { super.onCreate(); - SharedPreferencesUtils.initialize(this); + SharedPreferencesUtils.initialize(this, new Runnable() { + @Override + public void run() { + if (mRunningInMainProcess != null && mRunningInMainProcess) { + checkTunerServiceOnFirstLaunch(); + } + } + }); + // TunerPreferences is used to enable/disable the tuner input even when TUNER feature is + // disabled. + TunerPreferences.initialize(this); try { PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); mVersionName = pInfo.versionName; @@ -100,17 +126,21 @@ public class TvApplication extends Application implements ApplicationSingletons mVersionName = ""; } Log.i(TAG, "Starting Live TV " + getVersionName()); + + // Only set StrictMode for ENG builds because the build server only produces userdebug // builds. if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) { - StrictMode.setThreadPolicy( - new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build()); - StrictMode.VmPolicy.Builder vmPolicyBuilder = new StrictMode.VmPolicy.Builder() - .detectAll().penaltyLog(); - if (BuildConfig.ENG && SystemProperties.ALLOW_DEATH_PENALTY.getValue() && - !TvCommonUtils.isRunningInTest()) { - // TODO turn on death penalty for tests when they stop leaking MainActivity + StrictMode.ThreadPolicy.Builder threadPolicyBuilder = + new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog(); + StrictMode.VmPolicy.Builder vmPolicyBuilder = + new StrictMode.VmPolicy.Builder().detectAll().penaltyLog(); + if (!TvCommonUtils.isRunningInTest()) { + threadPolicyBuilder.penaltyDialog(); + // Turn off death penalty for tests b/23355898 + vmPolicyBuilder.penaltyDeath(); } + StrictMode.setThreadPolicy(threadPolicyBuilder.build()); StrictMode.setVmPolicy(vmPolicyBuilder.build()); } if (BuildConfig.ENG && !SystemProperties.ALLOW_ANALYTICS_IN_ENG.getValue()) { @@ -121,27 +151,63 @@ public class TvApplication extends Application implements ApplicationSingletons mTracker = mAnalytics.getDefaultTracker(); mTvInputManagerHelper = new TvInputManagerHelper(this); mTvInputManagerHelper.start(); - mTvInputManagerHelper.addCallback(new TvInputCallback() { - @Override - public void onInputAdded(String inputId) { - handleInputCountChanged(); - } - - @Override - public void onInputRemoved(String inputId) { - handleInputCountChanged(); - } - }); - if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) { - mDvrManager = new DvrManager(this); - //NOTE: DvrRecordingService just keeps running. - DvrRecordingService.startService(this); - } // In SetupFragment, transitions are set in the constructor. Because the fragment can be // created in Activity.onCreate() by the framework, SetupAnimationHelper should be // initialized here before Activity.onCreate() is called. SetupAnimationHelper.initialize(this); - if (DEBUG) Log.i(TAG, "Started Live TV " + mVersionName); + Log.i(TAG, "Started Live TV " + mVersionName); + } + + private void setCurrentRunningProcess(boolean isMainProcess) { + if (mRunningInMainProcess != null) { + SoftPreconditions.checkState(isMainProcess == mRunningInMainProcess); + return; + } + mRunningInMainProcess = isMainProcess; + if (CommonFeatures.DVR.isEnabled(this)) { + mDvrStorageStatusManager = new DvrStorageStatusManager(this, mRunningInMainProcess); + } + if (mRunningInMainProcess) { + mTvInputManagerHelper.addCallback(new TvInputCallback() { + @Override + public void onInputAdded(String inputId) { + if (Features.TUNER.isEnabled(TvApplication.this) && TextUtils.equals(inputId, + TunerTvInputService.getInputId(TvApplication.this))) { + TunerInputInfoUtils.updateTunerInputInfo(TvApplication.this); + } + handleInputCountChanged(); + } + + @Override + public void onInputRemoved(String inputId) { + handleInputCountChanged(); + } + }); + if (Features.TUNER.isEnabled(this)) { + // If the tuner input service is added before the app is started, we need to + // handle it here. + TunerInputInfoUtils.updateTunerInputInfo(this); + } + if (CommonFeatures.DVR.isEnabled(this)) { + mDvrScheduleManager = new DvrScheduleManager(this); + mDvrManager = new DvrManager(this); + //NOTE: DvrRecordingService just keeps running. + DvrRecordingService.startService(this); + } + } + } + + private void checkTunerServiceOnFirstLaunch() { + SharedPreferences sharedPreferences = this.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_FEATURES, Context.MODE_PRIVATE); + boolean isFirstLaunch = sharedPreferences.getBoolean(PREFERENCE_IS_FIRST_LAUNCH, true); + if (isFirstLaunch) { + if (DEBUG) Log.d(TAG, "Congratulations, it's the first launch!"); + sendBroadcast(new Intent(ACTION_APPLICATION_FIRST_LAUNCHED)); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putBoolean(PREFERENCE_IS_FIRST_LAUNCH, false); + editor.apply(); + } } /** @@ -152,13 +218,32 @@ public class TvApplication extends Application implements ApplicationSingletons return mDvrManager; } + /** + * Returns the {@link DvrScheduleManager}. + */ + @Override + public DvrScheduleManager getDvrScheduleManager() { + return mDvrScheduleManager; + } + + /** + * Returns the {@link DvrWatchedPositionManager}. + */ + @Override + public DvrWatchedPositionManager getDvrWatchedPositionManager() { + if (mDvrWatchedPositionManager == null) { + mDvrWatchedPositionManager = new DvrWatchedPositionManager(this); + } + return mDvrWatchedPositionManager; + } + @Override @TargetApi(Build.VERSION_CODES.N) - public DvrSessionManager getDvrSessionManger() { - if (mDvrSessionManager == null) { - mDvrSessionManager = new DvrSessionManager(this); + public InputSessionManager getInputSessionManager() { + if (mInputSessionManager == null) { + mInputSessionManager = new InputSessionManager(this); } - return mDvrSessionManager; + return mInputSessionManager; } /** @@ -177,7 +262,6 @@ public class TvApplication extends Application implements ApplicationSingletons return mTracker; } - /** * Returns {@link ChannelDataManager}. */ @@ -209,14 +293,23 @@ public class TvApplication extends Application implements ApplicationSingletons @Override public DvrDataManager getDvrDataManager() { if (mDvrDataManager == null) { - DvrDataManagerImpl dvrDataManager = new DvrDataManagerImpl(this, Clock.SYSTEM); - mDvrDataManager = dvrDataManager; - dvrDataManager.start(); + DvrDataManagerImpl dvrDataManager = new DvrDataManagerImpl(this, Clock.SYSTEM); + mDvrDataManager = dvrDataManager; + dvrDataManager.start(); } return mDvrDataManager; } /** + * Returns {@link DvrStorageStatusManager}. + */ + @TargetApi(Build.VERSION_CODES.N) + @Override + public DvrStorageStatusManager getDvrStorageStatusManager() { + return mDvrStorageStatusManager; + } + + /** * Returns {@link TvInputManagerHelper}. */ @Override @@ -233,6 +326,26 @@ public class TvApplication extends Application implements ApplicationSingletons } /** + * Returns the {@link AccountHelper}. + */ + @Override + public AccountHelper getAccountHelper() { + if (mAccountHelper == null) { + mAccountHelper = new AccountHelper(getApplicationContext()); + } + return mAccountHelper; + } + + @Override + public RemoteConfig getRemoteConfig() { + if (mRemoteConfig == null) { + // No need to synchronize this, it does not hurt to create two and throw one away. + mRemoteConfig = DefaultConfigManager.createInstance(this).getRemoteConfig(); + } + return mRemoteConfig; + } + + /** * SelectInputActivity is set in {@link SelectInputActivity#onCreate} and cleared in * {@link SelectInputActivity#onDestroy}. */ @@ -322,7 +435,7 @@ public class TvApplication extends Application implements ApplicationSingletons * Checks the input counts and enable/disable TvActivity. Also updates the input list in * {@link SetupUtils}. * - * @param calledByTunerServiceChanged true if it is called when UsbTunerTvInputService + * @param calledByTunerServiceChanged true if it is called when TunerTvInputService * is enabled or disabled. * @param tunerServiceEnabled it's available only when calledByTunerServiceChanged is true. * @param dontKillApp when TvActivity is enabled or disabled by this method, the app restarts @@ -340,7 +453,7 @@ public class TvApplication extends Application implements ApplicationSingletons if (!skipTunerInputCheck) { for (TvInputInfo input : inputs) { if (calledByTunerServiceChanged && !tunerServiceEnabled - && UsbTunerTvInputService.getInputId(this).equals(input.getId())) { + && TunerTvInputService.getInputId(this).equals(input.getId())) { continue; } if (input.getType() == TvInputInfo.TYPE_TUNER) { @@ -361,4 +474,29 @@ public class TvApplication extends Application implements ApplicationSingletons } SetupUtils.getInstance(TvApplication.this).onInputListUpdated(inputManager); } + + /** + * Returns the @{@link ApplicationSingletons} using the application context. + */ + public static ApplicationSingletons getSingletons(Context context) { + return (ApplicationSingletons) context.getApplicationContext(); + } + + /** + * Sets true, if TvApplication is running on the main process. If TvApplication runs on + * tuner process or other process, it sets false. + * + * Note: it should be called at the beginning of Service.onCreate Activity.onCreate, or + * BroadcastReceiver.onCreate. When it is firstly called after launch, it runs process + * specific initializations. + */ + public static void setCurrentRunningProcess(Context context, boolean isMainProcess) { + if (context.getApplicationContext() instanceof TvApplication) { + TvApplication tvApplication = (TvApplication) context.getApplicationContext(); + tvApplication.setCurrentRunningProcess(isMainProcess); + } else { + // Application context can be MockTvApplication. + Log.w(TAG, "It is not a context of TvApplication"); + } + } } diff --git a/src/com/android/tv/TvOptionsManager.java b/src/com/android/tv/TvOptionsManager.java index f104e75d..7871cbe7 100644 --- a/src/com/android/tv/TvOptionsManager.java +++ b/src/com/android/tv/TvOptionsManager.java @@ -39,7 +39,8 @@ public class TvOptionsManager { public static final int OPTION_SYSTEMWIDE_PIP = 3; public static final int OPTION_MULTI_AUDIO = 4; public static final int OPTION_MORE_CHANNELS = 5; - public static final int OPTION_SETTINGS = 6; + public static final int OPTION_DEVELOPER = 6; + public static final int OPTION_SETTINGS = 7; public static final int OPTION_PIP_INPUT = 100; public static final int OPTION_PIP_SWAP = 101; diff --git a/src/com/android/tv/dvr/ui/EmptyHolder.java b/src/com/android/tv/config/ConfigKeys.java index 45cd3a36..7df033d2 100644 --- a/src/com/android/tv/dvr/ui/EmptyHolder.java +++ b/src/com/android/tv/config/ConfigKeys.java @@ -14,14 +14,14 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.config; /** - * Special object meaning a row is empty; + * Static list of config keys. */ -final class EmptyHolder { - static final EmptyHolder EMPTY_HOLDER = new EmptyHolder(); +public final class ConfigKeys { - private EmptyHolder() { + + private ConfigKeys() { } } diff --git a/src/com/android/tv/config/DefaultConfigManager.java b/src/com/android/tv/config/DefaultConfigManager.java new file mode 100644 index 00000000..f5a6e959 --- /dev/null +++ b/src/com/android/tv/config/DefaultConfigManager.java @@ -0,0 +1,55 @@ +/* + * 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.config; + +import android.content.Context; + +/** + * Stub Remote Config. + */ +public class DefaultConfigManager { + public static DefaultConfigManager createInstance(Context context) { + return new DefaultConfigManager(); + } + + private StubRemoteConfig mRemoteConfig = new StubRemoteConfig(); + + public RemoteConfig getRemoteConfig() { + return mRemoteConfig; + } + + private static class StubRemoteConfig implements RemoteConfig { + @Override + public void fetch(OnRemoteConfigUpdatedListener listener) { + + } + + @Override + public String getString(String key) { + return null; + } + + @Override + public boolean getBoolean(String key) { + return false; + } + } +} + + + + diff --git a/src/com/android/tv/config/RemoteConfig.java b/src/com/android/tv/config/RemoteConfig.java new file mode 100644 index 00000000..0f7d2c53 --- /dev/null +++ b/src/com/android/tv/config/RemoteConfig.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.config; + +/** + * Manages Live TV Configuration, allowing remote updates. + * + * <p>This is a thin wrapper around + * <a href="https://firebase.google.com/docs/remote-config/"></a>Firebase Remote Config</a> + */ +public interface RemoteConfig { + + /** + * Notified on successful completion of a {@link #fetch)} + */ + interface OnRemoteConfigUpdatedListener { + void onRemoteConfigUpdated(); + } + + /** + * Starts a fetch and notifies {@code listener} on successful completion. + */ + void fetch(OnRemoteConfigUpdatedListener listener); + + /** + * Gets value as a string corresponding to the specified key. + */ + String getString(String key); + + /** + * Gets value as a boolean corresponding to the specified key. + */ + boolean getBoolean(String key); +} diff --git a/src/com/android/tv/config/RemoteConfigFeature.java b/src/com/android/tv/config/RemoteConfigFeature.java new file mode 100644 index 00000000..502e6a9c --- /dev/null +++ b/src/com/android/tv/config/RemoteConfigFeature.java @@ -0,0 +1,43 @@ +/* + * 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.config; + +import android.content.Context; + +import com.android.tv.TvApplication; +import com.android.tv.common.feature.Feature; + +/** + * A {@link Feature} controlled by a {@link RemoteConfig} boolean. + */ +public class RemoteConfigFeature implements Feature { + private final String mKey; + + /** Creates a {@link RemoteConfigFeature for the {@code key}. */ + public static RemoteConfigFeature fromKey(String key) { + return new RemoteConfigFeature(key); + } + + private RemoteConfigFeature(String key) { + mKey = key; + } + + @Override + public boolean isEnabled(Context context) { + return TvApplication.getSingletons(context).getRemoteConfig().getBoolean(mKey); + } +} diff --git a/src/com/android/tv/data/BaseProgram.java b/src/com/android/tv/data/BaseProgram.java new file mode 100644 index 00000000..f420de02 --- /dev/null +++ b/src/com/android/tv/data/BaseProgram.java @@ -0,0 +1,177 @@ +/* + * 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.data; + +import android.content.Context; + +import java.util.Comparator; + +/** + * Base class for {@link com.android.tv.data.Program} and + * {@link com.android.tv.dvr.RecordedProgram}. + */ +public abstract class BaseProgram { + /** + * Comparator used to compare {@link BaseProgram} according to its season and episodes number. + * If a program's season or episode number is null, it will be consider "smaller" than programs + * with season or episode numbers. + */ + public static final Comparator<BaseProgram> EPISODE_COMPARATOR = + new EpisodeComparator(false); + + /** + * Comparator used to compare {@link BaseProgram} according to its season and episodes number + * with season numbers in a reversed order. If a program's season or episode number is null, it + * will be consider "smaller" than programs with season or episode numbers. + */ + public static final Comparator<BaseProgram> SEASON_REVERSED_EPISODE_COMPARATOR = + new EpisodeComparator(true); + + private static class EpisodeComparator implements Comparator<BaseProgram> { + private final boolean mReversedSeason; + + EpisodeComparator(boolean reversedSeason) { + mReversedSeason = reversedSeason; + } + + @Override + public int compare(BaseProgram lhs, BaseProgram rhs) { + if (lhs == rhs) { + return 0; + } + int seasonNumberCompare = + numberCompare(lhs.getSeasonNumber(), rhs.getSeasonNumber()); + if (seasonNumberCompare != 0) { + return mReversedSeason ? -seasonNumberCompare : seasonNumberCompare; + } else { + return numberCompare(lhs.getEpisodeNumber(), rhs.getEpisodeNumber()); + } + } + } + + /** + * Compares two strings represent season numbers or episode numbers of programs. + */ + public static int numberCompare(String s1, String s2) { + if (s1 == s2) { + return 0; + } else if (s1 == null) { + return -1; + } else if (s2 == null) { + return 1; + } else if (s1.equals(s2)) { + return 0; + } + try { + return Integer.compare(Integer.parseInt(s1), Integer.parseInt(s2)); + } catch (NumberFormatException e) { + return s1.compareTo(s2); + } + } + + /** + * Returns ID of the program. + */ + abstract public long getId(); + + /** + * Returns the title of the program. + */ + abstract public String getTitle(); + + /** + * Returns the program's title withe its season and episode number. + */ + abstract public String getTitleWithEpisodeNumber(Context context); + + /** + * Returns the displayed title of the program episode. + */ + abstract public String getEpisodeDisplayTitle(Context context); + + /** + * Returns the description of the program. + */ + abstract public String getDescription(); + + /** + * Returns the long description of the program. + */ + abstract public String getLongDescription(); + + /** + * Returns the start time of the program in Milliseconds. + */ + abstract public long getStartTimeUtcMillis(); + + /** + * Returns the end time of the program in Milliseconds. + */ + abstract public long getEndTimeUtcMillis(); + + /** + * Returns the duration of the program in Milliseconds. + */ + abstract public long getDurationMillis(); + + /** + * Returns the series ID. + */ + abstract public String getSeriesId(); + + /** + * Returns the season number. + */ + abstract public String getSeasonNumber(); + + /** + * Returns the episode number. + */ + abstract public String getEpisodeNumber(); + + /** + * Returns URI of the program's poster. + */ + abstract public String getPosterArtUri(); + + /** + * Returns URI of the program's thumbnail. + */ + abstract public String getThumbnailUri(); + + /** + * Returns the array of the ID's of the canonical genres. + */ + abstract public int[] getCanonicalGenreIds(); + + /** + * Returns channel's ID of the program. + */ + abstract public long getChannelId(); + + /** + * Returns if the program is valid. + */ + abstract public boolean isValid(); + + /** + * Generates the series ID for the other inputs than the tuner TV input. + */ + public static String generateSeriesId(String packageName, String title) { + return packageName + "/" + title; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/data/Channel.java b/src/com/android/tv/data/Channel.java index 86437ab2..30f84236 100644 --- a/src/com/android/tv/data/Channel.java +++ b/src/com/android/tv/data/Channel.java @@ -16,7 +16,6 @@ package com.android.tv.data; -import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; @@ -24,14 +23,12 @@ import android.database.Cursor; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.net.Uri; -import android.os.Build; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; -import com.android.tv.common.CollectionUtils; import com.android.tv.common.TvCommonConstants; import com.android.tv.util.ImageLoader; import com.android.tv.util.TvInputManagerHelper; @@ -73,7 +70,7 @@ public final class Channel { private static final int APP_LINK_TYPE_NOT_SET = 0; private static final String INVALID_PACKAGE_NAME = "packageName"; - private static final String[] PROJECTION_BASE = { + public static final String[] PROJECTION = { // Columns must match what is read in Channel.fromCursor() TvContract.Channels._ID, TvContract.Channels.COLUMN_PACKAGE_NAME, @@ -85,12 +82,6 @@ public final class Channel { TvContract.Channels.COLUMN_VIDEO_FORMAT, TvContract.Channels.COLUMN_BROWSABLE, TvContract.Channels.COLUMN_LOCKED, - }; - - // Additional fields added in MNC. - @SuppressLint("InlinedApi") - private static final String[] PROJECTION_ADDED_IN_MNC = { - // Columns should match what is read in Channel.fromCursor() TvContract.Channels.COLUMN_APP_LINK_TEXT, TvContract.Channels.COLUMN_APP_LINK_COLOR, TvContract.Channels.COLUMN_APP_LINK_ICON_URI, @@ -98,16 +89,6 @@ public final class Channel { TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, }; - public static final String[] PROJECTION = createProjection(); - - private static String[] createProjection() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return CollectionUtils.concatAll(PROJECTION_BASE, PROJECTION_ADDED_IN_MNC); - } else { - return PROJECTION_BASE; - } - } - /** * Creates {@code Channel} object from cursor. * @@ -128,13 +109,11 @@ public final class Channel { channel.mVideoFormat = Utils.intern(cursor.getString(index++)); channel.mBrowsable = cursor.getInt(index++) == 1; channel.mLocked = cursor.getInt(index++) == 1; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - channel.mAppLinkText = cursor.getString(index++); - channel.mAppLinkColor = cursor.getInt(index++); - channel.mAppLinkIconUri = cursor.getString(index++); - channel.mAppLinkPosterArtUri = cursor.getString(index++); - channel.mAppLinkIntentUri = cursor.getString(index++); - } + channel.mAppLinkText = cursor.getString(index++); + channel.mAppLinkColor = cursor.getInt(index++); + channel.mAppLinkIconUri = cursor.getString(index++); + channel.mAppLinkPosterArtUri = cursor.getString(index++); + channel.mAppLinkIntentUri = cursor.getString(index++); return channel; } @@ -171,11 +150,6 @@ public final class Channel { private long mDvrId; - /** - * TODO(DVR): Need to fill the following data. - */ - private boolean mRecordable; - private Channel() { // Do nothing. } @@ -226,6 +200,15 @@ public final class Channel { return mIsPassthrough; } + /** + * Gets identification text for displaying or debugging. + * It's made from Channels' display number plus their display name. + */ + public String getDisplayText() { + return TextUtils.isEmpty(mDisplayName) ? mDisplayNumber + : mDisplayNumber + " " + mDisplayName; + } + public String getAppLinkText() { return mAppLinkText; } @@ -578,6 +561,8 @@ public final class Channel { getUri().toString()); mAppLinkType = APP_LINK_TYPE_CHANNEL; return; + } else { + Log.w(TAG, "No activity exists to handle : " + mAppLinkIntentUri); } } catch (URISyntaxException e) { Log.w(TAG, "Unable to set app link for " + mAppLinkIntentUri, e); @@ -650,8 +635,7 @@ public final class Channel { result = ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber()); if (mDetectDuplicatesEnabled && result == 0) { Log.w(TAG, "Duplicate channels detected! - \"" - + lhs.getDisplayNumber() + " " + lhs.getDisplayName() + "\" and \"" - + rhs.getDisplayNumber() + " " + rhs.getDisplayName() + "\""); + + lhs.getDisplayText() + "\" and \"" + rhs.getDisplayText() + "\""); } return result; } diff --git a/src/com/android/tv/data/ChannelDataManager.java b/src/com/android/tv/data/ChannelDataManager.java index 84a16111..6f9ea6d7 100644 --- a/src/com/android/tv/data/ChannelDataManager.java +++ b/src/com/android/tv/data/ChannelDataManager.java @@ -50,6 +50,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; /** * The class to manage channel data. @@ -72,7 +73,7 @@ public class ChannelDataManager { private QueryAllChannelsTask mChannelsUpdateTask; private final List<Runnable> mPostRunnablesAfterChannelUpdate = new ArrayList<>(); - private final Set<Listener> mListeners = new ArraySet<>(); + private final Set<Listener> mListeners = new CopyOnWriteArraySet<>(); private final Map<Long, ChannelWrapper> mChannelWrapperMap = new HashMap<>(); private final Map<String, MutableInt> mChannelCountMap = new HashMap<>(); private final Channel.DefaultComparator mChannelComparator; @@ -296,6 +297,16 @@ public class ChannelDataManager { } /** + * Checks if the channel exists in DB. + * + * <p>Note that the channels of the removed inputs can not be obtained from {@link #getChannel}. + * In that case this method is used to check if the channel exists in the DB. + */ + public boolean doesChannelExistInDb(long channelId) { + return mChannelWrapperMap.get(channelId) != null; + } + + /** * Returns true if and only if there exists at least one channel and all channels are hidden. */ public boolean areAllChannelsHidden() { @@ -360,22 +371,19 @@ public class ChannelDataManager { } public void notifyChannelBrowsableChanged() { - // Copy the original collection to allow the callee to modify the listeners. - for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) { + for (Listener l : mListeners) { l.onChannelBrowsableChanged(); } } private void notifyChannelListUpdated() { - // Copy the original collection to allow the callee to modify the listeners. - for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) { + for (Listener l : mListeners) { l.onChannelListUpdated(); } } private void notifyLoadFinished() { - // Copy the original collection to allow the callee to modify the listeners. - for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) { + for (Listener l : mListeners) { l.onLoadFinished(); } } diff --git a/src/com/android/tv/data/GenreItems.java b/src/com/android/tv/data/GenreItems.java index b1110612..b12fd1aa 100644 --- a/src/com/android/tv/data/GenreItems.java +++ b/src/com/android/tv/data/GenreItems.java @@ -16,10 +16,8 @@ package com.android.tv.data; -import android.annotation.SuppressLint; import android.content.Context; import android.media.tv.TvContract.Programs.Genres; -import android.os.Build; import com.android.tv.R; @@ -29,23 +27,7 @@ public class GenreItems { */ public static final int ID_ALL_CHANNELS = 0; - private static final String[] CANONICAL_GENRES_L = { - null, // All channels - Genres.FAMILY_KIDS, - Genres.SPORTS, - Genres.SHOPPING, - Genres.MOVIES, - Genres.COMEDY, - Genres.TRAVEL, - Genres.DRAMA, - Genres.EDUCATION, - Genres.ANIMAL_WILDLIFE, - Genres.NEWS, - Genres.GAMING - }; - - @SuppressLint("InlinedApi") - private static final String[] CANONICAL_GENRES_L_MR1 = { + private static final String[] CANONICAL_GENRES = { null, // All channels Genres.FAMILY_KIDS, Genres.SPORTS, @@ -66,25 +48,13 @@ public class GenreItems { Genres.TECH_SCIENCE }; - private static final String[] CANONICAL_GENRES = createGenres(); - - private static String[] createGenres() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { - return CANONICAL_GENRES_L; - } else { - return CANONICAL_GENRES_L_MR1; - } - } - private GenreItems() { } /** * Returns array of all genre labels. */ public static String[] getLabels(Context context) { - String[] items = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1 - ? context.getResources().getStringArray(R.array.genre_labels_l) - : context.getResources().getStringArray(R.array.genre_labels_l_mr1); + String[] items = context.getResources().getStringArray(R.array.genre_labels); if (items.length != CANONICAL_GENRES.length) { throw new IllegalArgumentException("Genre data mismatch"); } diff --git a/src/com/android/tv/data/InternalDataUtils.java b/src/com/android/tv/data/InternalDataUtils.java new file mode 100644 index 00000000..6054f089 --- /dev/null +++ b/src/com/android/tv/data/InternalDataUtils.java @@ -0,0 +1,133 @@ +/* + * 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.data; + +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.data.Program.CriticScore; +import com.android.tv.dvr.RecordedProgram; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; + +/** + * A utility class to parse and store data from the + * {@link android.media.tv.TvContract.Programs#COLUMN_INTERNAL_PROVIDER_DATA} field in the + * {@link android.media.tv.TvContract.Programs}. + */ +public final class InternalDataUtils { + private static final boolean DEBUG = false; + private static final String TAG = "InternalDataUtils"; + + private InternalDataUtils() { + //do nothing + } + + /** + * Deserializes a byte array into objects to be stored in the Program class. + * + * <p> Series ID and critic scores are loaded from the bytes. + * + * @param bytes the bytes to be deserialized + * @param builder the builder for the Program class + */ + public static void deserializeInternalProviderData(byte[] bytes, Program.Builder builder) { + if (bytes == null || bytes.length == 0) { + return; + } + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + builder.setSeriesId((String) in.readObject()); + builder.setCriticScores((List<CriticScore>) in.readObject()); + } catch (NullPointerException e) { + Log.e(TAG, "no bytes to deserialize"); + } catch (IOException e) { + Log.e(TAG, "Could not deserialize internal provider contents"); + } catch (ClassNotFoundException e) { + Log.e(TAG, "class not found in internal provider contents"); + } + } + + /** + * Convenience method for converting relevant data in Program class to a serialized blob type + * for storage in internal_provider_data field. + * @param program the program which contains the objects to be serialized + * @return serialized blob-type data + */ + @Nullable + public static byte[] serializeInternalProviderData(Program program) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bos)) { + if (!TextUtils.isEmpty(program.getSeriesId()) || program.getCriticScores() != null) { + out.writeObject(program.getSeriesId()); + out.writeObject(program.getCriticScores()); + return bos.toByteArray(); + } + } catch (IOException e) { + Log.e(TAG, "Could not serialize internal provider contents for program: " + + program.getTitle()); + } + return null; + } + + /** + * Deserializes a byte array into objects to be stored in the RecordedProgram class. + * + * <p> Series ID is loaded from the bytes. + * + * @param bytes the bytes to be deserialized + * @param builder the builder for the RecordedProgram class + */ + public static void deserializeInternalProviderData(byte[] bytes, + RecordedProgram.Builder builder) { + if (bytes == null || bytes.length == 0) { + return; + } + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + builder.setSeriesId((String) in.readObject()); + } catch (NullPointerException e) { + Log.e(TAG, "no bytes to deserialize"); + } catch (IOException e) { + Log.e(TAG, "Could not deserialize internal provider contents"); + } catch (ClassNotFoundException e) { + Log.e(TAG, "class not found in internal provider contents"); + } + } + + /** + * Serializes relevant objects in {@link android.media.tv.TvContract.Programs} to byte array. + * @return the serialized byte array + */ + public static byte[] serializeInternalProviderData(RecordedProgram program) { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bos)) { + if (!TextUtils.isEmpty(program.getSeriesId())) { + out.writeObject(program.getSeriesId()); + return bos.toByteArray(); + } + } catch (IOException e) { + Log.e(TAG, "Could not serialize internal provider contents for program: " + + program.getTitle()); + } + return null; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/data/Lineup.java b/src/com/android/tv/data/Lineup.java new file mode 100644 index 00000000..d0e9d7ba --- /dev/null +++ b/src/com/android/tv/data/Lineup.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.data; + +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A class that represents a lineup. + */ +public class Lineup { + /** + * The ID of this lineup. + */ + public final String id; + + /** + * The type associated with this lineup. + */ + public final int type; + + /** + * The human readable name associated with this lineup. + */ + public final String name; + + /** + * Location this lineup can be found. + * This is a human readable description of a geographic location. + */ + public final String location; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({LINEUP_CABLE, LINEUP_SATELLITE, LINEUP_BROADCAST_DIGITAL, LINEUP_BROADCAST_ANALOG, + LINEUP_IPTV, LINEUP_MVPD}) + public @interface LineupType {} + + /** + * Lineup type for cable. + */ + public static final int LINEUP_CABLE = 0; + + /** + * Lineup type for satelite. + */ + public static final int LINEUP_SATELLITE = 1; + + /** + * Lineup type for broadcast digital. + */ + public static final int LINEUP_BROADCAST_DIGITAL = 2; + + /** + * Lineup type for broadcast analog. + */ + public static final int LINEUP_BROADCAST_ANALOG = 3; + + /** + * Lineup type for IPTV. + */ + public static final int LINEUP_IPTV = 4; + + /** + * Indicates the lineup is either satelite, cable or IPTV but we are not sure which specific + * type. + */ + public static final int LINEUP_MVPD = 5; + + /** + * Creates a lineup. + */ + public Lineup(String id, int type, String name, String location) { + this.id = id; + this.type = type; + this.name = name; + this.location = location; + } +} diff --git a/src/com/android/tv/data/ParcelableList.java b/src/com/android/tv/data/ParcelableList.java new file mode 100644 index 00000000..78f444e4 --- /dev/null +++ b/src/com/android/tv/data/ParcelableList.java @@ -0,0 +1,86 @@ +/* + * 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.data; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * A convenience class for the list of {@link Parcelable}s. + */ +public final class ParcelableList<T extends Parcelable> implements Parcelable { + /** + * Create instance from {@link Parcel}. + */ + public static ParcelableList fromParcel(Parcel in) { + ParcelableList list = new ParcelableList(); + int length = in.readInt(); + if (length > 0) { + for (int i = 0; i < length; ++i) { + list.mList.add(in.readParcelable(Thread.currentThread().getContextClassLoader())); + } + } + return list; + } + + /** + * A creator for {@link ParcelableList}. + */ + public static final Creator<ParcelableList> CREATOR = new Creator<ParcelableList>() { + @Override + public ParcelableList createFromParcel(Parcel in) { + return ParcelableList.fromParcel(in); + } + + @Override + public ParcelableList[] newArray(int size) { + return new ParcelableList[size]; + } + }; + + private final List<T> mList = new ArrayList<>(); + + private ParcelableList() { } + + public ParcelableList(Collection<T> initialList) { + mList.addAll(initialList); + } + + /** + * Returns the list. + */ + public List<T> getList() { + return new ArrayList<T>(mList); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int paramInt) { + out.writeInt(mList.size()); + for (T data : mList) { + out.writeParcelable(data, 0); + } + } +} diff --git a/src/com/android/tv/data/Program.java b/src/com/android/tv/data/Program.java index af5f93bb..b9cd3d8d 100644 --- a/src/com/android/tv/data/Program.java +++ b/src/com/android/tv/data/Program.java @@ -20,8 +20,12 @@ import android.content.Context; import android.database.Cursor; import android.media.tv.TvContentRating; import android.media.tv.TvContract; +import android.os.Parcel; +import android.os.Parcelable; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.UiThread; +import android.support.annotation.VisibleForTesting; import android.support.v4.os.BuildCompat; import android.text.TextUtils; import android.util.Log; @@ -33,13 +37,16 @@ import com.android.tv.common.TvContentRatingCache; import com.android.tv.util.ImageLoader; import com.android.tv.util.Utils; +import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Objects; /** * A convenience class to create and insert program information entries into the database. */ -public final class Program implements Comparable<Program> { +public final class Program extends BaseProgram implements Comparable<Program>, Parcelable { private static final boolean DEBUG = false; private static final boolean DEBUG_DUMP_DESCRIPTION = false; private static final String TAG = "Program"; @@ -47,10 +54,12 @@ public final class Program implements Comparable<Program> { private static final String[] PROJECTION_BASE = { // Columns must match what is read in Program.fromCursor() TvContract.Programs._ID, + TvContract.Programs.COLUMN_PACKAGE_NAME, TvContract.Programs.COLUMN_CHANNEL_ID, TvContract.Programs.COLUMN_TITLE, TvContract.Programs.COLUMN_EPISODE_TITLE, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + TvContract.Programs.COLUMN_LONG_DESCRIPTION, TvContract.Programs.COLUMN_POSTER_ART_URI, TvContract.Programs.COLUMN_THUMBNAIL_URI, TvContract.Programs.COLUMN_CANONICAL_GENRE, @@ -58,10 +67,12 @@ public final class Program implements Comparable<Program> { TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, TvContract.Programs.COLUMN_VIDEO_WIDTH, - TvContract.Programs.COLUMN_VIDEO_HEIGHT + TvContract.Programs.COLUMN_VIDEO_HEIGHT, + TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA }; // Columns which is deprecated in NYC + @SuppressWarnings("deprecation") private static final String[] PROJECTION_DEPRECATED_IN_NYC = { TvContract.Programs.COLUMN_SEASON_NUMBER, TvContract.Programs.COLUMN_EPISODE_NUMBER @@ -70,7 +81,8 @@ public final class Program implements Comparable<Program> { private static final String[] PROJECTION_ADDED_IN_NYC = { TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, TvContract.Programs.COLUMN_SEASON_TITLE, - TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER + TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, + TvContract.Programs.COLUMN_RECORDING_PROHIBITED }; public static final String[] PROJECTION = createProjection(); @@ -82,6 +94,18 @@ public final class Program implements Comparable<Program> { } /** + * Returns the column index for {@code column}, -1 if the column doesn't exist. + */ + public static int getColumnIndex(String column) { + for (int i = 0; i < PROJECTION.length; ++i) { + if (PROJECTION[i].equals(column)) { + return i; + } + } + return -1; + } + + /** * Creates {@code Program} object from cursor. * * <p>The query that created the cursor MUST use {@link #PROJECTION}. @@ -91,10 +115,13 @@ public final class Program implements Comparable<Program> { Builder builder = new Builder(); int index = 0; builder.setId(cursor.getLong(index++)); + String packageName = cursor.getString(index++); + builder.setPackageName(packageName); builder.setChannelId(cursor.getLong(index++)); builder.setTitle(cursor.getString(index++)); builder.setEpisodeTitle(cursor.getString(index++)); builder.setDescription(cursor.getString(index++)); + builder.setLongDescription(cursor.getString(index++)); builder.setPosterArtUri(cursor.getString(index++)); builder.setThumbnailUri(cursor.getString(index++)); builder.setCanonicalGenres(cursor.getString(index++)); @@ -104,10 +131,15 @@ public final class Program implements Comparable<Program> { builder.setEndTimeUtcMillis(cursor.getLong(index++)); builder.setVideoWidth((int) cursor.getLong(index++)); builder.setVideoHeight((int) cursor.getLong(index++)); + if (Utils.isInBundledPackageSet(packageName)) { + InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); + } + index++; if (BuildCompat.isAtLeastN()) { builder.setSeasonNumber(cursor.getString(index++)); builder.setSeasonTitle(cursor.getString(index++)); builder.setEpisodeNumber(cursor.getString(index++)); + builder.setRecordingProhibited(cursor.getInt(index++) == 1); } else { builder.setSeasonNumber(cursor.getString(index++)); builder.setEpisodeNumber(cursor.getString(index++)); @@ -115,9 +147,55 @@ public final class Program implements Comparable<Program> { return builder.build(); } + public static Program fromParcel(Parcel in) { + Program program = new Program(); + program.mId = in.readLong(); + program.mPackageName = in.readString(); + program.mChannelId = in.readLong(); + program.mTitle = in.readString(); + program.mSeriesId = in.readString(); + program.mEpisodeTitle = in.readString(); + program.mSeasonNumber = in.readString(); + program.mSeasonTitle = in.readString(); + program.mEpisodeNumber = in.readString(); + program.mStartTimeUtcMillis = in.readLong(); + program.mEndTimeUtcMillis = in.readLong(); + program.mDescription = in.readString(); + program.mLongDescription = in.readString(); + program.mVideoWidth = in.readInt(); + program.mVideoHeight = in.readInt(); + program.mCriticScores = in.readArrayList(Thread.currentThread().getContextClassLoader()); + program.mPosterArtUri = in.readString(); + program.mThumbnailUri = in.readString(); + program.mCanonicalGenreIds = in.createIntArray(); + int length = in.readInt(); + if (length > 0) { + program.mContentRatings = new TvContentRating[length]; + for (int i = 0; i < length; ++i) { + program.mContentRatings[i] = TvContentRating.unflattenFromString(in.readString()); + } + } + program.mRecordingProhibited = in.readByte() != (byte) 0; + return program; + } + + public static final Parcelable.Creator<Program> CREATOR = new Parcelable.Creator<Program>() { + @Override + public Program createFromParcel(Parcel in) { + return Program.fromParcel(in); + } + + @Override + public Program[] newArray(int size) { + return new Program[size]; + } + }; + private long mId; + private String mPackageName; private long mChannelId; private String mTitle; + private String mSeriesId; private String mEpisodeTitle; private String mSeasonNumber; private String mSeasonTitle; @@ -125,17 +203,19 @@ public final class Program implements Comparable<Program> { private long mStartTimeUtcMillis; private long mEndTimeUtcMillis; private String mDescription; + private String mLongDescription; private int mVideoWidth; private int mVideoHeight; + private List<CriticScore> mCriticScores; private String mPosterArtUri; private String mThumbnailUri; private int[] mCanonicalGenreIds; private TvContentRating[] mContentRatings; + private boolean mRecordingProhibited; /** * TODO(DVR): Need to fill the following data. */ - private boolean mRecordable; private boolean mRecordingScheduled; private Program() { @@ -146,6 +226,13 @@ public final class Program implements Comparable<Program> { return mId; } + /** + * Returns the package name of this program. + */ + public String getPackageName() { + return mPackageName; + } + public long getChannelId() { return mChannelId; } @@ -153,6 +240,7 @@ public final class Program implements Comparable<Program> { /** * Returns {@code true} if this program is valid or {@code false} otherwise. */ + @Override public boolean isValid() { return mChannelId >= 0; } @@ -164,35 +252,77 @@ public final class Program implements Comparable<Program> { return program != null && program.isValid(); } + @Override public String getTitle() { return mTitle; } + /** + * Returns the series ID. + */ + @Override + public String getSeriesId() { + return mSeriesId; + } + + /** + * Returns the episode title. + */ public String getEpisodeTitle() { return mEpisodeTitle; } + /** + * Returns season number, episode number and episode title for display. + */ + @Override public String getEpisodeDisplayTitle(Context context) { - if (!TextUtils.isEmpty(mSeasonNumber) && !TextUtils.isEmpty(mEpisodeNumber) - && !TextUtils.isEmpty(mEpisodeTitle)) { - return String.format(context.getResources().getString(R.string.episode_format), - mSeasonNumber, mEpisodeNumber, mEpisodeTitle); + 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; } + @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); + } + } + + @Override public String getSeasonNumber() { return mSeasonNumber; } + @Override public String getEpisodeNumber() { return mEpisodeNumber; } + @Override public long getStartTimeUtcMillis() { return mStartTimeUtcMillis; } + @Override public long getEndTimeUtcMillis() { return mEndTimeUtcMillis; } @@ -200,14 +330,21 @@ public final class Program implements Comparable<Program> { /** * Returns the program duration. */ + @Override public long getDurationMillis() { return mEndTimeUtcMillis - mStartTimeUtcMillis; } + @Override public String getDescription() { return mDescription; } + @Override + public String getLongDescription() { + return mLongDescription; + } + public int getVideoWidth() { return mVideoWidth; } @@ -216,22 +353,40 @@ public final class Program implements Comparable<Program> { return mVideoHeight; } + /** + * Returns the list of Critic Scores for this program + */ + @Nullable + public List<CriticScore> getCriticScores() { + return mCriticScores; + } + public TvContentRating[] getContentRatings() { return mContentRatings; } + @Override public String getPosterArtUri() { return mPosterArtUri; } + @Override public String getThumbnailUri() { return mThumbnailUri; } /** + * Returns {@code true} if the recording of this program is prohibited. + */ + public boolean isRecordingProhibited() { + return mRecordingProhibited; + } + + /** * Returns array of canonical genres for this program. * This is expected to be called rarely. */ + @Nullable public String[] getCanonicalGenres() { if (mCanonicalGenreIds == null) { return null; @@ -244,6 +399,14 @@ public final class Program implements Comparable<Program> { } /** + * Returns array of canonical genre ID's for this program. + */ + @Override + public int[] getCanonicalGenreIds() { + return mCanonicalGenreIds; + } + + /** * Returns if this program has the genre. */ public boolean hasGenre(int genreId) { @@ -262,10 +425,12 @@ public final class Program implements Comparable<Program> { @Override public int hashCode() { + // Hash with all the properties because program ID can be invalid for the dummy programs. return Objects.hash(mChannelId, mStartTimeUtcMillis, mEndTimeUtcMillis, - mTitle, mEpisodeTitle, mDescription, mVideoWidth, mVideoHeight, - mPosterArtUri, mThumbnailUri, Arrays.hashCode(mContentRatings), - Arrays.hashCode(mCanonicalGenreIds), mSeasonNumber, mSeasonTitle, mEpisodeNumber); + mTitle, mSeriesId, mEpisodeTitle, mDescription, mLongDescription, mVideoWidth, + mVideoHeight, mPosterArtUri, mThumbnailUri, Arrays.hashCode(mContentRatings), + Arrays.hashCode(mCanonicalGenreIds), mSeasonNumber, mSeasonTitle, mEpisodeNumber, + mRecordingProhibited); } @Override @@ -273,13 +438,17 @@ public final class Program implements Comparable<Program> { if (!(other instanceof Program)) { return false; } + // Compare all the properties because program ID can be invalid for the dummy programs. Program program = (Program) other; - return mChannelId == program.mChannelId + return Objects.equals(mPackageName, program.mPackageName) + && mChannelId == program.mChannelId && mStartTimeUtcMillis == program.mStartTimeUtcMillis && mEndTimeUtcMillis == program.mEndTimeUtcMillis && Objects.equals(mTitle, program.mTitle) + && Objects.equals(mSeriesId, program.mSeriesId) && Objects.equals(mEpisodeTitle, program.mEpisodeTitle) && Objects.equals(mDescription, program.mDescription) + && Objects.equals(mLongDescription, program.mLongDescription) && mVideoWidth == program.mVideoWidth && mVideoHeight == program.mVideoHeight && Objects.equals(mPosterArtUri, program.mPosterArtUri) @@ -288,7 +457,8 @@ public final class Program implements Comparable<Program> { && Arrays.equals(mCanonicalGenreIds, program.mCanonicalGenreIds) && Objects.equals(mSeasonNumber, program.mSeasonNumber) && Objects.equals(mSeasonTitle, program.mSeasonTitle) - && Objects.equals(mEpisodeNumber, program.mEpisodeNumber); + && Objects.equals(mEpisodeNumber, program.mEpisodeNumber) + && mRecordingProhibited == program.mRecordingProhibited; } @Override @@ -299,9 +469,11 @@ public final class Program implements Comparable<Program> { @Override public String toString() { StringBuilder builder = new StringBuilder(); - builder.append("Program[" + mId + "]{") - .append("channelId=").append(mChannelId) + builder.append("Program[").append(mId) + .append("]{channelId=").append(mChannelId) + .append(", packageName=").append(mPackageName) .append(", title=").append(mTitle) + .append(", seriesId=").append(mSeriesId) .append(", episodeTitle=").append(mEpisodeTitle) .append(", seasonNumber=").append(mSeasonNumber) .append(", seasonTitle=").append(mSeasonTitle) @@ -314,9 +486,11 @@ public final class Program implements Comparable<Program> { .append(TvContentRatingCache.contentRatingsToString(mContentRatings)) .append(", posterArtUri=").append(mPosterArtUri) .append(", thumbnailUri=").append(mThumbnailUri) - .append(", canonicalGenres=").append(Arrays.toString(mCanonicalGenreIds)); + .append(", canonicalGenres=").append(Arrays.toString(mCanonicalGenreIds)) + .append(", recordingProhibited=").append(mRecordingProhibited); if (DEBUG_DUMP_DESCRIPTION) { - builder.append(", description=").append(mDescription); + builder.append(", description=").append(mDescription) + .append(", longDescription=").append(mLongDescription); } return builder.append("}").toString(); } @@ -327,8 +501,10 @@ public final class Program implements Comparable<Program> { } mId = other.mId; + mPackageName = other.mPackageName; mChannelId = other.mChannelId; mTitle = other.mTitle; + mSeriesId = other.mSeriesId; mEpisodeTitle = other.mEpisodeTitle; mSeasonNumber = other.mSeasonNumber; mSeasonTitle = other.mSeasonTitle; @@ -336,21 +512,37 @@ public final class Program implements Comparable<Program> { mStartTimeUtcMillis = other.mStartTimeUtcMillis; mEndTimeUtcMillis = other.mEndTimeUtcMillis; mDescription = other.mDescription; + mLongDescription = other.mLongDescription; mVideoWidth = other.mVideoWidth; mVideoHeight = other.mVideoHeight; + mCriticScores = other.mCriticScores; mPosterArtUri = other.mPosterArtUri; mThumbnailUri = other.mThumbnailUri; mCanonicalGenreIds = other.mCanonicalGenreIds; mContentRatings = other.mContentRatings; + mRecordingProhibited = other.mRecordingProhibited; + } + + /** + * Checks whether the program is episodic or not. + */ + public boolean isEpisodic() { + return mSeriesId != null; } + /** + * A Builder for the Program class + */ public static final class Builder { private final Program mProgram; - private long mId; + /** + * Creates a Builder for this Program class + */ public Builder() { mProgram = new Program(); // Fill initial data. + mProgram.mPackageName = null; mProgram.mChannelId = Channel.INVALID_ID; mProgram.mTitle = null; mProgram.mSeasonNumber = null; @@ -359,113 +551,258 @@ public final class Program implements Comparable<Program> { mProgram.mStartTimeUtcMillis = -1; mProgram.mEndTimeUtcMillis = -1; mProgram.mDescription = null; + mProgram.mLongDescription = null; + mProgram.mRecordingProhibited = false; + mProgram.mCriticScores = null; } + /** + * Creates a builder for this Program class + * by setting default values equivalent to another Program + * @param other the program to be copied + */ + @VisibleForTesting public Builder(Program other) { mProgram = new Program(); mProgram.copyFrom(other); } + /** + * Sets the ID of this program + * @param id the ID + * @return a reference to this object + */ public Builder setId(long id) { mProgram.mId = id; return this; } + /** + * Sets the package name for this program + * @param packageName the package name + * @return a reference to this object + */ + public Builder setPackageName(String packageName){ + mProgram.mPackageName = packageName; + return this; + } + + /** + * Sets the channel ID for this program + * @param channelId the channel ID + * @return a reference to this object + */ public Builder setChannelId(long channelId) { mProgram.mChannelId = channelId; return this; } + /** + * Sets the program title + * @param title the title + * @return a reference to this object + */ public Builder setTitle(String title) { mProgram.mTitle = title; return this; } + /** + * Sets the series ID. + * @param seriesId the series ID + * @return a reference to this object + */ + public Builder setSeriesId(String seriesId) { + mProgram.mSeriesId = seriesId; + return this; + } + + /** + * Sets the episode title if this is a series program + * @param episodeTitle the episode title + * @return a reference to this object + */ public Builder setEpisodeTitle(String episodeTitle) { mProgram.mEpisodeTitle = episodeTitle; return this; } + /** + * Sets the season number if this is a series program + * @param seasonNumber the season number + * @return a reference to this object + */ public Builder setSeasonNumber(String seasonNumber) { mProgram.mSeasonNumber = seasonNumber; return this; } + + /** + * Sets the season title if this is a series program + * @param seasonTitle the season title + * @return a reference to this object + */ public Builder setSeasonTitle(String seasonTitle) { mProgram.mSeasonTitle = seasonTitle; return this; } + /** + * Sets the episode number if this is a series program + * @param episodeNumber the episode number + * @return a reference to this object + */ public Builder setEpisodeNumber(String episodeNumber) { mProgram.mEpisodeNumber = episodeNumber; return this; } + /** + * Sets the start time of this program + * @param startTimeUtcMillis the start time in UTC milliseconds + * @return a reference to this object + */ public Builder setStartTimeUtcMillis(long startTimeUtcMillis) { mProgram.mStartTimeUtcMillis = startTimeUtcMillis; return this; } + /** + * Sets the end time of this program + * @param endTimeUtcMillis the end time in UTC milliseconds + * @return a reference to this object + */ public Builder setEndTimeUtcMillis(long endTimeUtcMillis) { mProgram.mEndTimeUtcMillis = endTimeUtcMillis; return this; } + /** + * Sets a description + * @param description the description + * @return a reference to this object + */ public Builder setDescription(String description) { mProgram.mDescription = description; return this; } + /** + * Sets a long description + * @param longDescription the long description + * @return a reference to this object + */ + public Builder setLongDescription(String longDescription) { + mProgram.mLongDescription = longDescription; + return this; + } + + /** + * Defines the video width of this program + * @param width + * @return a reference to this object + */ public Builder setVideoWidth(int width) { mProgram.mVideoWidth = width; return this; } + /** + * Defines the video height of this program + * @param height + * @return a reference to this object + */ public Builder setVideoHeight(int height) { mProgram.mVideoHeight = height; return this; } + /** + * Sets the content ratings for this program + * @param contentRatings the content ratings + * @return a reference to this object + */ public Builder setContentRatings(TvContentRating[] contentRatings) { mProgram.mContentRatings = contentRatings; return this; } + /** + * Sets the poster art URI + * @param posterArtUri the poster art URI + * @return a reference to this object + */ public Builder setPosterArtUri(String posterArtUri) { mProgram.mPosterArtUri = posterArtUri; return this; } + /** + * Sets the thumbnail URI + * @param thumbnailUri the thumbnail URI + * @return a reference to this object + */ public Builder setThumbnailUri(String thumbnailUri) { mProgram.mThumbnailUri = thumbnailUri; return this; } + /** + * Sets the canonical genres by id + * @param genres the genres + * @return a reference to this object + */ public Builder setCanonicalGenres(String genres) { - if (TextUtils.isEmpty(genres)) { - return this; - } - String[] canonicalGenres = TvContract.Programs.Genres.decode(genres); - if (canonicalGenres.length > 0) { - int[] temp = new int[canonicalGenres.length]; - int i = 0; - for (String canonicalGenre : canonicalGenres) { - int genreId = GenreItems.getId(canonicalGenre); - if (genreId == GenreItems.ID_ALL_CHANNELS) { - // Skip if the genre is unknown. - continue; - } - temp[i++] = genreId; - } - if (i < canonicalGenres.length) { - temp = Arrays.copyOf(temp, i); + mProgram.mCanonicalGenreIds = Utils.getCanonicalGenreIds(genres); + return this; + } + + /** + * Sets the recording prohibited flag + * @param recordingProhibited recording prohibited flag + * @return a reference to this object + */ + public Builder setRecordingProhibited(boolean recordingProhibited) { + mProgram.mRecordingProhibited = recordingProhibited; + return this; + } + + /** + * Adds a critic score + * @param criticScore the critic score + * @return a reference to this object + */ + public Builder addCriticScore(CriticScore criticScore) { + if (criticScore.score != null) { + if (mProgram.mCriticScores == null) { + mProgram.mCriticScores = new ArrayList<>(); } - mProgram.mCanonicalGenreIds=temp; + mProgram.mCriticScores.add(criticScore); } return this; } + /** + * Sets the critic scores + * @param criticScores the critic scores + * @return a reference to this objects + */ + public Builder setCriticScores(List<CriticScore> criticScores) { + mProgram.mCriticScores = criticScores; + return this; + } + + /** + * Returns a reference to the Program object being constructed + * @return the Program object constructed + */ public Program build() { + // Generate the series ID for the episodic program of other TV input. + if (TextUtils.isEmpty(mProgram.mSeriesId) + && !TextUtils.isEmpty(mProgram.mEpisodeNumber)) { + setSeriesId(BaseProgram.generateSeriesId(mProgram.mPackageName, mProgram.mTitle)); + } Program program = new Program(); program.copyFrom(mProgram); return program; @@ -509,4 +846,96 @@ public final class Program implements Comparable<Program> { } return isDuplicate; } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int paramInt) { + out.writeLong(mId); + out.writeString(mPackageName); + out.writeLong(mChannelId); + out.writeString(mTitle); + out.writeString(mSeriesId); + out.writeString(mEpisodeTitle); + out.writeString(mSeasonNumber); + out.writeString(mSeasonTitle); + out.writeString(mEpisodeNumber); + out.writeLong(mStartTimeUtcMillis); + out.writeLong(mEndTimeUtcMillis); + out.writeString(mDescription); + out.writeString(mLongDescription); + out.writeInt(mVideoWidth); + out.writeInt(mVideoHeight); + out.writeList(mCriticScores); + out.writeString(mPosterArtUri); + out.writeString(mThumbnailUri); + out.writeIntArray(mCanonicalGenreIds); + out.writeInt(mContentRatings == null ? 0 : mContentRatings.length); + if (mContentRatings != null) { + for (TvContentRating rating : mContentRatings) { + out.writeString(rating.flattenToString()); + } + } + out.writeByte((byte) (mRecordingProhibited ? 1 : 0)); + } + + /** + * Holds one type of critic score and its source. + */ + public static final class CriticScore implements Serializable, Parcelable { + /** + * The source of the rating. + */ + public final String source; + /** + * The score. + */ + public final String score; + /** + * The url of the logo image + */ + public final String logoUrl; + + public static final Parcelable.Creator<CriticScore> CREATOR = + new Parcelable.Creator<CriticScore>() { + @Override + public CriticScore createFromParcel(Parcel in) { + String source = in.readString(); + String score = in.readString(); + String logoUri = in.readString(); + return new CriticScore(source, score, logoUri); + } + + @Override + public CriticScore[] newArray(int size) { + return new CriticScore[size]; + } + }; + + /** + * Constructor for this class. + * @param source the source of the rating + * @param score the score + */ + public CriticScore(String source, String score, String logoUrl) { + this.source = source; + this.score = score; + this.logoUrl = logoUrl; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int i) { + out.writeString(source); + out.writeString(score); + out.writeString(logoUrl); + } + } } diff --git a/src/com/android/tv/data/ProgramDataManager.java b/src/com/android/tv/data/ProgramDataManager.java index 88db91b9..d2af33a7 100644 --- a/src/com/android/tv/data/ProgramDataManager.java +++ b/src/com/android/tv/data/ProgramDataManager.java @@ -36,6 +36,7 @@ import android.util.LruCache; import com.android.tv.common.MemoryManageable; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.epg.EpgFetcher; +import com.android.tv.experiments.Experiments; import com.android.tv.util.AsyncDbTask; import com.android.tv.util.Clock; import com.android.tv.util.MultiLongSparseArray; @@ -108,17 +109,17 @@ public class ProgramDataManager implements MemoryManageable { private boolean mPauseProgramUpdate = false; private final LruCache<Long, Program> mZeroLengthProgramCache = new LruCache<>(10); - - // TODO: Change to final. - private EpgFetcher mEpgFetcher; + private final EpgFetcher mEpgFetcher; public ProgramDataManager(Context context) { - this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper()); - mEpgFetcher = new EpgFetcher(context); + this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper(), + EpgFetcher.getInstance(context)); } @VisibleForTesting - ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper) { + ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper, + EpgFetcher epgFetcher) { + mEpgFetcher = epgFetcher; mClock = time; mContentResolver = contentResolver; mHandler = new MyHandler(looper); @@ -174,7 +175,7 @@ public class ProgramDataManager implements MemoryManageable { } mContentResolver.registerContentObserver(Programs.CONTENT_URI, true, mProgramObserver); - if (mEpgFetcher != null) { + if (mEpgFetcher != null && Experiments.CLOUD_EPG.get()) { mEpgFetcher.start(); } } @@ -624,22 +625,6 @@ public class ProgramDataManager implements MemoryManageable { } } - /** - * Gets an single {@link Program} from {@link TvContract.Programs#CONTENT_URI}. - */ - public static class QueryProgramTask extends AsyncDbTask.AsyncQueryItemTask<Program> { - - public QueryProgramTask(ContentResolver contentResolver, long programId) { - super(contentResolver, TvContract.buildProgramUri(programId), Program.PROJECTION, null, - null, null); - } - - @Override - protected Program fromCursor(Cursor c) { - return Program.fromCursor(c); - } - } - private class MyHandler extends Handler { public MyHandler(Looper looper) { super(looper); diff --git a/src/com/android/tv/data/StreamInfo.java b/src/com/android/tv/data/StreamInfo.java index df842737..fe461f14 100644 --- a/src/com/android/tv/data/StreamInfo.java +++ b/src/com/android/tv/data/StreamInfo.java @@ -16,6 +16,8 @@ package com.android.tv.data; +import android.media.tv.TvContentRating; + public interface StreamInfo { int VIDEO_DEFINITION_LEVEL_UNKNOWN = 0; int VIDEO_DEFINITION_LEVEL_SD = 1; @@ -26,6 +28,7 @@ public interface StreamInfo { int AUDIO_CHANNEL_COUNT_UNKNOWN = 0; Channel getCurrentChannel(); + TvContentRating getBlockedContentRating(); int getVideoWidth(); int getVideoHeight(); diff --git a/src/com/android/tv/data/WatchedHistoryManager.java b/src/com/android/tv/data/WatchedHistoryManager.java index fc6672d2..59319338 100644 --- a/src/com/android/tv/data/WatchedHistoryManager.java +++ b/src/com/android/tv/data/WatchedHistoryManager.java @@ -219,7 +219,7 @@ public class WatchedHistoryManager { } Long duration = durationMap.get(channelId); if (duration == null) { - duration = 0l; + duration = 0L; } if (duration >= RECENT_CHANNEL_THRESHOLD_MS) { continue; diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java index 9ff527d8..3b093b6a 100644 --- a/src/com/android/tv/data/epg/EpgFetcher.java +++ b/src/com/android/tv/data/epg/EpgFetcher.java @@ -16,37 +16,48 @@ package com.android.tv.data.epg; +import android.Manifest; +import android.annotation.SuppressLint; import android.content.ContentProviderOperation; -import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.OperationApplicationException; +import android.content.pm.PackageManager; import android.database.Cursor; +import android.location.Address; +import android.media.tv.TvContentRating; import android.media.tv.TvContract; import android.media.tv.TvContract.Programs; +import android.media.tv.TvContract.Programs.Genres; import android.media.tv.TvInputInfo; -import android.media.tv.TvInputManager.TvInputCallback; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.os.RemoteException; import android.preference.PreferenceManager; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.os.BuildCompat; import android.text.TextUtils; import android.util.Log; -import com.android.tv.Features; 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.data.InternalDataUtils; +import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.util.LocationUtils; import com.android.tv.util.RecurringRunner; -import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -61,116 +72,275 @@ public class EpgFetcher { private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4); private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1); + private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10); + private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1); private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30); private static final int BATCH_OPERATION_COUNT = 100; + private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry(); + private static final String CONTENT_RATING_SEPARATOR = ","; + // Value: Long private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP = "com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp"; + // Value: String + private static final String KEY_LAST_LINEUP_ID = + "com.android.tv.data.epg.EpgFetcher.LastLineupId"; + + private static EpgFetcher sInstance; private final Context mContext; - private final TvInputManagerHelper mInputHelper; - private final TvInputCallback mInputCallback; - private HandlerThread mHandlerThread; + private final ChannelDataManager mChannelDataManager; + private final EpgReader mEpgReader; private EpgFetcherHandler mHandler; private RecurringRunner mRecurringRunner; + private boolean mStarted; private long mLastEpgTimestamp = -1; + private String mLineupId; + + public static synchronized EpgFetcher getInstance(Context context) { + if (sInstance == null) { + sInstance = new EpgFetcher(context.getApplicationContext()); + } + return sInstance; + } + + /** + * Creates and returns {@link EpgReader}. + */ + public static EpgReader createEpgReader(Context context) { + return new StubEpgReader(context); + } - public EpgFetcher(Context context) { + private EpgFetcher(Context context) { mContext = context; - mInputHelper = TvApplication.getSingletons(mContext).getTvInputManagerHelper(); - mInputCallback = new TvInputCallback() { + mEpgReader = new StubEpgReader(mContext); + mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); + mChannelDataManager.addListener(new ChannelDataManager.Listener() { @Override - public void onInputAdded(String inputId) { - if (Utils.isInternalTvInput(mContext, inputId)) { - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessage(MSG_FETCH_EPG); - } + public void onLoadFinished() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); + handleChannelChanged(); + } + + @Override + public void onChannelListUpdated() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); + handleChannelChanged(); + } + + @Override + public void onChannelBrowsableChanged() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()"); + handleChannelChanged(); + } + }); + } + + private void handleChannelChanged() { + if (mStarted) { + if (needToStop()) { + stop(); } - }; + } else { + start(); + } + } + + private boolean needToStop() { + return !canStart(); + } + + private boolean canStart() { + if (DEBUG) Log.d(TAG, "canStart()"); + boolean hasInternalTunerChannel = false; + for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper() + .getTvInputInfos(true, true)) { + String inputId = input.getId(); + if (Utils.isInternalTvInput(mContext, inputId) + && mChannelDataManager.getChannelCountForInput(inputId) > 0) { + hasInternalTunerChannel = true; + break; + } + } + if (!hasInternalTunerChannel) { + if (DEBUG) Log.d(TAG, "No internal tuner channels."); + return false; + } + + if (!TextUtils.isEmpty(getLastLineupId())) { + return true; + } + if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + if (DEBUG) Log.d(TAG, "No permission to check the current location."); + return false; + } + + try { + Address address = LocationUtils.getCurrentAddress(mContext); + if (address != null + && !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { + if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode()); + return false; + } + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location", e); + return false; + } catch (IOException e) { + Log.w(TAG, "IO Exception when getting the current location", e); + } + return true; } /** * Starts fetching EPG. */ + @MainThread public void start() { - if (DEBUG) Log.d(TAG, "Request to start fetching EPG."); - if (!Features.FETCH_EPG.isEnabled(mContext)) { + if (DEBUG) Log.d(TAG, "start()"); + if (mStarted) { + if (DEBUG) Log.d(TAG, "EpgFetcher thread already started."); return; } - if (mHandlerThread == null) { - mHandlerThread = new HandlerThread("EpgFetcher"); - mHandlerThread.start(); - mHandler = new EpgFetcherHandler(mHandlerThread.getLooper(), this); - mInputHelper.addCallback(mInputCallback); - mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS, - new Runnable() { - @Override - public void run() { - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessage(MSG_FETCH_EPG); - } - }, null); - mRecurringRunner.start(); + if (!canStart()) { + return; + } + mStarted = true; + if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread."); + HandlerThread handlerThread = new HandlerThread("EpgFetcher"); + handlerThread.start(); + mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this); + mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS, + new EpgRunner(), null); + mRecurringRunner.start(); + if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully."); + } + + /** + * Starts fetching EPG immediately if possible without waiting for the timer. + */ + @MainThread + public void startImmediately() { + start(); + if (mStarted) { + if (DEBUG) Log.d(TAG, "Starting fetcher immediately"); + fetchEpg(); } } /** * Stops fetching EPG. */ + @MainThread public void stop() { - if (mHandlerThread == null) { + if (DEBUG) Log.d(TAG, "stop()"); + if (!mStarted) { return; } + mStarted = false; mRecurringRunner.stop(); mHandler.removeCallbacksAndMessages(null); - mHandler = null; - mHandlerThread.quit(); - mHandlerThread = null; + mHandler.getLooper().quit(); + } + + private void fetchEpg() { + fetchEpg(0); + } + + private void fetchEpg(long delay) { + mHandler.removeMessages(MSG_FETCH_EPG); + mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay); } private void onFetchEpg() { if (DEBUG) Log.d(TAG, "Start fetching EPG."); - // Check for the internal inputs. - boolean hasInternalInput = false; - for (TvInputInfo input : mInputHelper.getTvInputInfos(true, true)) { - if (Utils.isInternalTvInput(mContext, input.getId())) { - hasInternalInput = true; - break; - } - } - if (!hasInternalInput) { - if (DEBUG) Log.d(TAG, "No internal input found."); - return; - } - // Check if EPG reader is available. - EpgReader epgReader = new StubEpgReader(mContext); - if (!epgReader.isAvailable()) { + if (!mEpgReader.isAvailable()) { if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available."); - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, EPG_READER_INIT_WAIT_MS); + fetchEpg(EPG_READER_INIT_WAIT_MS); return; } + String lineupId = getLastLineupId(); + if (lineupId == null) { + Address address; + try { + address = LocationUtils.getCurrentAddress(mContext); + } catch (IOException e) { + if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); + fetchEpg(LOCATION_ERROR_WAIT_MS); + return; + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location."); + return; + } + if (address == null) { + if (DEBUG) Log.d(TAG, "Null address returned."); + fetchEpg(LOCATION_INIT_WAIT_MS); + return; + } + if (DEBUG) Log.d(TAG, "Current location is " + address); + + lineupId = getLineupForAddress(address); + if (lineupId != null) { + if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address); + setLastLineupId(lineupId); + } else { + if (DEBUG) Log.d(TAG, "No lineup found for " + address); + return; + } + } + // Check the EPG Timestamp. - long epgTimestamp = epgReader.getEpgTimestamp(); + long epgTimestamp = mEpgReader.getEpgTimestamp(); if (epgTimestamp <= getLastUpdatedEpgTimestamp()) { if (DEBUG) Log.d(TAG, "No new EPG."); return; } - List<Channel> channels = epgReader.getChannels(); + boolean updated = false; + List<Channel> channels = mEpgReader.getChannels(lineupId); for (Channel channel : channels) { - List<Program> programs = new ArrayList<>(epgReader.getPrograms(channel.getId())); + List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId())); Collections.sort(programs); if (DEBUG) { - Log.d(TAG, "Fetching " + programs.size() + " programs for channel " + channel); + Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel); + } + if (updateEpg(channel.getId(), programs)) { + updated = true; } - updateEpg(channel.getId(), programs); } + final boolean epgUpdated = updated; setLastUpdatedEpgTimestamp(epgTimestamp); + mHandler.removeMessages(MSG_FETCH_EPG); + if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + } + + @Nullable + private String getLineupForAddress(Address address) { + String lineup = null; + if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) { + String postalCode = address.getPostalCode(); + if (!TextUtils.isEmpty(postalCode)) { + lineup = getLineupForPostalCode(postalCode); + } + } + return lineup; + } + + @Nullable + private String getLineupForPostalCode(String postalCode) { + List<Lineup> lineups = mEpgReader.getLineups(postalCode); + for (Lineup lineup : lineups) { + // TODO(EPG): handle more than OTA digital + if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) { + if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")"); + return lineup.id; + } + } + return null; } private long getLastUpdatedEpgTimestamp() { @@ -184,18 +354,33 @@ public class EpgFetcher { private void setLastUpdatedEpgTimestamp(long timestamp) { mLastEpgTimestamp = timestamp; PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp); + KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit(); + } + + private String getLastLineupId() { + if (mLineupId == null) { + mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext) + .getString(KEY_LAST_LINEUP_ID, null); + } + if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId); + return mLineupId; } - private void updateEpg(long channelId, List<Program> newPrograms) { + private void setLastLineupId(String lineupId) { + mLineupId = lineupId; + PreferenceManager.getDefaultSharedPreferences(mContext).edit() + .putString(KEY_LAST_LINEUP_ID, lineupId).commit(); + } + + private boolean updateEpg(long channelId, List<Program> newPrograms) { final int fetchedProgramsCount = newPrograms.size(); if (fetchedProgramsCount == 0) { - return; + return false; } + boolean updated = false; long startTimeMs = System.currentTimeMillis(); long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION; - List<Program> oldPrograms = queryPrograms(mContext.getContentResolver(), channelId, - startTimeMs, endTimeMs); + List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs); Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null; int oldProgramsIndex = 0; int newProgramsIndex = 0; @@ -224,15 +409,13 @@ public class EpgFetcher { oldProgramsIndex++; newProgramsIndex++; } else if (isSameTitleAndOverlap(oldProgram, newProgram)) { - if (!oldProgram.equals(oldProgram)) { - // Partial match. Update the old program with the new one. - // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There - // could be application specific settings which belong to the old program. - ops.add(ContentProviderOperation.newUpdate( - TvContract.buildProgramUri(oldProgram.getId())) - .withValues(toContentValues(newProgram)) - .build()); - } + // Partial match. Update the old program with the new one. + // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There + // could be application specific settings which belong to the old program. + ops.add(ContentProviderOperation.newUpdate( + TvContract.buildProgramUri(oldProgram.getId())) + .withValues(toContentValues(newProgram)) + .build()); oldProgramsIndex++; newProgramsIndex++; } else if (oldProgram.getEndTimeUtcMillis() @@ -271,25 +454,26 @@ public class EpgFetcher { } } mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + updated = true; } catch (RemoteException | OperationApplicationException e) { Log.e(TAG, "Failed to insert programs.", e); - return; + return updated; } ops.clear(); } } if (DEBUG) { - Log.d(TAG, "Fetched " + fetchedProgramsCount + " programs for channel " + channelId); + Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); } + return updated; } - private List<Program> queryPrograms(ContentResolver contentResolver, long channelId, - long startTimeMs, long endTimeMs) { + private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) { try (Cursor c = mContext.getContentResolver().query( TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { if (c == null) { - return Collections.EMPTY_LIST; + return Collections.emptyList(); } ArrayList<Program> programs = new ArrayList<>(); while (c.moveToNext()) { @@ -313,18 +497,48 @@ public class EpgFetcher { && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); } + @SuppressLint("InlinedApi") + @SuppressWarnings("deprecation") private static ContentValues toContentValues(Program program) { ContentValues values = new ContentValues(); values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId()); putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle()); putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle()); - putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); - putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); + if (BuildCompat.isAtLeastN()) { + putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, + program.getSeasonNumber()); + putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, + program.getEpisodeNumber()); + } else { + putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber()); + putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber()); + } putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription()); putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri()); + putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri()); + String[] canonicalGenres = program.getCanonicalGenres(); + if (canonicalGenres != null && canonicalGenres.length > 0) { + putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, + Genres.encode(canonicalGenres)); + } else { + putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); + } + TvContentRating[] ratings = program.getContentRatings(); + if (ratings != null && ratings.length > 0) { + StringBuilder sb = new StringBuilder(ratings[0].flattenToString()); + for (int i = 1; i < ratings.length; ++i) { + sb.append(CONTENT_RATING_SEPARATOR); + sb.append(ratings[i].flattenToString()); + } + putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString()); + } else { + putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, ""); + } values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, program.getStartTimeUtcMillis()); values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis()); + putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA, + InternalDataUtils.serializeInternalProviderData(program)); return values; } @@ -336,6 +550,14 @@ public class EpgFetcher { } } + private static void putValue(ContentValues contentValues, String key, byte[] value) { + if (value == null || value.length == 0) { + contentValues.putNull(key); + } else { + contentValues.put(key, value); + } + } + private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> { public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) { super(looper, ref); @@ -353,4 +575,11 @@ public class EpgFetcher { } } } + + private class EpgRunner implements Runnable { + @Override + public void run() { + fetchEpg(); + } + } } diff --git a/src/com/android/tv/data/epg/EpgReader.java b/src/com/android/tv/data/epg/EpgReader.java index 1c7712f4..4f3b6f52 100644 --- a/src/com/android/tv/data/epg/EpgReader.java +++ b/src/com/android/tv/data/epg/EpgReader.java @@ -16,10 +16,13 @@ package com.android.tv.data.epg; +import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import com.android.tv.data.Channel; +import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.dvr.SeriesInfo; import java.util.List; @@ -42,7 +45,12 @@ public interface EpgReader { /** * Returns the channels list. */ - List<Channel> getChannels(); + List<Channel> getChannels(@NonNull String lineupId); + + /** + * Returns the lineups list. + */ + List<Lineup> getLineups(@NonNull String postalCode); /** * Returns the programs for the given channel. The result is sorted by the start time. @@ -50,4 +58,9 @@ public interface EpgReader { * TvProvider. */ List<Program> getPrograms(long channelId); + + /** + * Returns the series information for the given series ID. + */ + SeriesInfo getSeriesInfo(String seriesId); } diff --git a/src/com/android/tv/data/epg/StubEpgReader.java b/src/com/android/tv/data/epg/StubEpgReader.java index 2896e8e5..64093f89 100644 --- a/src/com/android/tv/data/epg/StubEpgReader.java +++ b/src/com/android/tv/data/epg/StubEpgReader.java @@ -19,7 +19,9 @@ package com.android.tv.data.epg; import android.content.Context; import com.android.tv.data.Channel; +import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.dvr.SeriesInfo; import java.util.Collections; import java.util.List; @@ -28,7 +30,7 @@ import java.util.List; * A stub class to read EPG. */ public class StubEpgReader implements EpgReader{ - public StubEpgReader(Context context) { + public StubEpgReader(@SuppressWarnings("unused") Context context) { } @Override @@ -42,12 +44,22 @@ public class StubEpgReader implements EpgReader{ } @Override - public List<Channel> getChannels() { - return Collections.EMPTY_LIST; + public List<Channel> getChannels(String lineupId) { + return Collections.emptyList(); + } + + @Override + public List<Lineup> getLineups(String postalCode) { + return Collections.emptyList(); } @Override public List<Program> getPrograms(long channelId) { - return Collections.EMPTY_LIST; + return Collections.emptyList(); + } + + @Override + public SeriesInfo getSeriesInfo(String seriesId) { + return null; } } diff --git a/src/com/android/tv/dialog/PinDialogFragment.java b/src/com/android/tv/dialog/PinDialogFragment.java index 3952bb0b..d9d6c73f 100644 --- a/src/com/android/tv/dialog/PinDialogFragment.java +++ b/src/com/android/tv/dialog/PinDialogFragment.java @@ -75,6 +75,11 @@ public class PinDialogFragment extends SafeDismissDialogFragment { // PIN code dialog for checking old PIN. This is internal only. private static final int PIN_DIALOG_TYPE_OLD_PIN = 4; + /** + * PIN code dialog for unlocking DVR playback + */ + public static final int PIN_DIALOG_TYPE_UNLOCK_DVR = 5; + private static final int PIN_DIALOG_RESULT_SUCCESS = 0; private static final int PIN_DIALOG_RESULT_FAIL = 1; @@ -104,14 +109,20 @@ public class PinDialogFragment extends SafeDismissDialogFragment { private SharedPreferences mSharedPreferences; private String mPrevPin; private String mPin; + private String mRatingString; private int mWrongPinCount; private long mDisablePinUntil; private final Handler mHandler = new Handler(); public PinDialogFragment(int type, ResultListener listener) { + this(type, listener, null); + } + + public PinDialogFragment(int type, ResultListener listener, String rating) { mType = type; mListener = listener; mRetCode = PIN_DIALOG_RESULT_FAIL; + mRatingString = rating; } @Override @@ -174,6 +185,9 @@ public class PinDialogFragment extends SafeDismissDialogFragment { case PIN_DIALOG_TYPE_UNLOCK_PROGRAM: mTitleView.setText(R.string.pin_enter_unlock_program); break; + case PIN_DIALOG_TYPE_UNLOCK_DVR: + mTitleView.setText(getString(R.string.pin_enter_unlock_dvr, mRatingString)); + break; case PIN_DIALOG_TYPE_ENTER_PIN: mTitleView.setText(R.string.pin_enter_pin); break; @@ -269,6 +283,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment { switch (mType) { case PIN_DIALOG_TYPE_UNLOCK_CHANNEL: case PIN_DIALOG_TYPE_UNLOCK_PROGRAM: + case PIN_DIALOG_TYPE_UNLOCK_DVR: case PIN_DIALOG_TYPE_ENTER_PIN: // TODO: Implement limited number of retrials and timeout logic. if (TextUtils.isEmpty(getPin()) || pin.equals(getPin())) { diff --git a/src/com/android/tv/dialog/SafeDismissDialogFragment.java b/src/com/android/tv/dialog/SafeDismissDialogFragment.java index 1569f0a9..f671a87d 100644 --- a/src/com/android/tv/dialog/SafeDismissDialogFragment.java +++ b/src/com/android/tv/dialog/SafeDismissDialogFragment.java @@ -47,7 +47,9 @@ public abstract class SafeDismissDialogFragment extends DialogFragment public void onAttach(Activity activity) { super.onAttach(activity); mAttached = true; - mActivity = (MainActivity) activity; + if (activity instanceof MainActivity) { + mActivity = (MainActivity) activity; + } mTracker = TvApplication.getSingletons(activity).getTracker(); if (mDismissPending) { mDismissPending = false; @@ -100,7 +102,7 @@ public abstract class SafeDismissDialogFragment extends DialogFragment public boolean onKeyUp(int keyCode, KeyEvent event) { // When a dialog is showing, key events are handled by the dialog instead of // MainActivity. Therefore, unless a key is a global key, it should be handled here. - if (mAttached && keyCode == KeyEvent.KEYCODE_SEARCH) { + if (mAttached && keyCode == KeyEvent.KEYCODE_SEARCH && mActivity != null) { mActivity.showSearchActivity(); return true; } diff --git a/src/com/android/tv/dvr/BaseDvrDataManager.java b/src/com/android/tv/dvr/BaseDvrDataManager.java index 0fb469be..89661df3 100644 --- a/src/com/android/tv/dvr/BaseDvrDataManager.java +++ b/src/com/android/tv/dvr/BaseDvrDataManager.java @@ -20,17 +20,24 @@ 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.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; /** * Base implementation of @{link DataManagerInternal}. @@ -42,8 +49,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 +64,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 +96,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,71 +116,124 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } /** - * Calls {@link RecordedProgramListener#onRecordedProgramAdded(RecordedProgram)} + * 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#onRecordedProgramsAdded} * for each listener. */ - protected final void notifyRecordedProgramAdded(RecordedProgram recordedProgram) { + protected final void notifyRecordedProgramsAdded(RecordedProgram... recordedPrograms) { for (RecordedProgramListener l : mRecordedProgramListeners) { - if (DEBUG) Log.d(TAG, "notify " + l + "added " + recordedProgram); - l.onRecordedProgramAdded(recordedProgram); + if (DEBUG) Log.d(TAG, "notify " + l + " added " + Arrays.asList(recordedPrograms)); + l.onRecordedProgramsAdded(recordedPrograms); } } /** - * Calls {@link RecordedProgramListener#onRecordedProgramChanged(RecordedProgram)} + * Calls {@link RecordedProgramListener#onRecordedProgramsChanged} * for each listener. */ - protected final void notifyRecordedProgramChanged(RecordedProgram recordedProgram) { + protected final void notifyRecordedProgramsChanged(RecordedProgram... recordedPrograms) { for (RecordedProgramListener l : mRecordedProgramListeners) { - if (DEBUG) Log.d(TAG, "notify " + l + "changed " + recordedProgram); - l.onRecordedProgramChanged(recordedProgram); + if (DEBUG) Log.d(TAG, "notify " + l + " changed " + Arrays.asList(recordedPrograms)); + l.onRecordedProgramsChanged(recordedPrograms); } } /** - * Calls {@link RecordedProgramListener#onRecordedProgramRemoved(RecordedProgram)} + * Calls {@link RecordedProgramListener#onRecordedProgramsRemoved} * for each listener. */ - protected final void notifyRecordedProgramRemoved(RecordedProgram recordedProgram) { + protected final void notifyRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { for (RecordedProgramListener l : mRecordedProgramListeners) { - if (DEBUG) Log.d(TAG, "notify " + l + "removed " + recordedProgram); - l.onRecordedProgramRemoved(recordedProgram); + if (DEBUG) Log.d(TAG, "notify " + l + " removed " + Arrays.asList(recordedPrograms)); + l.onRecordedProgramsRemoved(recordedPrograms); + } + } + + /** + * 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 " + Arrays.asList(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 " + Arrays.asList(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 " + Arrays.asList(seriesRecordings)); + l.onSeriesRecordingChanged(seriesRecordings); } } /** - * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded(ScheduledRecording)} + * 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); + if (DEBUG) Log.d(TAG, "notify " + l + " added " + Arrays.asList(scheduledRecording)); l.onScheduledRecordingAdded(scheduledRecording); } } /** - * 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 " + Arrays.asList(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); + if (DEBUG) Log.d(TAG, "notify " + l + " changed " + Arrays.asList(scheduledRecording)); l.onScheduledRecordingStatusChanged(scheduledRecording); } } @@ -155,16 +253,70 @@ 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> 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)); + 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(); + } + + @NonNull + @Override + public Collection<Long> getDisallowedProgramIds() { + return mDeletedScheduleMap.keySet(); } - protected abstract List<ScheduledRecording> getRecordingsWithState(int state); + /** + * 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); + if (seriesRecording == null) { + return Collections.emptyList(); + } + List<RecordedProgram> result = new ArrayList<>(); + for (RecordedProgram r : getRecordedPrograms()) { + if (seriesRecording.getSeriesId().equals(r.getSeriesId())) { + result.add(r); + } + } + return result; + } + + @Override + public void forgetStorage(String inputId) { } } 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..06613667 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,39 @@ 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(); + + /** * Returns started recordings that expired. */ List<ScheduledRecording> getStartedRecordings(); @@ -54,9 +79,14 @@ public interface DvrDataManager { List<ScheduledRecording> getNonStartedScheduledRecordings(); /** - * Returns season recordings. + * Returns series recordings. + */ + List<SeriesRecording> getSeriesRecordings(); + + /** + * Returns series recordings from the given input. */ - List<SeasonRecording> getSeasonRecordings(); + List<SeriesRecording> getSeriesRecordings(String inputId); /** * Returns the next start time after {@code time} or {@link #NEXT_START_TIME_NOT_FOUND} @@ -67,15 +97,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 +160,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,19 +187,78 @@ 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); + void onRecordedProgramsAdded(RecordedProgram... recordedPrograms); - void onRecordedProgramChanged(RecordedProgram recordedProgram); + void onRecordedProgramsChanged(RecordedProgram... recordedPrograms); - void onRecordedProgramRemoved(RecordedProgram recordedProgram); + void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms); } } diff --git a/src/com/android/tv/dvr/DvrDataManagerImpl.java b/src/com/android/tv/dvr/DvrDataManagerImpl.java index 02c47750..46682a48 100644 --- a/src/com/android/tv/dvr/DvrDataManagerImpl.java +++ b/src/com/android/tv/dvr/DvrDataManagerImpl.java @@ -16,13 +16,16 @@ package com.android.tv.dvr; +import android.annotation.SuppressLint; import android.annotation.TargetApi; 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.database.sqlite.SQLiteException; +import android.media.tv.TvContract.RecordedPrograms; +import android.media.tv.TvInputInfo; +import android.media.tv.TvInputManager.TvInputCallback; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -31,23 +34,38 @@ 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.dvr.DvrStorageStatusManager.OnStorageMountChangedListener; 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.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; +import com.android.tv.util.AsyncDbTask.AsyncRecordedProgramQueryTask; import com.android.tv.util.Clock; +import com.android.tv.util.Filter; +import com.android.tv.util.TvInputManagerHelper; +import com.android.tv.util.TvProviderUriMatcher; +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.Entry; import java.util.Set; /** @@ -59,90 +77,221 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { private static final String TAG = "DvrDataManagerImpl"; private static final boolean DEBUG = false; + private final TvInputManagerHelper mInputManager; + 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 HashMap<Long, ScheduledRecording> mScheduledRecordingsForRemovedInput = + new HashMap<>(); + private final HashMap<Long, RecordedProgram> mRecordedProgramsForRemovedInput = new HashMap<>(); + private final HashMap<Long, SeriesRecording> mSeriesRecordingsForRemovedInput = new HashMap<>(); + private final Context mContext; + 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); + private boolean mDvrLoadFinished; + private boolean mRecordedProgramLoadFinished; + private final Set<AsyncTask> mPendingTasks = new ArraySet<>(); + private DvrDbSync mDbSync; + private DvrStorageStatusManager mStorageStatusManager; + + private final TvInputCallback mInputCallback = new TvInputCallback() { + @Override + public void onInputAdded(String inputId) { + if (DEBUG) Log.d(TAG, "onInputAdded " + inputId); + if (!isInputAvailable(inputId)) { + if (DEBUG) Log.d(TAG, "Not available for recording"); + return; } - } else { - RecordedProgram old = mRecordedPrograms.put(id, recordedProgram); - if (old == null) { - notifyRecordedProgramAdded(recordedProgram); - } else { - notifyRecordedProgramChanged(recordedProgram); + unhideInput(inputId); + } + + @Override + public void onInputRemoved(String inputId) { + if (DEBUG) Log.d(TAG, "onInputRemoved " + inputId); + hideInput(inputId); + } + }; + + private final OnStorageMountChangedListener mStorageMountChangedListener = + new OnStorageMountChangedListener() { + @Override + public void onStorageMountChanged(boolean storageMounted) { + for (TvInputInfo input : mInputManager.getTvInputInfos(true, true)) { + if (Utils.isBundledInput(input.getId())) { + if (storageMounted) { + unhideInput(input.getId()); + } else { + hideInput(input.getId()); + } + } + } + } + }; + + private static <T> List<T> moveElements(HashMap<Long, T> from, HashMap<Long, T> to, + Filter<T> filter) { + List<T> moved = new ArrayList<>(); + Iterator<Entry<Long, T>> iter = from.entrySet().iterator(); + while (iter.hasNext()) { + Entry<Long, T> entry = iter.next(); + if (filter.filter(entry.getValue())) { + to.put(entry.getKey(), entry.getValue()); + iter.remove(); + moved.add(entry.getValue()); } } + return moved; } - private boolean mDvrLoadFinished; - private boolean mRecordedProgramLoadFinished; - private final Set<AsyncTask> mPendingTasks = new ArraySet<>(); - public DvrDataManagerImpl(Context context, Clock clock) { super(context, clock); mContext = context; + mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper(); + mStorageStatusManager = TvApplication.getSingletons(context).getDvrStorageStatusManager(); } public void start() { - AsyncDvrQueryTask mDvrQueryTask = new AsyncDvrQueryTask(mContext) { + mInputManager.addCallback(mInputCallback); + mStorageStatusManager.addListener(mStorageMountChangedListener); + 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; + HashSet<String> seriesIds = new HashSet<>(); + for (SeriesRecording r : seriesRecordings) { + if (SoftPreconditions.checkState(!seriesIds.contains(r.getSeriesId()), TAG, + "Skip loading series recording with duplicate series ID: " + r)) { + seriesIds.add(r.getSeriesId()); + if (isInputAvailable(r.getInputId())) { + mSeriesRecordings.put(r.getId(), r); + mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); + } else { + mSeriesRecordingsForRemovedInput.put(r.getId(), r); + } + } + if (maxId < r.getId()) { + maxId = r.getId(); + } + } + IdGenerator.SERIES_RECORDING.setMaxId(maxId); + } + }; + dvrQuerySeriesRecordingTask.executeOnDbThread(); + mPendingTasks.add(dvrQuerySeriesRecordingTask); + AsyncDvrQueryScheduleTask dvrQueryScheduleTask + = new AsyncDvrQueryScheduleTask(mContext) { + @Override protected void onCancelled(List<ScheduledRecording> scheduledRecordings) { mPendingTasks.remove(this); } + @SuppressLint("SwitchIntDef") @Override protected void onPostExecute(List<ScheduledRecording> result) { mPendingTasks.remove(this); - mDvrLoadFinished = true; + long maxId = 0; + List<SeriesRecording> seriesRecordingsToAdd = new ArrayList<>(); + List<ScheduledRecording> toUpdate = new ArrayList<>(); + List<ScheduledRecording> toDelete = new ArrayList<>(); for (ScheduledRecording r : result) { - mScheduledRecordings.put(r.getId(), r); + if (!isInputAvailable(r.getInputId())) { + mScheduledRecordingsForRemovedInput.put(r.getId(), r); + } else 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. + switch (r.getState()) { + case 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()); + } + break; + case ScheduledRecording.STATE_RECORDING_NOT_STARTED: + if (r.getEndTimeMs() <= mClock.currentTimeMillis()) { + toUpdate.add(ScheduledRecording.buildFrom(r) + .setState(ScheduledRecording.STATE_RECORDING_FAILED) + .build()); + } + break; + case ScheduledRecording.STATE_RECORDING_CANCELED: + toDelete.add(r); + break; + } + } + if (maxId < r.getId()) { + maxId = r.getId(); + } + } + if (!toUpdate.isEmpty()) { + updateScheduledRecording(ScheduledRecording.toArray(toUpdate)); + } + if (!toDelete.isEmpty()) { + removeScheduledRecording(ScheduledRecording.toArray(toDelete)); + } + IdGenerator.SCHEDULED_RECORDING.setMaxId(maxId); + mDvrLoadFinished = true; + notifyDvrScheduleLoadFinished(); + mDbSync = new DvrDbSync(mContext, DvrDataManagerImpl.this); + mDbSync.start(); + if (isInitialized()) { + SeriesRecordingScheduler.getInstance(mContext).start(); } } }; - mDvrQueryTask.executeOnDbThread(); - mPendingTasks.add(mDvrQueryTask); - AsyncRecordedProgramsQueryTask mRecordedProgramQueryTask = - new AsyncRecordedProgramsQueryTask(mContext.getContentResolver()); + dvrQueryScheduleTask.executeOnDbThread(); + mPendingTasks.add(dvrQueryScheduleTask); + 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() { + mInputManager.removeCallback(mInputCallback); + mStorageStatusManager.removeListener(mStorageMountChangedListener); + SeriesRecordingScheduler.getInstance(mContext).stop(); + if (mDbSync != null) { + mDbSync.stop(); + } ContentResolver cr = mContext.getContentResolver(); cr.unregisterContentObserver(mContentObserver); Iterator<AsyncTask> i = mPendingTasks.iterator(); @@ -153,11 +302,104 @@ 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) { + if (isInputAvailable(recorded.getInputId())) { + mRecordedPrograms.put(recorded.getId(), recorded); + } else { + mRecordedProgramsForRemovedInput.put(recorded.getId(), recorded); + } + } + mRecordedProgramLoadFinished = true; + notifyRecordedProgramLoadFinished(); + } else if (recordedPrograms == null || recordedPrograms.isEmpty()) { + List<RecordedProgram> oldRecordedPrograms = + new ArrayList<>(mRecordedPrograms.values()); + mRecordedPrograms.clear(); + mRecordedProgramsForRemovedInput.clear(); + notifyRecordedProgramsRemoved(RecordedProgram.toArray(oldRecordedPrograms)); + } else { + HashMap<Long, RecordedProgram> oldRecordedPrograms + = new HashMap<>(mRecordedPrograms); + mRecordedPrograms.clear(); + mRecordedProgramsForRemovedInput.clear(); + List<RecordedProgram> addedRecordedPrograms = new ArrayList<>(); + List<RecordedProgram> changedRecordedPrograms = new ArrayList<>(); + for (RecordedProgram recorded : recordedPrograms) { + if (isInputAvailable(recorded.getInputId())) { + mRecordedPrograms.put(recorded.getId(), recorded); + if (oldRecordedPrograms.remove(recorded.getId()) == null) { + addedRecordedPrograms.add(recorded); + } else { + changedRecordedPrograms.add(recorded); + } + } else { + mRecordedProgramsForRemovedInput.put(recorded.getId(), recorded); + } + } + if (!addedRecordedPrograms.isEmpty()) { + notifyRecordedProgramsAdded(RecordedProgram.toArray(addedRecordedPrograms)); + } + if (!changedRecordedPrograms.isEmpty()) { + notifyRecordedProgramsChanged(RecordedProgram.toArray(changedRecordedPrograms)); + } + if (!oldRecordedPrograms.isEmpty()) { + notifyRecordedProgramsRemoved( + RecordedProgram.toArray(oldRecordedPrograms.values())); + } + } + if (isInitialized()) { + SeriesRecordingScheduler.getInstance(mContext).start(); + } + } else if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM_ID) { + if (!mRecordedProgramLoadFinished) { + return; + } + long id = ContentUris.parseId(uri); + if (DEBUG) Log.d(TAG, "changed recorded program #" + id + " to " + recordedPrograms); + if (recordedPrograms == null || recordedPrograms.isEmpty()) { + mRecordedProgramsForRemovedInput.remove(id); + RecordedProgram old = mRecordedPrograms.remove(id); + if (old != null) { + notifyRecordedProgramsRemoved(old); + } + } else { + RecordedProgram recordedProgram = recordedPrograms.get(0); + if (isInputAvailable(recordedProgram.getInputId())) { + RecordedProgram old = mRecordedPrograms.put(id, recordedProgram); + if (old == null) { + notifyRecordedProgramsAdded(recordedProgram); + } else { + notifyRecordedProgramsChanged(recordedProgram); + } + } else { + mRecordedProgramsForRemovedInput.put(id, recordedProgram); + } + } + } + } + @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 +419,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 +487,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) && 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.isOverLapping(period)) { + 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 +523,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 +538,386 @@ 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) { + if (r.getId() == ScheduledRecording.ID_NOT_SET) { + r.setId(IdGenerator.SCHEDULED_RECORDING.newId()); } - }.executeOnDbThread(scheduledRecording); + mScheduledRecordings.put(r.getId(), r); + if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) { + mProgramId2ScheduledRecordings.put(r.getProgramId(), r); + } + } + 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); + SeriesRecording previousSeries = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); + SoftPreconditions.checkArgument(previousSeries == null, TAG, "Attempt to add series" + + " recording with the duplicate series ID: " + r.getSeriesId()); + } + 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); + } + @Override + public void removeScheduledRecording(boolean forceRemove, ScheduledRecording... schedules) { + List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); + List<ScheduledRecording> schedulesNotToDelete = new ArrayList<>(); + for (ScheduledRecording r : schedules) { + mScheduledRecordings.remove(r.getId()); + getDeletedScheduleMap().remove(r.getId()); + mProgramId2ScheduledRecordings.remove(r.getProgramId()); + boolean isScheduleForRemovedInput = + mScheduledRecordingsForRemovedInput.remove(r.getProgramId()) != null; + // If it belongs to the series recording and it's not started yet, just mark delete + // instead of deleting it. + if (!isScheduleForRemovedInput && !forceRemove + && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET + && (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || r.getState() == ScheduledRecording.STATE_RECORDING_CANCELED)) { + 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); } - }.executeOnDbThread(scheduledRecording); + } + 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()); + long programId = r.getProgramId(); + if (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); + } + } + if (toUpdate.isEmpty()) { + return; + } + ScheduledRecording[] scheduleArray = ScheduledRecording.toArray(toUpdate); + if (mDvrLoadFinished) { + notifyScheduledRecordingStatusChanged(scheduleArray); + } + if (updateDb) { + new AsyncUpdateScheduleTask(mContext).executeOnDbThread(scheduleArray); + } + removeDeletedSchedules(schedules); } - 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 old1 = mSeriesRecordings.put(r.getId(), r); + SeriesRecording old2 = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); + SoftPreconditions.checkArgument(old1.equals(old2), TAG, "Series ID cannot be" + + " updated: " + r); } + if (mDvrLoadFinished) { + notifySeriesRecordingChanged(seriesRecordings); + } + new AsyncUpdateSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings); + } - @Override - protected RecordedProgram fromCursor(Cursor c) { - return RecordedProgram.fromCursor(c); + private boolean isInputAvailable(String inputId) { + return mInputManager.hasTvInputInfo(inputId) + && (!Utils.isBundledInput(inputId) || mStorageStatusManager.isStorageMounted()); + } + + 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 onCancelled(List<RecordedProgram> scheduledRecordings) { - mPendingTasks.remove(this); + 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)); } + } - @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 unhideInput(String inputId) { + if (DEBUG) Log.d(TAG, "unhideInput " + inputId); + List<ScheduledRecording> movedSchedules = + moveElements(mScheduledRecordingsForRemovedInput, mScheduledRecordings, + new Filter<ScheduledRecording>() { + @Override + public boolean filter(ScheduledRecording r) { + return r.getInputId().equals(inputId); + } + }); + List<SeriesRecording> movedSeriesRecordings = + moveElements(mSeriesRecordingsForRemovedInput, mSeriesRecordings, + new Filter<SeriesRecording>() { + @Override + public boolean filter(SeriesRecording r) { + return r.getInputId().equals(inputId); + } + }); + List<RecordedProgram> movedRecordedPrograms = + moveElements(mRecordedProgramsForRemovedInput, mRecordedPrograms, + new Filter<RecordedProgram>() { + @Override + public boolean filter(RecordedProgram r) { + return r.getInputId().equals(inputId); + } + }); + if (!movedSchedules.isEmpty()) { + for (ScheduledRecording schedule : movedSchedules) { + mProgramId2ScheduledRecordings.put(schedule.getProgramId(), schedule); + } + } + if (!movedSeriesRecordings.isEmpty()) { + for (SeriesRecording seriesRecording : movedSeriesRecordings) { + mSeriesId2SeriesRecordings.put(seriesRecording.getSeriesId(), seriesRecording); + } + } + // Notify after all the data are moved. + if (!movedSchedules.isEmpty()) { + notifyScheduledRecordingAdded(ScheduledRecording.toArray(movedSchedules)); + } + if (!movedSeriesRecordings.isEmpty()) { + notifySeriesRecordingAdded(SeriesRecording.toArray(movedSeriesRecordings)); + } + if (!movedRecordedPrograms.isEmpty()) { + notifyRecordedProgramsAdded(RecordedProgram.toArray(movedRecordedPrograms)); + } + } + + private void hideInput(String inputId) { + if (DEBUG) Log.d(TAG, "hideInput " + inputId); + List<ScheduledRecording> movedSchedules = + moveElements(mScheduledRecordings, mScheduledRecordingsForRemovedInput, + new Filter<ScheduledRecording>() { + @Override + public boolean filter(ScheduledRecording r) { + return r.getInputId().equals(inputId); + } + }); + List<SeriesRecording> movedSeriesRecordings = + moveElements(mSeriesRecordings, mSeriesRecordingsForRemovedInput, + new Filter<SeriesRecording>() { + @Override + public boolean filter(SeriesRecording r) { + return r.getInputId().equals(inputId); + } + }); + List<RecordedProgram> movedRecordedPrograms = + moveElements(mRecordedPrograms, mRecordedProgramsForRemovedInput, + new Filter<RecordedProgram>() { + @Override + public boolean filter(RecordedProgram r) { + return r.getInputId().equals(inputId); + } + }); + if (!movedSchedules.isEmpty()) { + for (ScheduledRecording schedule : movedSchedules) { + mProgramId2ScheduledRecordings.remove(schedule.getProgramId()); + } + } + if (!movedSeriesRecordings.isEmpty()) { + for (SeriesRecording seriesRecording : movedSeriesRecordings) { + mSeriesId2SeriesRecordings.remove(seriesRecording.getSeriesId()); } } + // Notify after all the data are moved. + if (!movedSchedules.isEmpty()) { + notifyScheduledRecordingRemoved(ScheduledRecording.toArray(movedSchedules)); + } + if (!movedSeriesRecordings.isEmpty()) { + notifySeriesRecordingRemoved(SeriesRecording.toArray(movedSeriesRecordings)); + } + if (!movedRecordedPrograms.isEmpty()) { + notifyRecordedProgramsRemoved(RecordedProgram.toArray(movedRecordedPrograms)); + } } - private final class AsyncRecordedProgramQueryTask - extends AsyncDbTask.AsyncQueryItemTask<RecordedProgram> { + @Override + public void forgetStorage(String inputId) { + List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); + for (Iterator<ScheduledRecording> i = + mScheduledRecordingsForRemovedInput.values().iterator(); i.hasNext(); ) { + ScheduledRecording r = i.next(); + if (inputId.equals(r.getInputId())) { + schedulesToDelete.add(r); + i.remove(); + } + } + List<SeriesRecording> seriesRecordingsToDelete = new ArrayList<>(); + for (Iterator<SeriesRecording> i = + mSeriesRecordingsForRemovedInput.values().iterator(); i.hasNext(); ) { + SeriesRecording r = i.next(); + if (inputId.equals(r.getInputId())) { + seriesRecordingsToDelete.add(r); + i.remove(); + } + } + for (Iterator<RecordedProgram> i = + mRecordedProgramsForRemovedInput.values().iterator(); i.hasNext(); ) { + if (inputId.equals(i.next().getInputId())) { + i.remove(); + } + } + new AsyncDeleteScheduleTask(mContext).executeOnDbThread( + ScheduledRecording.toArray(schedulesToDelete)); + new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread( + SeriesRecording.toArray(seriesRecordingsToDelete)); + new AsyncDbTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContentResolver resolver = mContext.getContentResolver(); + String args[] = { inputId }; + try { + resolver.delete(RecordedPrograms.CONTENT_URI, + RecordedPrograms.COLUMN_INPUT_ID + " = ?", args); + } catch (SQLiteException e) { + Log.e(TAG, "Failed to delete recorded programs for inputId: " + inputId, e); + } + return null; + } + }.executeOnDbThread(); + } + 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..df181455 --- /dev/null +++ b/src/com/android/tv/dvr/DvrDbSync.java @@ -0,0 +1,363 @@ +/* + * 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 android.util.Log; + +import com.android.tv.TvApplication; +import com.android.tv.data.ChannelDataManager; +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.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; + +/** + * A class to synchronizes DVR DB with TvProvider. + * + * <p>The current implementation of AsyncDbTask allows only one task to run at a time, and all the + * other tasks are blocked until the current one finishes. As this class performs the low priority + * jobs which take long time, it should not block others if possible. For this reason, only one + * program is queried at a time and others are queued and will be executed on the other + * AsyncDbTask's after the current one finishes to minimize the execution time of one AsyncDbTask. + */ +@MainThread +@TargetApi(Build.VERSION_CODES.N) +class DvrDbSync { + private static final String TAG = "DvrDbSync"; + private static final boolean DEBUG = false; + + private final Context mContext; + private final DvrDataManagerImpl mDataManager; + private final ChannelDataManager mChannelDataManager; + private final Queue<Long> mProgramIdQueue = new LinkedList<>(); + private QueryProgramTask mQueryProgramTask; + private final SeriesRecordingScheduler mSeriesRecordingScheduler; + private final ContentObserver mContentObserver = new ContentObserver(new Handler( + Looper.getMainLooper())) { + @SuppressLint("SwitchIntDef") + @Override + public void onChange(boolean selfChange, Uri uri) { + switch (TvProviderUriMatcher.match(uri)) { + case TvProviderUriMatcher.MATCH_PROGRAM: + if (DEBUG) Log.d(TAG, "onProgramsUpdated"); + onProgramsUpdated(); + break; + case TvProviderUriMatcher.MATCH_PROGRAM_ID: + if (DEBUG) { + Log.d(TAG, "onProgramUpdated: programId=" + ContentUris.parseId(uri)); + } + onProgramUpdated(ContentUris.parseId(uri)); + break; + } + } + }; + + private final ChannelDataManager.Listener mChannelDataManagerListener = + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + start(); + } + + @Override + public void onChannelListUpdated() { + onChannelsUpdated(); + } + + @Override + public void onChannelBrowsableChanged() { } + }; + + private final ScheduledRecordingListener mScheduleListener = new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + addProgramIdToCheckIfNeeded(schedule); + } + startNextUpdateIfNeeded(); + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + mProgramIdQueue.remove(schedule.getProgramId()); + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + mProgramIdQueue.remove(schedule.getProgramId()); + addProgramIdToCheckIfNeeded(schedule); + } + startNextUpdateIfNeeded(); + } + }; + + DvrDbSync(Context context, DvrDataManagerImpl dataManager) { + this(context, dataManager, TvApplication.getSingletons(context).getChannelDataManager()); + } + + @VisibleForTesting + DvrDbSync(Context context, DvrDataManagerImpl dataManager, + ChannelDataManager channelDataManager) { + mContext = context; + mDataManager = dataManager; + mChannelDataManager = channelDataManager; + mSeriesRecordingScheduler = SeriesRecordingScheduler.getInstance(context); + } + + /** + * Starts the DB sync. + */ + public void start() { + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener(mChannelDataManagerListener); + return; + } + mContext.getContentResolver().registerContentObserver(Programs.CONTENT_URI, true, + mContentObserver); + mDataManager.addScheduledRecordingListener(mScheduleListener); + onChannelsUpdated(); + onProgramsUpdated(); + } + + /** + * Stops the DB sync. + */ + public void stop() { + mProgramIdQueue.clear(); + if (mQueryProgramTask != null) { + mQueryProgramTask.cancel(true); + } + mChannelDataManager.removeListener(mChannelDataManagerListener); + mDataManager.removeScheduledRecordingListener(mScheduleListener); + mContext.getContentResolver().unregisterContentObserver(mContentObserver); + } + + private void onChannelsUpdated() { + List<SeriesRecording> seriesRecordingsToUpdate = new ArrayList<>(); + for (SeriesRecording r : mDataManager.getSeriesRecordings()) { + if (r.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE + && !mChannelDataManager.doesChannelExistInDb(r.getChannelId())) { + seriesRecordingsToUpdate.add(SeriesRecording.buildFrom(r) + .setChannelOption(SeriesRecording.OPTION_CHANNEL_ALL) + .setState(SeriesRecording.STATE_SERIES_STOPPED).build()); + } + } + if (!seriesRecordingsToUpdate.isEmpty()) { + mDataManager.updateSeriesRecording( + SeriesRecording.toArray(seriesRecordingsToUpdate)); + } + List<ScheduledRecording> schedulesToRemove = new ArrayList<>(); + for (ScheduledRecording r : mDataManager.getAvailableScheduledRecordings()) { + if (!mChannelDataManager.doesChannelExistInDb(r.getChannelId())) { + schedulesToRemove.add(r); + mProgramIdQueue.remove(r.getProgramId()); + } + } + if (!schedulesToRemove.isEmpty()) { + mDataManager.removeScheduledRecording( + ScheduledRecording.toArray(schedulesToRemove)); + } + } + + private void onProgramsUpdated() { + for (ScheduledRecording schedule : mDataManager.getAvailableScheduledRecordings()) { + addProgramIdToCheckIfNeeded(schedule); + } + startNextUpdateIfNeeded(); + } + + private void onProgramUpdated(long programId) { + addProgramIdToCheckIfNeeded(mDataManager.getScheduledRecordingForProgramId(programId)); + startNextUpdateIfNeeded(); + } + + 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)) { + if (DEBUG) Log.d(TAG, "Program ID enqueued: " + programId); + mProgramIdQueue.offer(programId); + // There are schedules to be updated. Pause the SeriesRecordingScheduler until all the + // schedule updates finish. + // Note that the SeriesRecordingScheduler should be paused even though the program to + // check is not episodic because it can be changed to the episodic program after the + // update, which affect the SeriesRecordingScheduler. + mSeriesRecordingScheduler.pauseUpdate(); + } + } + + private void startNextUpdateIfNeeded() { + if (mQueryProgramTask != null && !mQueryProgramTask.isCancelled()) { + return; + } + if (!mProgramIdQueue.isEmpty()) { + if (DEBUG) Log.d(TAG, "Program ID dequeued: " + mProgramIdQueue.peek()); + mQueryProgramTask = new QueryProgramTask(mProgramIdQueue.poll()); + mQueryProgramTask.executeOnDbThread(); + } else { + mSeriesRecordingScheduler.resumeUpdate(); + } + } + + @VisibleForTesting + void handleUpdateProgram(Program program, long programId) { + Set<SeriesRecording> seriesRecordingsToUpdate = new HashSet<>(); + 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); + if (schedule.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) { + SeriesRecording seriesRecording = + mDataManager.getSeriesRecording(schedule.getSeriesRecordingId()); + if (seriesRecording != null) { + seriesRecordingsToUpdate.add(seriesRecording); + } + } + } else { + long currentTimeMs = System.currentTimeMillis(); + 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()); + boolean needUpdate = false; + // Check the series recording. + SeriesRecording seriesRecordingForOldSchedule = + mDataManager.getSeriesRecording(schedule.getSeriesRecordingId()); + if (program.getSeriesId() != null) { + // New program belongs to a series. + SeriesRecording seriesRecording = + mDataManager.getSeriesRecording(program.getSeriesId()); + if (seriesRecording == null) { + // The new program is episodic while the previous one isn't. + SeriesRecording newSeriesRecording = TvApplication.getSingletons(mContext) + .getDvrManager().addSeriesRecording(program, + Collections.singletonList(program), + SeriesRecording.STATE_SERIES_STOPPED); + builder.setSeriesRecordingId(newSeriesRecording.getId()); + needUpdate = true; + } else if (seriesRecording.getId() != schedule.getSeriesRecordingId()) { + // The new program belongs to the other series. + builder.setSeriesRecordingId(seriesRecording.getId()); + needUpdate = true; + seriesRecordingsToUpdate.add(seriesRecording); + if (seriesRecordingForOldSchedule != null) { + seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule); + } + } else if (!Objects.equals(schedule.getSeasonNumber(), + program.getSeasonNumber()) + || !Objects.equals(schedule.getEpisodeNumber(), + program.getEpisodeNumber())) { + // The episode number has been changed. + if (seriesRecordingForOldSchedule != null) { + seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule); + } + } + } else if (seriesRecordingForOldSchedule != null) { + // Old program belongs to a series but the new one doesn't. + seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule); + } + // Change start time only when the recording start time has not passed. + boolean needToChangeStartTime = schedule.getStartTimeMs() > currentTimeMs + && program.getStartTimeUtcMillis() != schedule.getStartTimeMs(); + if (needToChangeStartTime) { + builder.setStartTimeMs(program.getStartTimeUtcMillis()); + needUpdate = true; + } + if (needUpdate || 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()); + } + if (!seriesRecordingsToUpdate.isEmpty()) { + // The series recordings will be updated after it's resumed. + mSeriesRecordingScheduler.updateSchedules(seriesRecordingsToUpdate); + } + } + } + } + + private class QueryProgramTask extends AsyncQueryProgramTask { + private final long mProgramId; + + QueryProgramTask(long programId) { + super(mContext.getContentResolver(), programId); + mProgramId = programId; + } + + @Override + protected void onCancelled(Program program) { + if (mQueryProgramTask == this) { + mQueryProgramTask = null; + } + startNextUpdateIfNeeded(); + } + + @Override + protected void onPostExecute(Program program) { + if (mQueryProgramTask == this) { + mQueryProgramTask = 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..5fa6f90f 100644 --- a/src/com/android/tv/dvr/DvrManager.java +++ b/src/com/android/tv/dvr/DvrManager.java @@ -16,28 +16,43 @@ package com.android.tv.dvr; +import android.annotation.TargetApi; +import android.content.ContentProviderOperation; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; +import android.content.OperationApplicationException; +import android.media.tv.TvContract; import android.media.tv.TvInputInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; import android.os.Handler; +import android.os.RemoteException; 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.DvrDataManager.OnRecordedProgramLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; +import com.android.tv.dvr.DvrScheduleManager.OnInitializeListener; +import com.android.tv.dvr.SeriesRecording.SeriesState; 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,68 +64,375 @@ 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; public DvrManager(Context context) { SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG); + mAppContext = context.getApplicationContext(); ApplicationSingletons appSingletons = TvApplication.getSingletons(context); mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager(); - mAppContext = context.getApplicationContext(); - mChannelDataManager = appSingletons.getChannelDataManager(); - mDvrSessionManager = appSingletons.getDvrSessionManger(); + mScheduleManager = appSingletons.getDvrScheduleManager(); + if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) { + createSeriesRecordingsForRecordedProgramsIfNeeded(mDataManager.getRecordedPrograms()); + } else { + // No need to handle DVR schedule load finished because schedule manager is initialized + // after the all the schedules are loaded. + if (!mDataManager.isRecordedProgramLoadFinished()) { + mDataManager.addRecordedProgramLoadFinishedListener( + new OnRecordedProgramLoadFinishedListener() { + @Override + public void onRecordedProgramLoadFinished() { + mDataManager.removeRecordedProgramLoadFinishedListener(this); + if (mDataManager.isInitialized() + && mScheduleManager.isInitialized()) { + createSeriesRecordingsForRecordedProgramsIfNeeded( + mDataManager.getRecordedPrograms()); + } + } + }); + } + if (!mScheduleManager.isInitialized()) { + mScheduleManager.addOnInitializeListener(new OnInitializeListener() { + @Override + public void onInitialize() { + mScheduleManager.removeOnInitializeListener(this); + if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) { + createSeriesRecordingsForRecordedProgramsIfNeeded( + mDataManager.getRecordedPrograms()); + } + } + }); + } + } + mDataManager.addRecordedProgramListener(new RecordedProgramListener() { + @Override + public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { + if (!mDataManager.isInitialized() || !mScheduleManager.isInitialized()) { + return; + } + for (RecordedProgram recordedProgram : recordedPrograms) { + createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram); + } + } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + // Removing series recording is handled in the SeriesRecordingDetailsFragment. + } + }); + } + + private void createSeriesRecordingsForRecordedProgramsIfNeeded( + List<RecordedProgram> recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram); + } + } + + private void createSeriesRecordingForRecordedProgramIfNeeded(RecordedProgram recordedProgram) { + if (recordedProgram.getSeriesId() != null) { + SeriesRecording seriesRecording = + mDataManager.getSeriesRecording(recordedProgram.getSeriesId()); + if (seriesRecording == null) { + addSeriesRecording(recordedProgram); + } + } } /** - * 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 + * Schedules a recording for {@code program}. */ - public void addSchedule(Program program, List<ScheduledRecording> recordingsToOverride) { - Log.i(TAG, - "Adding scheduled recording of " + program + " instead of " + recordingsToOverride); - 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) + public ScheduledRecording addSchedule(Program program) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return null; + } + SeriesRecording seriesRecording = getSeriesRecording(program); + return addSchedule(program, seriesRecording == null + ? mScheduleManager.suggestNewPriority() + : seriesRecording.getPriority()); + } + + /** + * Schedules a recording for {@code program} with the highest priority so that the schedule + * can be recorded. + */ + public ScheduledRecording addScheduleWithHighestPriority(Program program) { + if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return null; + } + SeriesRecording seriesRecording = getSeriesRecording(program); + return addSchedule(program, seriesRecording == null + ? mScheduleManager.suggestNewPriority() + : mScheduleManager.suggestHighestPriority(seriesRecording.getInputId(), + new Range(program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis()), + seriesRecording.getPriority())); + } + + private ScheduledRecording addSchedule(Program program, long priority) { + TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, program); + if (input == null) { + Log.e(TAG, "Can't find input for program: " + program); + return null; + } + ScheduledRecording schedule; + SeriesRecording seriesRecording = getSeriesRecording(program); + schedule = createScheduledRecordingBuilder(input.getId(), program) .setPriority(priority) - .setChannelId(c.getId()) + .setSeriesRecordingId(seriesRecording == null ? SeriesRecording.ID_NOT_SET + : seriesRecording.getId()) .build(); - mDataManager.addScheduledRecording(r); + mDataManager.addScheduledRecording(schedule); + return schedule; } /** * 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); + } + + /** + * Adds the schedule. + */ + public void addSchedule(ScheduledRecording schedule) { + if (mDataManager.isDvrScheduleLoadFinished()) { + mDataManager.addScheduledRecording(schedule); + } + } + + 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 with the initial state. + */ + public SeriesRecording addSeriesRecording(Program selectedProgram, + List<Program> programsToSchedule, @SeriesState int initialState) { + 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()) + .setState(initialState) .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 addSeriesRecording(RecordedProgram recordedProgram) { + SeriesRecording seriesRecording = + SeriesRecording.builder(recordedProgram.getInputId(), recordedProgram) + .setPriority(mScheduleManager.suggestNewSeriesPriority()) + .setState(SeriesRecording.STATE_SERIES_STOPPED) + .build(); + mDataManager.addSeriesRecording(seriesRecording); + // The schedules for the recorded programs should be added not to create the schedule the + // duplicate episodes. + addRecordedProgramToSeriesRecording(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 a season recording schedule based on {@code program}. + * Adds {@link ScheduledRecording}s for the series recording. + * <p> + * This method doesn't add the series recording. */ - public void addSeasonSchedule(Program program) { - Log.i(TAG, "Adding season recording of " + program); - // TODO: implement + 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) { + ScheduledRecording r = ScheduledRecording.buildFrom(scheduleWithSameProgram) + .setSeriesRecordingId(series.getId()) + .build(); + if (!r.equals(scheduleWithSameProgram)) { + toUpdate.add(r); + } + } + } else { + toAdd.add(createScheduledRecordingBuilder(input.getId(), program) + .setPriority(series.getPriority()) + .setSeriesRecordingId(series.getId()) + .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())) { + SeriesRecordingScheduler scheduler = SeriesRecordingScheduler.getInstance(mAppContext); + scheduler.pauseUpdate(); + SeriesRecording previousSeries = mDataManager.getSeriesRecording(series.getId()); + if (previousSeries != null) { + if (previousSeries.getChannelOption() != series.getChannelOption() + || (previousSeries.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE + && previousSeries.getChannelId() != series.getChannelId())) { + List<ScheduledRecording> schedules = + mDataManager.getScheduledRecordings(series.getId()); + List<ScheduledRecording> schedulesToRemove = new ArrayList<>(); + for (ScheduledRecording schedule : schedules) { + if (schedule.isNotStarted()) { + schedulesToRemove.add(schedule); + } + } + mDataManager.removeScheduledRecording(true, + ScheduledRecording.toArray(schedulesToRemove)); + } + } + mDataManager.updateSeriesRecording(series); + if (previousSeries == null + || previousSeries.getPriority() != series.getPriority()) { + long priority = series.getPriority(); + List<ScheduledRecording> schedulesToUpdate = new ArrayList<>(); + for (ScheduledRecording schedule + : mDataManager.getScheduledRecordings(series.getId())) { + if (schedule.isNotStarted()) { + schedulesToUpdate.add(ScheduledRecording.buildFrom(schedule) + .setPriority(priority).build()); + } + } + if (!schedulesToUpdate.isEmpty()) { + mDataManager.updateScheduledRecording( + ScheduledRecording.toArray(schedulesToUpdate)); + } + } + scheduler.resumeUpdate(); + } + } + + /** + * 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); + } + + /** + * Returns true, if the series recording can be removed. If a series recording is NORMAL state + * or has recordings or schedules, it cannot be removed. + */ + public boolean canRemoveSeriesRecording(long seriesRecordingId) { + SeriesRecording seriesRecording = mDataManager.getSeriesRecording(seriesRecordingId); + if (seriesRecording == null) { + return false; + } + if (!seriesRecording.isStopped()) { + return false; + } + for (ScheduledRecording r : mDataManager.getAvailableScheduledRecordings()) { + if (r.getSeriesRecordingId() == seriesRecordingId) { + return false; + } + } + String seriesId = seriesRecording.getSeriesId(); + SoftPreconditions.checkNotNull(seriesId); + for (RecordedProgram r : mDataManager.getRecordedPrograms()) { + if (seriesId.equals(r.getSeriesId())) { + return false; + } + } + return true; } /** * 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 +446,297 @@ public class DvrManager { } /** - * Removes a scheduled recording or an existing recording. + * Removes scheduled recordings or an existing recordings. + */ + 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 scheduled recordings without changing to the DELETED state. + */ + public void forceRemoveScheduledRecording(ScheduledRecording... schedules) { + Log.i(TAG, "Force 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(true, r); + } + } + } + + /** + * Removes the recorded program. It deletes the file if possible. */ - public void removeScheduledRecording(ScheduledRecording scheduledRecording) { - Log.i(TAG, "Removing " + scheduledRecording); - mDataManager.removeScheduledRecording(scheduledRecording); + 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(); + int deletedCounts = resolver.delete(recordedProgram.getUri(), null, null); + if (deletedCounts > 0) { + // TODO: executeOnExecutor should be called on the main thread. + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + removeRecordedData(recordedProgram.getDataUri()); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + return null; + } + }.executeOnDbThread(); + } + + public void removeRecordedPrograms(List<Long> recordedProgramIds) { + final ArrayList<ContentProviderOperation> dbOperations = new ArrayList<>(); + final List<Uri> dataUris = new ArrayList<>(); + for (Long rId : recordedProgramIds) { + RecordedProgram r = mDataManager.getRecordedProgram(rId); + if (r != null) { + dataUris.add(r.getDataUri()); + dbOperations.add(ContentProviderOperation.newDelete(r.getUri()).build()); + } + } new AsyncDbTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { ContentResolver resolver = mAppContext.getContentResolver(); - resolver.delete(recordedProgram.getUri(), null, null); + try { + resolver.applyBatch(TvContract.AUTHORITY, dbOperations); + // TODO: executeOnExecutor should be called on the main thread. + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + for (Uri dataUri : dataUris) { + removeRecordedData(dataUri); + } + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } catch (RemoteException | OperationApplicationException e) { + Log.w(TAG, "Remove reocrded programs from DB failed.", e); + } return null; } - }.execute(); + }.executeOnDbThread(); } /** - * Returns priority ordered list of all scheduled recording that will not be recorded if + * 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) { + return schedule != null + && SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished()) + && 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); + } + + /** + * Sets the highest priority to the schedule. + */ + public void setHighestPriority(ScheduledRecording schedule) { + if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + long newPriority = mScheduleManager.suggestHighestPriority(schedule); + if (newPriority != schedule.getPriority()) { + mDataManager.updateScheduledRecording(ScheduledRecording.buildFrom(schedule) + .setPriority(newPriority).build()); } } - return overLap; } - @NonNull - private static Range getPeriod(Program program) { - return new Range(program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis()); + /** + * Suggests the higher priority than the schedules which overlap with {@code schedule}. + */ + public long suggestHighestPriority(ScheduledRecording schedule) { + if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) { + return mScheduleManager.suggestHighestPriority(schedule); + } + return DvrScheduleManager.DEFAULT_PRIORITY; } /** - * 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 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 canTuneTo(Channel channel, List<ScheduledRecording> outConflictScheduledRecordings) { - // TODO: implement - return true; + 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(); + } + + /** + * 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 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 the current recording for the channel. + * <p> + * This can be called from the UI before the schedules are loaded. + */ + public ScheduledRecording getCurrentRecording(long channelId) { + if (!mDataManager.isDvrScheduleLoadFinished()) { + return null; + } + for (ScheduledRecording recording : mDataManager.getStartedRecordings()) { + if (recording.getChannelId() == channelId) { + return recording; + } + } + return null; + } + + /** + * Returns schedules which is available (i.e., isNotStarted or isInProgress) and belongs to + * the series recording {@code seriesRecordingId}. + */ + public List<ScheduledRecording> getAvailableScheduledRecording(long seriesRecordingId) { + if (!mDataManager.isDvrScheduleLoadFinished()) { + return Collections.emptyList(); + } + List<ScheduledRecording> schedules = new ArrayList<>(); + for (ScheduledRecording schedule : mDataManager.getScheduledRecordings(seriesRecordingId)) { + if (schedule.isInProgress() || schedule.isNotStarted()) { + schedules.add(schedule); + } + } + return schedules; } /** - * Returns true is the inputId supports recording. + * Returns the series recording related to the program. */ - public boolean canRecord(String inputId) { - TvInputInfo info = mDvrSessionManager.getTvInputInfo(inputId); - return info != null && info.getTunerCount() > 0; + @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 +744,102 @@ 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; + } + + @WorkerThread + private void removeRecordedData(Uri dataUri) { + try { + 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: " + dataUri); + } + } + } + } catch (SecurityException e) { + if (DEBUG) { + Log.d(TAG, "To delete this recorded program, please manually delete video data at" + + "\nadb shell rm -rf " + dataUri); + } + } + } + + /** + * Remove all the records related to the input. + * <p> + * Note that this should be called after the input was removed. + */ + public void forgetStorage(String inputId) { + if (mDataManager.isInitialized()) { + mDataManager.forgetStorage(inputId); + } + } + + /** * 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..5deda44a --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlaybackActivity.java @@ -0,0 +1,67 @@ +/* + * 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.TvApplication; +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) { + TvApplication.setCurrentRunningProcess(this, true); + 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..9759a856 --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java @@ -0,0 +1,327 @@ +/* + * 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.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.dvr.ui.DvrPlaybackOverlayFragment; +import com.android.tv.util.ImageLoader; +import com.android.tv.util.TimeShiftUtils; +import com.android.tv.util.Utils; + +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, DvrPlaybackOverlayFragment overlayFragment) { + 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); + } + } + + @Override + public void onPlaybackEnded() { + // TODO: Deal with watched over recordings in DVR library + RecordedProgram nextEpisode = + overlayFragment.getNextEpisode(mDvrPlayer.getProgram()); + if (nextEpisode == null) { + mDvrPlayer.reset(); + mActivity.finish(); + } else { + Intent intent = new Intent(activity, DvrPlaybackActivity.class); + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, nextEpisode.getId()); + mActivity.startActivity(intent); + } + } + }); + 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 && 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() { + if (mDvrPlayer.isPlaybackPrepared()) { + mDvrPlayer.play(); + } + } + + @Override + public void onPause() { + if (mDvrPlayer.isPlaybackPrepared()) { + mDvrPlayer.pause(); + } + } + + @Override + public void onFastForward() { + if (!mDvrPlayer.isPlaybackPrepared()) { + return; + } + 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.isPlaybackPrepared()) { + return; + } + 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) { + if (mDvrPlayer.isPlaybackPrepared()) { + 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..5656655c --- /dev/null +++ b/src/com/android/tv/dvr/DvrPlayer.java @@ -0,0 +1,425 @@ +/* + * 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(); + private long mStartPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; + private boolean mTimeShiftPlayAvailable; + + 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) { } + /** + * Called when the playback toward the end. + */ + public void onPlaybackEnded() { } + } + + 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 + mStartPositionMs); + 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()"); + mCallback.onPlaybackStateChanged(PlaybackState.STATE_NONE, 1); + mPlaybackState = PlaybackState.STATE_NONE; + mTvView.reset(); + mTimeShiftPlayAvailable = false; + mStartPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; + mTimeShiftCurrentPositionMs = 0; + mPlaybackParams.setSpeed(1.0f); + mProgram = null; + } + + /** + * 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 onTimeShiftStartPositionChanged(String inputId, long timeMs) { + if (DEBUG) Log.d(TAG, "onTimeShiftStartPositionChanged:" + timeMs); + mStartPositionMs = timeMs; + if (mTimeShiftPlayAvailable) { + resumeToWatchedPositionIfNeeded(); + } + } + + @Override + public void onTimeShiftCurrentPositionChanged(String inputId, long timeMs) { + if (DEBUG) Log.d(TAG, "onTimeShiftCurrentPositionChanged: " + timeMs); + if (!mTimeShiftPlayAvailable) { + // Workaround of b/31436263 + return; + } + // Workaround of b/32211561, TIF won't report start position when TIS report + // its start position as 0. In that case, we have to do the prework of playback + // on the first time we get current position, and the start position should be 0 + // at that time. + if (mStartPositionMs == TvInputManager.TIME_SHIFT_INVALID_TIME) { + mStartPositionMs = 0; + resumeToWatchedPositionIfNeeded(); + } + timeMs -= mStartPositionMs; + if (mPlaybackState == PlaybackState.STATE_REWINDING + && timeMs <= REWIND_POSITION_MARGIN_MS) { + play(); + } else { + mTimeShiftCurrentPositionMs = getRealSeekPosition(timeMs, 0); + mCallback.onPlaybackPositionChanged(mTimeShiftCurrentPositionMs); + if (timeMs >= mProgram.getDurationMillis()) { + pause(); + mCallback.onPlaybackEnded(); + } + } + } + }); + mTvView.setCallback(new TvView.TvInputCallback() { + @Override + public void onTimeShiftStatusChanged(String inputId, int status) { + if (DEBUG) Log.d(TAG, "onTimeShiftStatusChanged:" + status); + if (status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE + && mPlaybackState == PlaybackState.STATE_CONNECTING) { + mTimeShiftPlayAvailable = true; + } + } + + @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); + } + } + }); + } + + private void resumeToWatchedPositionIfNeeded() { + if (mInitialSeekPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + mTvView.timeShiftSeekTo(getRealSeekPosition(mInitialSeekPositionMs, + SEEK_POSITION_MARGIN_MS) + mStartPositionMs); + 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); + } +}
\ 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..8c40aaa8 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,18 @@ 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(); + WritableDvrDataManager dataManager = (WritableDvrDataManager) singletons.getDvrDataManager(); AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); // mScheduler may have been set for testing. @@ -92,12 +78,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,10 +99,10 @@ 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(); + mHandlerThread.quitSafely(); mHandlerThread = null; } super.onDestroy(); @@ -124,7 +111,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..a5851a75 --- /dev/null +++ b/src/com/android/tv/dvr/DvrScheduleManager.java @@ -0,0 +1,980 @@ +/* + * 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.LongSparseArray; +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.CompositeComparator; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * 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 static final Comparator<ScheduledRecording> RESULT_COMPARATOR = + new CompositeComparator<>( + ScheduledRecording.PRIORITY_COMPARATOR.reversed(), + ScheduledRecording.START_TIME_COMPARATOR, + ScheduledRecording.ID_COMPARATOR.reversed()); + + // The candidate comparator should be the consistent with + // InputTaskScheduler#CANDIDATE_COMPARATOR. + private static final Comparator<ScheduledRecording> CANDIDATE_COMPARATOR = + new CompositeComparator<>( + ScheduledRecording.PRIORITY_COMPARATOR, + ScheduledRecording.END_TIME_COMPARATOR, + ScheduledRecording.ID_COMPARATOR); + + private final Context mContext; + private final DvrDataManagerImpl mDataManager; + private final ChannelDataManager mChannelDataManager; + + private final Map<String, List<ScheduledRecording>> mInputScheduleMap = new HashMap<>(); + // The inner map is a hash map from scheduled recording to its conflicting status, i.e., + // the boolean value true denotes the schedule is just partially conflicting, which means + // although there's conflictit, it might still be recorded partially. + private final Map<String, Map<ScheduledRecording, Boolean>> mInputConflictInfoMap = + new HashMap<>(); + + private boolean mInitialized; + + private final Set<OnInitializeListener> mOnInitializeListeners = new CopyOnWriteArraySet<>(); + 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 + .getTvInputInfoForInputId(mContext, schedule.getInputId()); + if (!SoftPreconditions.checkArgument(input != null, TAG, + "Input was removed for : " + schedule)) { + // Input removed. + mInputScheduleMap.remove(schedule.getInputId()); + mInputConflictInfoMap.remove(schedule.getInputId()); + 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 + .getTvInputInfoForInputId(mContext, schedule.getInputId()); + if (input == null) { + // Input removed. + mInputScheduleMap.remove(schedule.getInputId()); + mInputConflictInfoMap.remove(schedule.getInputId()); + continue; + } + String inputId = input.getId(); + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules != null) { + schedules.remove(schedule); + if (schedules.isEmpty()) { + mInputScheduleMap.remove(inputId); + } + } + Map<ScheduledRecording, Boolean> conflictInfo = + mInputConflictInfoMap.get(inputId); + if (conflictInfo != null) { + conflictInfo.remove(schedule); + if (conflictInfo.isEmpty()) { + mInputConflictInfoMap.remove(inputId); + } + } + } + onSchedulesChanged(); + notifyScheduledRecordingRemoved(scheduledRecordings); + } + + @Override + public void onScheduledRecordingStatusChanged( + ScheduledRecording... scheduledRecordings) { + if (!mInitialized) { + return; + } + for (ScheduledRecording schedule : scheduledRecordings) { + TvInputInfo input = Utils + .getTvInputInfoForInputId(mContext, schedule.getInputId()); + if (!SoftPreconditions.checkArgument(input != null, TAG, + "Input was removed for : " + schedule)) { + // Input removed. + mInputScheduleMap.remove(schedule.getInputId()); + mInputConflictInfoMap.remove(schedule.getInputId()); + 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. + for (Iterator<ScheduledRecording> i = schedules.iterator(); 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); + } + // Update conflict list as well + Map<ScheduledRecording, Boolean> conflictInfo = + mInputConflictInfoMap.get(inputId); + if (conflictInfo != null) { + // Compare ID because ScheduledRecording.equals() doesn't work if the state + // is changed. + ScheduledRecording oldSchedule = null; + for (ScheduledRecording s : conflictInfo.keySet()) { + if (s.getId() == schedule.getId()) { + oldSchedule = s; + break; + } + } + if (oldSchedule != null) { + conflictInfo.put(schedule, conflictInfo.get(oldSchedule)); + conflictInfo.remove(oldSchedule); + } + } + } + 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); + } + } + if (!mInitialized) { + mInitialized = true; + notifyInitialize(); + } + onSchedulesChanged(); + } + + private void onSchedulesChanged() { + // TODO: notify conflict state change when some conflicting recording becomes partially + // conflicting, vice versa. + List<ScheduledRecording> addedConflicts = new ArrayList<>(); + List<ScheduledRecording> removedConflicts = new ArrayList<>(); + for (String inputId : mInputScheduleMap.keySet()) { + Map<ScheduledRecording, Boolean> oldConflictsInfo = mInputConflictInfoMap.get(inputId); + Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>(); + if (oldConflictsInfo != null) { + for (ScheduledRecording r : oldConflictsInfo.keySet()) { + oldConflictMap.put(r.getId(), r); + } + } + Map<ScheduledRecording, Boolean> conflictInfo = getConflictingSchedulesInfo(inputId); + if (conflictInfo.isEmpty()) { + mInputConflictInfoMap.remove(inputId); + } else { + mInputConflictInfoMap.put(inputId, conflictInfo); + List<ScheduledRecording> conflicts = new ArrayList<>(conflictInfo.keySet()); + for (ScheduledRecording r : conflicts) { + if (oldConflictMap.remove(r.getId()) == null) { + addedConflicts.add(r); + } + } + } + removedConflicts.addAll(oldConflictMap.values()); + } + 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 OnInitializeListener}. + */ + public final void addOnInitializeListener(OnInitializeListener listener) { + mOnInitializeListeners.add(listener); + } + + /** + * Removes a {@link OnInitializeListener}. + */ + public final void removeOnInitializeListener(OnInitializeListener listener) { + mOnInitializeListeners.remove(listener); + } + + /** + * Calls {@link OnInitializeListener#onInitialize} for each listener. + */ + private void notifyInitialize() { + for (OnInitializeListener l : mOnInitializeListeners) { + l.onInitialize(); + } + } + + /** + * 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; + } + + /** + * Suggests the higher priority than the schedules which overlap with {@code schedule}. + */ + public long suggestHighestPriority(ScheduledRecording schedule) { + List<ScheduledRecording> schedules = mInputScheduleMap.get(schedule.getInputId()); + if (schedules == null) { + return DEFAULT_PRIORITY; + } + long highestPriority = Long.MIN_VALUE; + for (ScheduledRecording r : schedules) { + if (!r.equals(schedule) && r.isOverLapping(schedule) + && r.getPriority() > highestPriority) { + highestPriority = r.getPriority(); + } + } + if (highestPriority == Long.MIN_VALUE || highestPriority < schedule.getPriority()) { + return schedule.getPriority(); + } + return highestPriority + PRIORITY_OFFSET; + } + + /** + * Suggests the higher priority than the schedules which overlap with {@code schedule}. + */ + public long suggestHighestPriority(String inputId, Range<Long> peroid, long basePriority) { + List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId); + if (schedules == null) { + return DEFAULT_PRIORITY; + } + long highestPriority = Long.MIN_VALUE; + for (ScheduledRecording r : schedules) { + if (r.isOverLapping(peroid) && r.getPriority() > highestPriority) { + highestPriority = r.getPriority(); + } + } + if (highestPriority == Long.MIN_VALUE || highestPriority < basePriority) { + return basePriority; + } + 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 a sorted list of all scheduled recordings that will not be recorded if + * this program is going to be recorded, with their priorities in decending order. + * <p> + * An empty list means there is no conflicts. If there is conflict, a priority higher than + * the first recording in the returned list should be assigned to the new schedule of this + * program to guarantee the program would be completely recorded. + */ + 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 list of all conflicting scheduled recordings with schedules belonging to {@code + * seriesRecording} + * recording. + * <p> + * Any empty list means there is no conflicts. + */ + public List<ScheduledRecording> getConflictingSchedules(SeriesRecording seriesRecording) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + SoftPreconditions.checkState(seriesRecording != null, TAG, "series recording is null"); + if (!mInitialized || seriesRecording == null) { + return Collections.emptyList(); + } + TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, seriesRecording.getInputId()); + if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { + return Collections.emptyList(); + } + List<ScheduledRecording> schedulesForSeries = mDataManager.getScheduledRecordings( + seriesRecording.getId()); + return getConflictingSchedules(input, schedulesForSeries); + } + + /** + * Returns a sorted list of all scheduled recordings that will not be recorded if + * this channel is going to be recorded, with their priority in decending order. + * <p> + * An empty list means there is no conflicts. If there is conflict, a priority higher than + * the first recording in the returned list should be assigned to the new schedule of this + * channel to guarantee the channel would be completely recorded in the designated time range. + */ + 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 Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(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.emptyMap(); + } + List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); + if (schedules == null || schedules.isEmpty()) { + return Collections.emptyMap(); + } + return getConflictingSchedulesInfo(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.getTvInputInfoForInputId(mContext, schedule.getInputId()); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " + + schedule.getChannelId()); + if (!mInitialized || input == null) { + return false; + } + Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId()); + return conflicts != null && conflicts.containsKey(schedule); + } + + /** + * Checks if the schedule is partially conflicting, i.e., part of the scheduled program might be + * recorded even if the priority of the schedule is not raised. + * <p> + * If the given schedule is not conflicting or is totally conflicting, i.e., cannot be recorded + * at all, this method returns {@code false} in both cases. + */ + public boolean isPartiallyConflicting(@NonNull ScheduledRecording schedule) { + SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet"); + TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); + SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : " + + schedule.getChannelId()); + if (!mInitialized || input == null) { + return false; + } + Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId()); + return conflicts != null && conflicts.getOrDefault(schedule, false); + } + + /** + * 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, RESULT_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, null); + } + + @VisibleForTesting + static List<ScheduledRecording> getConflictingSchedules( + List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) { + List<ScheduledRecording> result = new ArrayList<>( + getConflictingSchedulesInfo(schedules, tunerCount, periods).keySet()); + Collections.sort(result, RESULT_COMPARATOR); + return result; + } + + @VisibleForTesting + static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo( + List<ScheduledRecording> schedules, int tunerCount) { + return getConflictingSchedulesInfo(schedules, tunerCount, null); + } + + /** + * This is the core method to calculate all the conflicting schedules (in given periods). + * <p> + * Note that this method will ignore duplicated schedules with a same hash code. (Please refer + * to {@link ScheduledRecording#hashCode}.) + * + * @return A {@link HashMap} from {@link ScheduledRecording} to {@link Boolean}. The boolean + * value denotes if the scheduled recording is partially conflicting, i.e., is possible + * to be partially recorded under the given schedules and tuner count {@code true}, + * or not {@code false}. + */ + private static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo( + List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) { + List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules); + // Sort by the same order as that in InputTaskScheduler. + Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator()); + List<ScheduledRecording> recordings = new ArrayList<>(); + Map<ScheduledRecording, Boolean> conflicts = new HashMap<>(); + Map<ScheduledRecording, ScheduledRecording> modified2OriginalSchedules = new HashMap<>(); + // Simulate InputTaskScheduler. + while (!schedulesToCheck.isEmpty()) { + ScheduledRecording schedule = schedulesToCheck.remove(0); + removeFinishedRecordings(recordings, schedule.getStartTimeMs()); + if (recordings.size() < tunerCount) { + recordings.add(schedule); + if (modified2OriginalSchedules.containsKey(schedule)) { + // Schedule has been modified, which means it's already conflicted. + // Modify its state to partially conflicted. + conflicts.put(modified2OriginalSchedules.get(schedule), true); + } + } else { + ScheduledRecording candidate = findReplaceableRecording(recordings, schedule); + if (candidate != null) { + if (!modified2OriginalSchedules.containsKey(candidate)) { + conflicts.put(candidate, true); + } + recordings.remove(candidate); + recordings.add(schedule); + if (modified2OriginalSchedules.containsKey(schedule)) { + // Schedule has been modified, which means it's already conflicted. + // Modify its state to partially conflicted. + conflicts.put(modified2OriginalSchedules.get(schedule), true); + } + } else { + if (!modified2OriginalSchedules.containsKey(schedule)) { + // if schedule has been modified, it's already conflicted. + // No need to add it again. + conflicts.put(schedule, false); + } + long earliestEndTime = getEarliestEndTime(recordings); + if (earliestEndTime < schedule.getEndTimeMs()) { + // The schedule can starts when other recording ends even though it's + // clipped. + ScheduledRecording modifiedSchedule = ScheduledRecording.buildFrom(schedule) + .setStartTimeMs(earliestEndTime).build(); + ScheduledRecording originalSchedule = + modified2OriginalSchedules.getOrDefault(schedule, schedule); + modified2OriginalSchedules.put(modifiedSchedule, originalSchedule); + int insertPosition = Collections.binarySearch(schedulesToCheck, + modifiedSchedule, + ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR); + if (insertPosition >= 0) { + schedulesToCheck.add(insertPosition, modifiedSchedule); + } else { + schedulesToCheck.add(-insertPosition - 1, modifiedSchedule); + } + } + } + } + } + // Returns only the schedules with the given range. + if (periods != null && !periods.isEmpty()) { + for (Iterator<ScheduledRecording> iter = conflicts.keySet().iterator(); + iter.hasNext(); ) { + boolean overlapping = false; + ScheduledRecording schedule = iter.next(); + for (Range<Long> period : periods) { + if (schedule.isOverLapping(period)) { + overlapping = true; + break; + } + } + if (!overlapping) { + iter.remove(); + } + } + } + return conflicts; + } + + private static void removeFinishedRecordings(List<ScheduledRecording> recordings, + long currentTimeMs) { + for (Iterator<ScheduledRecording> iter = recordings.iterator(); iter.hasNext(); ) { + if (iter.next().getEndTimeMs() <= currentTimeMs) { + iter.remove(); + } + } + } + + /** + * @see InputTaskScheduler#getReplacableTask + */ + private static ScheduledRecording findReplaceableRecording(List<ScheduledRecording> recordings, + ScheduledRecording schedule) { + // Returns the recording with the following priority. + // 1. The recording with the lowest priority is returned. + // 2. If the priorities are the same, the recording which finishes early is returned. + // 3. If 1) and 2) are the same, the early created schedule is returned. + ScheduledRecording candidate = null; + for (ScheduledRecording recording : recordings) { + if (schedule.getPriority() > recording.getPriority()) { + if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, recording) > 0) { + candidate = recording; + } + } + } + return candidate; + } + + private static long getEarliestEndTime(List<ScheduledRecording> recordings) { + long earliest = Long.MAX_VALUE; + for (ScheduledRecording recording : recordings) { + if (earliest > recording.getEndTimeMs()) { + earliest = recording.getEndTimeMs(); + } + } + return earliest; + } + + /** + * A listener which is notified the initialization of schedule manager. + */ + public interface OnInitializeListener { + /** + * Called when the schedule manager has been initialized. + */ + void onInitialize(); + } + + /** + * 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); + } +}
\ No newline at end of file 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/DvrStorageStatusManager.java b/src/com/android/tv/dvr/DvrStorageStatusManager.java new file mode 100644 index 00000000..a653b5f4 --- /dev/null +++ b/src/com/android/tv/dvr/DvrStorageStatusManager.java @@ -0,0 +1,376 @@ +/* + * 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.BroadcastReceiver; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Environment; +import android.os.Looper; +import android.os.RemoteException; +import android.os.StatFs; +import android.support.annotation.AnyThread; +import android.support.annotation.IntDef; +import android.support.annotation.WorkerThread; +import android.util.Log; + +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.util.Utils; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * Signals DVR storage status change such as plugging/unplugging. + */ +public class DvrStorageStatusManager { + private static final String TAG = "DvrStorageStatusManager"; + private static final boolean DEBUG = false; + + /** + * Minimum storage size to support DVR + */ + public static final long MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES = 50 * 1024 * 1024 * 1024L; // 50GB + private static final long MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES + = 10 * 1024 * 1024 * 1024L; // 10GB + private static final String RECORDING_DATA_SUB_PATH = "/recording"; + + private static final String[] PROJECTION = { + TvContract.RecordedPrograms._ID, + TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME, + TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI + }; + private final static int BATCH_OPERATION_COUNT = 100; + + @IntDef({STORAGE_STATUS_OK, STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL, + STORAGE_STATUS_FREE_SPACE_INSUFFICIENT, STORAGE_STATUS_MISSING}) + @Retention(RetentionPolicy.SOURCE) + public @interface StorageStatus { + } + + /** + * Current storage is OK to record a program. + */ + public static final int STORAGE_STATUS_OK = 0; + + /** + * Current storage's total capacity is smaller than DVR requirement. + */ + public static final int STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL = 1; + + /** + * Current storage's free space is insufficient to record programs. + */ + public static final int STORAGE_STATUS_FREE_SPACE_INSUFFICIENT = 2; + + /** + * Current storage is missing. + */ + public static final int STORAGE_STATUS_MISSING = 3; + + private final Context mContext; + private final Set<OnStorageMountChangedListener> mOnStorageMountChangedListeners = + new CopyOnWriteArraySet<>(); + private final boolean mRunningInMainProcess; + private MountedStorageStatus mMountedStorageStatus; + private boolean mStorageValid; + private CleanUpDbTask mCleanUpDbTask; + + private class MountedStorageStatus { + private final boolean mStorageMounted; + private final File mStorageMountedDir; + private final long mStorageMountedCapacity; + + private MountedStorageStatus(boolean mounted, File mountedDir, long capacity) { + mStorageMounted = mounted; + mStorageMountedDir = mountedDir; + mStorageMountedCapacity = capacity; + } + + private boolean isValidForDvr() { + return mStorageMounted && mStorageMountedCapacity >= MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MountedStorageStatus)) { + return false; + } + MountedStorageStatus status = (MountedStorageStatus) other; + return mStorageMounted == status.mStorageMounted + && Objects.equals(mStorageMountedDir, status.mStorageMountedDir) + && mStorageMountedCapacity == status.mStorageMountedCapacity; + } + } + + public interface OnStorageMountChangedListener { + + /** + * Listener for DVR storage status change. + * + * @param storageMounted {@code true} when DVR possible storage is mounted, + * {@code false} otherwise. + */ + void onStorageMountChanged(boolean storageMounted); + } + + private final class StorageStatusBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + MountedStorageStatus result = getStorageStatusInternal(); + if (mMountedStorageStatus.equals(result)) { + return; + } + mMountedStorageStatus = result; + if (result.mStorageMounted && mRunningInMainProcess) { + // Cleans up DB in LC process. + // Tuner process is not always on. + if (mCleanUpDbTask != null) { + mCleanUpDbTask.cancel(true); + } + mCleanUpDbTask = new CleanUpDbTask(); + mCleanUpDbTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + boolean valid = result.isValidForDvr(); + if (valid == mStorageValid) { + return; + } + mStorageValid = valid; + for (OnStorageMountChangedListener l : mOnStorageMountChangedListeners) { + l.onStorageMountChanged(valid); + } + } + } + + /** + * Creates DvrStorageStatusManager. + * + * @param context {@link Context} + */ + public DvrStorageStatusManager(final Context context, boolean runningInMainProcess) { + mContext = context; + mRunningInMainProcess = runningInMainProcess; + mMountedStorageStatus = getStorageStatusInternal(); + mStorageValid = mMountedStorageStatus.isValidForDvr(); + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_MEDIA_MOUNTED); + filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); + filter.addAction(Intent.ACTION_MEDIA_EJECT); + filter.addAction(Intent.ACTION_MEDIA_REMOVED); + filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL); + filter.addDataScheme(ContentResolver.SCHEME_FILE); + mContext.registerReceiver(new StorageStatusBroadcastReceiver(), filter); + } + + /** + * Adds the listener for receiving storage status change. + * + * @param listener + */ + public void addListener(OnStorageMountChangedListener listener) { + mOnStorageMountChangedListeners.add(listener); + } + + /** + * Removes the current listener. + */ + public void removeListener(OnStorageMountChangedListener listener) { + mOnStorageMountChangedListeners.remove(listener); + } + + /** + * Returns true if a storage is mounted. + */ + public boolean isStorageMounted() { + return mMountedStorageStatus.mStorageMounted; + } + + /** + * Returns the path to DVR recording data directory. + * This can take for a while sometimes. + */ + @WorkerThread + public File getRecordingRootDataDirectory() { + SoftPreconditions.checkState(Looper.myLooper() != Looper.getMainLooper()); + if (mMountedStorageStatus.mStorageMountedDir == null) { + return null; + } + File root = mContext.getExternalFilesDir(null); + String rootPath; + try { + rootPath = root != null ? root.getCanonicalPath() : null; + } catch (IOException | SecurityException e) { + return null; + } + return rootPath == null ? null : new File(rootPath + RECORDING_DATA_SUB_PATH); + } + + /** + * Returns the current storage status for DVR recordings. + * + * @return {@link StorageStatus} + */ + @AnyThread + public @StorageStatus int getDvrStorageStatus() { + MountedStorageStatus status = mMountedStorageStatus; + if (status.mStorageMountedDir == null) { + return STORAGE_STATUS_MISSING; + } + if (CommonFeatures.FORCE_RECORDING_UNTIL_NO_SPACE.isEnabled(mContext)) { + return STORAGE_STATUS_OK; + } + if (status.mStorageMountedCapacity < MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES) { + return STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL; + } + try { + StatFs statFs = new StatFs(status.mStorageMountedDir.toString()); + if (statFs.getAvailableBytes() < MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES) { + return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT; + } + } catch (IllegalArgumentException e) { + // In rare cases, storage status change was not notified yet. + SoftPreconditions.checkState(false); + return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT; + } + return STORAGE_STATUS_OK; + } + + /** + * Returns whether the storage has sufficient storage. + * + * @return {@code true} when there is sufficient storage, {@code false} otherwise + */ + public boolean isStorageSufficient() { + return getDvrStorageStatus() == STORAGE_STATUS_OK; + } + + private MountedStorageStatus getStorageStatusInternal() { + boolean storageMounted = + Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED); + File storageMountedDir = storageMounted ? Environment.getExternalStorageDirectory() : null; + storageMounted = storageMounted && storageMountedDir != null; + long storageMountedCapacity = 0L; + if (storageMounted) { + try { + StatFs statFs = new StatFs(storageMountedDir.toString()); + storageMountedCapacity = statFs.getTotalBytes(); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Storage mount status was changed."); + storageMounted = false; + storageMountedDir = null; + } + } + return new MountedStorageStatus( + storageMounted, storageMountedDir, storageMountedCapacity); + } + + private class CleanUpDbTask extends AsyncTask<Void, Void, Void> { + private final ContentResolver mContentResolver; + + private CleanUpDbTask() { + mContentResolver = mContext.getContentResolver(); + } + + @Override + protected Void doInBackground(Void... params) { + @DvrStorageStatusManager.StorageStatus int storageStatus = getDvrStorageStatus(); + if (storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { + return null; + } + List<ContentProviderOperation> ops = getDeleteOps(storageStatus + == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL); + if (ops == null || ops.isEmpty()) { + return null; + } + Log.i(TAG, "New device storage mounted. # of recordings to be forgotten : " + + ops.size()); + for (int i = 0 ; i < ops.size() && !isCancelled() ; i += BATCH_OPERATION_COUNT) { + int toIndex = (i + BATCH_OPERATION_COUNT) > ops.size() + ? ops.size() : (i + BATCH_OPERATION_COUNT); + ArrayList<ContentProviderOperation> batchOps = + new ArrayList<>(ops.subList(i, toIndex)); + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, batchOps); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Failed to clean up RecordedPrograms.", e); + } + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (mCleanUpDbTask == this) { + mCleanUpDbTask = null; + } + } + + private List<ContentProviderOperation> getDeleteOps(boolean deleteAll) { + List<ContentProviderOperation> ops = new ArrayList<>(); + + try (Cursor c = mContentResolver.query( + TvContract.RecordedPrograms.CONTENT_URI, PROJECTION, null, null, null)) { + if (c == null) { + return null; + } + while (c.moveToNext()) { + @DvrStorageStatusManager.StorageStatus int storageStatus = + getDvrStorageStatus(); + if (isCancelled() + || storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { + ops.clear(); + break; + } + String id = c.getString(0); + String packageName = c.getString(1); + String dataUriString = c.getString(2); + if (dataUriString == null) { + continue; + } + Uri dataUri = Uri.parse(dataUriString); + if (!Utils.isInBundledPackageSet(packageName) + || dataUri == null || dataUri.getPath() == null + || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) { + continue; + } + File recordedProgramDir = new File(dataUri.getPath()); + if (deleteAll || !recordedProgramDir.exists()) { + ops.add(ContentProviderOperation.newDelete( + TvContract.buildRecordedProgramUri(Long.parseLong(id))).build()); + } + } + return ops; + } + } + } +} diff --git a/src/com/android/tv/dvr/DvrUiHelper.java b/src/com/android/tv/dvr/DvrUiHelper.java new file mode 100644 index 00000000..c0d3b0c5 --- /dev/null +++ b/src/com/android/tv/dvr/DvrUiHelper.java @@ -0,0 +1,450 @@ +/* + * 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.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.DvrMissingStorageErrorDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrProgramConflictDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrScheduleDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrSmallSizedStorageErrorDialogFragment; +import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrStopRecordingDialogFragment; +import com.android.tv.dvr.ui.DvrSchedulesActivity; +import com.android.tv.dvr.ui.DvrSeriesDeletionActivity; +import com.android.tv.dvr.ui.DvrSeriesScheduledDialogActivity; +import com.android.tv.dvr.ui.DvrSeriesSettingsActivity; +import com.android.tv.dvr.ui.DvrStopRecordingFragment; +import com.android.tv.dvr.ui.DvrStopSeriesRecordingDialogFragment; +import com.android.tv.dvr.ui.DvrStopSeriesRecordingFragment; +import com.android.tv.dvr.ui.HalfSizedDialogFragment; +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 || seriesRecording.isStopped()) { + 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; + + } + + /** + * Checks if the storage status is good for recording and shows error messages if needed. + * + * @return true if the storage status is fine to be recorded for {@code inputId}. + */ + public static boolean checkStorageStatusAndShowErrorMessage(Activity activity, String inputId) { + if (!Utils.isBundledInput(inputId)) { + return true; + } + DvrStorageStatusManager dvrStorageStatusManager = + TvApplication.getSingletons(activity).getDvrStorageStatusManager(); + int status = dvrStorageStatusManager.getDvrStorageStatus(); + if (status == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL) { + showDvrSmallSizedStorageErrorDialog(activity); + return false; + } else if (status == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { + showDvrMissingStorageErrorDialog(activity, inputId); + return false; + } else if (status == DvrStorageStatusManager.STORAGE_STATUS_FREE_SPACE_INSUFFICIENT) { + // TODO: handle insufficient storage case. + return true; + } else { + 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. + */ + private 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 DVR small sized storage error dialog. + */ + public static void showDvrSmallSizedStorageErrorDialog(Activity activity) { + showDialogFragment(activity, new DvrSmallSizedStorageErrorDialogFragment(), null); + } + + /** + * Shows stop recording dialog. + */ + public static void showStopRecordingDialog(Activity activity, long channelId, int reason, + HalfSizedDialogFragment.OnActionClickListener listener) { + Bundle args = new Bundle(); + args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channelId); + args.putInt(DvrStopRecordingFragment.KEY_REASON, reason); + DvrHalfSizedDialogFragment fragment = new DvrStopRecordingDialogFragment(); + fragment.setOnActionClickListener(listener); + showDialogFragment(activity, fragment, 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_THEN_ID_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. + * + * @param channelIds Channel ID list which has programs belonging to the series. + */ + public static void startSeriesSettingsActivity(Context context, long seriesRecordingId, + @Nullable long[] channelIds, boolean removeEmptySeriesSchedule, + boolean isWindowTranslucent, boolean showViewScheduleOptionInDialog) { + Intent intent = new Intent(context, DvrSeriesSettingsActivity.class); + intent.putExtra(DvrSeriesSettingsActivity.SERIES_RECORDING_ID, seriesRecordingId); + intent.putExtra(DvrSeriesSettingsActivity.CHANNEL_ID_LIST, channelIds); + intent.putExtra(DvrSeriesSettingsActivity.REMOVE_EMPTY_SERIES_RECORDING, + removeEmptySeriesSchedule); + intent.putExtra(DvrSeriesSettingsActivity.IS_WINDOW_TRANSLUCENT, isWindowTranslucent); + intent.putExtra(DvrSeriesSettingsActivity.SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG, + showViewScheduleOptionInDialog); + context.startActivity(intent); + } + + /** + * Shows "series recording scheduled" dialog activity. + */ + public static void StartSeriesScheduledDialogActivity(Context context, + SeriesRecording seriesRecording, boolean showViewScheduleOptionInDialog) { + if (seriesRecording == null) { + return; + } + Intent intent = new Intent(context, DvrSeriesScheduledDialogActivity.class); + intent.putExtra(DvrSeriesScheduledDialogActivity.SERIES_RECORDING_ID, + seriesRecording.getId()); + intent.putExtra(DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION, + showViewScheduleOptionInDialog); + context.startActivity(intent); + } + + /** + * Shows the details activity for the DVR items. The type of DVR items may be + * {@link ScheduledRecording}, {@link RecordedProgram}, or {@link SeriesRecording}. + */ + public static void startDetailsActivity(Activity activity, Object dvrItem, + @Nullable ImageView imageView, boolean hideViewSchedule) { + if (dvrItem == null) { + return; + } + Intent intent = new Intent(activity, DvrDetailsActivity.class); + long recordingId; + int viewType; + if (dvrItem instanceof ScheduledRecording) { + ScheduledRecording schedule = (ScheduledRecording) dvrItem; + recordingId = schedule.getId(); + 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; + } + } else if (dvrItem instanceof RecordedProgram) { + recordingId = ((RecordedProgram) dvrItem).getId(); + viewType = DvrDetailsActivity.RECORDED_PROGRAM_VIEW; + } else if (dvrItem instanceof SeriesRecording) { + recordingId = ((SeriesRecording) dvrItem).getId(); + viewType = DvrDetailsActivity.SERIES_RECORDING_VIEW; + } else { + return; + } + intent.putExtra(DvrDetailsActivity.RECORDING_ID, recordingId); + intent.putExtra(DvrDetailsActivity.DETAILS_VIEW_TYPE, viewType); + 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 cancel all dialog for series schedules list. + */ + public static void showCancelAllSeriesRecordingDialog(DvrSchedulesActivity activity, + SeriesRecording seriesRecording) { + DvrStopSeriesRecordingDialogFragment dvrStopSeriesRecordingDialogFragment = + new DvrStopSeriesRecordingDialogFragment(); + Bundle arguments = new Bundle(); + arguments.putParcelable(DvrStopSeriesRecordingFragment.KEY_SERIES_RECORDING, + seriesRecording); + dvrStopSeriesRecordingDialogFragment.setArguments(arguments); + dvrStopSeriesRecordingDialogFragment.show(activity.getFragmentManager(), + DvrStopSeriesRecordingDialogFragment.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..4eada742 --- /dev/null +++ b/src/com/android/tv/dvr/DvrWatchedPositionManager.java @@ -0,0 +1,154 @@ +/* + * 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 android.support.annotation.IntDef; + +import com.android.tv.common.SharedPreferencesUtils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +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 Map<Long, Set> mListeners = new HashMap<>(); + + /** + * The minimum percentage of recorded program being watched that will be considered as being + * completely watched. + */ + public static final float DVR_WATCHED_THRESHOLD_RATE = 0.98f; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({DVR_WATCHED_STATUS_NEW, DVR_WATCHED_STATUS_WATCHING, DVR_WATCHED_STATUS_WATCHED}) + public @interface DvrWatchedStatus {} + /** + * The status indicates the recorded program has not been watched at all. + */ + public static final int DVR_WATCHED_STATUS_NEW = 0; + /** + * The status indicates the recorded program is being watched. + */ + public static final int DVR_WATCHED_STATUS_WATCHING = 1; + /** + * The status indicates the recorded program was completely watched. + */ + public static final int DVR_WATCHED_STATUS_WATCHED = 2; + + public DvrWatchedPositionManager(Context context) { + mWatchedPositions = context.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); + } + + @DvrWatchedStatus public int getWatchedStatus(RecordedProgram recordedProgram) { + long watchedPosition = getWatchedPosition(recordedProgram.getId()); + if (watchedPosition == TvInputManager.TIME_SHIFT_INVALID_TIME) { + return DVR_WATCHED_STATUS_NEW; + } else if (watchedPosition > recordedProgram + .getDurationMillis() * DVR_WATCHED_THRESHOLD_RATE) { + return DVR_WATCHED_STATUS_WATCHED; + } else { + return DVR_WATCHED_STATUS_WATCHING; + } + } + + /** + * 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/EpisodicProgramLoadTask.java b/src/com/android/tv/dvr/EpisodicProgramLoadTask.java new file mode 100644 index 00000000..15ca2700 --- /dev/null +++ b/src/com/android/tv/dvr/EpisodicProgramLoadTask.java @@ -0,0 +1,382 @@ +/* + * 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.TargetApi; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.net.Uri; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; + +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.data.Program; +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.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * A wrapper of AsyncProgramQueryTask to load the episodic programs for the series recordings. + */ +@TargetApi(Build.VERSION_CODES.N) +abstract public class EpisodicProgramLoadTask { + private static final String TAG = "EpisodicProgramLoadTask"; + + private static final int PROGRAM_ID_INDEX = Program.getColumnIndex(Programs._ID); + private static final int START_TIME_INDEX = + Program.getColumnIndex(Programs.COLUMN_START_TIME_UTC_MILLIS); + 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_PREDICATE = + Programs.COLUMN_START_TIME_UTC_MILLIS + ">? AND " + + Programs.COLUMN_RECORDING_PROHIBITED + "=0"; + private static final String PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM = + Programs.COLUMN_END_TIME_UTC_MILLIS + ">? AND " + + Programs.COLUMN_RECORDING_PROHIBITED + "=0"; + private static final String CHANNEL_ID_PREDICATE = Programs.COLUMN_CHANNEL_ID + "=?"; + private static final String PROGRAM_TITLE_PREDICATE = Programs.COLUMN_TITLE + "=?"; + + private final Context mContext; + private final DvrDataManager mDataManager; + private boolean mQueryAllChannels; + private boolean mLoadCurrentProgram; + private boolean mLoadScheduledEpisode; + private boolean mLoadDisallowedProgram; + // If true, match programs with OPTION_CHANNEL_ALL. + private boolean mIgnoreChannelOption; + private final ArrayList<SeriesRecording> mSeriesRecordings = new ArrayList<>(); + private AsyncProgramQueryTask mProgramQueryTask; + + /** + * + * Constructor used to load programs for one series recording with the given channel option. + */ + public EpisodicProgramLoadTask(Context context, SeriesRecording seriesRecording) { + this(context, Collections.singletonList(seriesRecording)); + } + + /** + * Constructor used to load programs for multiple series recordings. The channel option is + * {@link SeriesRecording#OPTION_CHANNEL_ALL}. + */ + public EpisodicProgramLoadTask(Context context, Collection<SeriesRecording> seriesRecordings) { + mContext = context.getApplicationContext(); + mDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mSeriesRecordings.addAll(seriesRecordings); + } + + /** + * Returns the series recordings. + */ + public List<SeriesRecording> getSeriesRecordings() { + return mSeriesRecordings; + } + + /** + * Returns the program query task. It is {@code null} until it is executed. + */ + @Nullable + public AsyncProgramQueryTask getTask() { + return mProgramQueryTask; + } + + /** + * Enables loading current programs. The default value is {@code false}. + */ + public EpisodicProgramLoadTask setLoadCurrentProgram(boolean loadCurrentProgram) { + SoftPreconditions.checkState(mProgramQueryTask == null, TAG, + "Can't change setting after execution."); + mLoadCurrentProgram = loadCurrentProgram; + return this; + } + + /** + * Enables already schedules episodes. The default value is {@code false}. + */ + public EpisodicProgramLoadTask setLoadScheduledEpisode(boolean loadScheduledEpisode) { + SoftPreconditions.checkState(mProgramQueryTask == null, TAG, + "Can't change setting after execution."); + mLoadScheduledEpisode = loadScheduledEpisode; + return this; + } + + /** + * Enables loading disallowed programs whose schedules were removed manually by the user. + * The default value is {@code false}. + */ + public EpisodicProgramLoadTask setLoadDisallowedProgram(boolean loadDisallowedProgram) { + SoftPreconditions.checkState(mProgramQueryTask == null, TAG, + "Can't change setting after execution."); + mLoadDisallowedProgram = loadDisallowedProgram; + return this; + } + + /** + * Gives the option whether to ignore the channel option when matching programs. + * If {@code ignoreChannelOption} is {@code true}, the program will be matched with + * {@link SeriesRecording#OPTION_CHANNEL_ALL} option. + */ + public EpisodicProgramLoadTask setIgnoreChannelOption(boolean ignoreChannelOption) { + SoftPreconditions.checkState(mProgramQueryTask == null, TAG, + "Can't change setting after execution."); + mIgnoreChannelOption = ignoreChannelOption; + return this; + } + + /** + * Executes the task. + * + * @see com.android.tv.util.AsyncDbTask#executeOnDbThread + */ + public void execute() { + if (SoftPreconditions.checkState(mProgramQueryTask == null, TAG, + "Can't execute task: the task is already running.")) { + mQueryAllChannels = mSeriesRecordings.size() > 1 + || mSeriesRecordings.get(0).getChannelOption() + == SeriesRecording.OPTION_CHANNEL_ALL + || mIgnoreChannelOption; + mProgramQueryTask = createTask(); + mProgramQueryTask.executeOnDbThread(); + } + } + + /** + * Cancels the task. + * + * @see android.os.AsyncTask#cancel + */ + public void cancel(boolean mayInterruptIfRunning) { + if (mProgramQueryTask != null) { + mProgramQueryTask.cancel(mayInterruptIfRunning); + } + } + + /** + * Runs on the UI thread after the program loading finishes successfully. + */ + protected void onPostExecute(List<Program> programs) { + } + + /** + * Runs on the UI thread after the program loading was canceled. + */ + protected void onCancelled(List<Program> programs) { + } + + private AsyncProgramQueryTask createTask() { + SqlParams sqlParams = createSqlParams(); + return new AsyncProgramQueryTask(mContext.getContentResolver(), sqlParams.uri, + sqlParams.selection, sqlParams.selectionArgs, null, sqlParams.filter) { + @Override + protected void onPostExecute(List<Program> programs) { + EpisodicProgramLoadTask.this.onPostExecute(programs); + } + + @Override + protected void onCancelled(List<Program> programs) { + EpisodicProgramLoadTask.this.onCancelled(programs); + } + }; + } + + private SqlParams createSqlParams() { + SqlParams sqlParams = new SqlParams(); + if (PermissionUtils.hasAccessAllEpg(mContext)) { + sqlParams.uri = Programs.CONTENT_URI; + // Base + StringBuilder selection = new StringBuilder(mLoadCurrentProgram + ? PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM : PROGRAM_PREDICATE); + List<String> args = new ArrayList<>(); + args.add(Long.toString(System.currentTimeMillis())); + // Channel option + if (!mQueryAllChannels) { + selection.append(" AND ").append(CHANNEL_ID_PREDICATE); + args.add(Long.toString(mSeriesRecordings.get(0).getChannelId())); + } + // Title + if (mSeriesRecordings.size() == 1) { + selection.append(" AND ").append(PROGRAM_TITLE_PREDICATE); + args.add(mSeriesRecordings.get(0).getTitle()); + } + sqlParams.selection = selection.toString(); + sqlParams.selectionArgs = args.toArray(new String[args.size()]); + sqlParams.filter = new SeriesRecordingCursorFilter(mSeriesRecordings); + } else { + // The query includes the current program. Will be filtered if needed. + if (mQueryAllChannels) { + 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 { + sqlParams.uri = TvContract.buildProgramsUriForChannel( + mSeriesRecordings.get(0).getChannelId(), + System.currentTimeMillis(), Long.MAX_VALUE); + } + sqlParams.selection = null; + sqlParams.selectionArgs = null; + sqlParams.filter = new SeriesRecordingCursorFilterForNonSystem(mSeriesRecordings); + } + 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); + } + + /** + * 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 Set<Long> mDisallowedProgramIds = new HashSet<>(); + private final Set<ScheduledEpisode> mScheduledEpisodes = new HashSet<>(); + + SeriesRecordingCursorFilter(List<SeriesRecording> seriesRecordings) { + if (!mLoadDisallowedProgram) { + mDisallowedProgramIds.addAll(mDataManager.getDisallowedProgramIds()); + } + if (!mLoadScheduledEpisode) { + Set<Long> seriesRecordingIds = new HashSet<>(); + for (SeriesRecording r : seriesRecordings) { + seriesRecordingIds.add(r.getId()); + } + 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 (!mLoadDisallowedProgram + && mDisallowedProgramIds.contains(c.getLong(PROGRAM_ID_INDEX))) { + return false; + } + Program program = Program.fromCursor(c); + for (SeriesRecording seriesRecording : mSeriesRecordings) { + boolean programMatches; + if (mIgnoreChannelOption) { + programMatches = seriesRecording.matchProgram(program, + SeriesRecording.OPTION_CHANNEL_ALL); + } else { + programMatches = seriesRecording.matchProgram(program); + } + if (programMatches) { + return mLoadScheduledEpisode + || !isEpisodeScheduled(mScheduledEpisodes, new ScheduledEpisode( + seriesRecording.getId(), program.getSeasonNumber(), + program.getEpisodeNumber())); + } + } + return false; + } + } + + private class SeriesRecordingCursorFilterForNonSystem extends SeriesRecordingCursorFilter { + SeriesRecordingCursorFilterForNonSystem(List<SeriesRecording> seriesRecordings) { + super(seriesRecordings); + } + + @Override + public boolean filter(Cursor c) { + return (mLoadCurrentProgram || c.getLong(START_TIME_INDEX) > System.currentTimeMillis()) + && 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; + } + + /** + * A plain java object which includes the season/episode number for the series recording. + */ + public 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 + + '}'; + } + } +} 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..53c89ebc --- /dev/null +++ b/src/com/android/tv/dvr/InputTaskScheduler.java @@ -0,0 +1,431 @@ +/* + * 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.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 com.android.tv.util.CompositeComparator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * The scheduler for a TV input. + */ +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; + private static final int MSG_STOP_SCHEDULE = 5; + + private static final float MIN_REMAIN_DURATION_PERCENT = 0.05f; + + // The candidate comparator should be the consistent with + // DvrScheduleManager#CANDIDATE_COMPARATOR. + private static final Comparator<RecordingTask> CANDIDATE_COMPARATOR = + new CompositeComparator<>( + RecordingTask.PRIORITY_COMPARATOR, + RecordingTask.END_TIME_COMPARATOR, + RecordingTask.ID_COMPARATOR); + + /** + * Returns the comparator which the schedules are sorted with when executed. + */ + public static Comparator<ScheduledRecording> getRecordingOrderComparator() { + return ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR; + } + + /** + * 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; + } + } + + /** + * Stops the input task scheduler. + */ + public void stop() { + mHandler.removeCallbacksAndMessages(null); + mHandler.sendEmptyMessage(MSG_STOP_SCHEDULE); + } + + private void handleStopSchedule() { + mWaitingSchedules.clear(); + int size = mPendingRecordings.size(); + for (int i = 0; i < size; ++i) { + RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask; + task.cleanUp(); + } + } + + @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 + <= MIN_REMAIN_DURATION_PERCENT * schedule.getDuration()) { + 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); + } + } + // The schedules will be executed with the following order. + // 1. The schedule which starts early. It can be replaced later when the schedule with the + // higher priority needs to start. + // 2. The schedule with the higher priority. It can be replaced later when the schedule with + // the higher priority needs to start. + // 3. The schedule which was created recently. + Collections.sort(schedulesToStart, getRecordingOrderComparator()); + 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; + } + } + } + if (mWaitingSchedules.isEmpty()) { + return; + } + // Set next scheduling. + long earliest = Long.MAX_VALUE; + for (ScheduledRecording schedule : mWaitingSchedules.values()) { + // The conflicting schedules will be removed if they end before conflicting resolved. + if (schedulesToStart.contains(schedule)) { + if (earliest > schedule.getEndTimeMs()) { + earliest = schedule.getEndTimeMs(); + } + } else { + if (earliest > schedule.getStartTimeMs() + - RecordingTask.RECORDING_EARLY_START_OFFSET_MS) { + earliest = schedule.getStartTimeMs() + - RecordingTask.RECORDING_EARLY_START_OFFSET_MS; + } + } + } + mHandler.sendEmptyMessageDelayed(MSG_BUILD_SCHEDULE, earliest - 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) { + // Returns the recording with the following priority. + // 1. The recording with the lowest priority is returned. + // 2. If the priorities are the same, the recording which finishes early is returned. + // 3. If 1) and 2) are the same, the early created schedule is returned. + 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()) { + if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, task) > 0) { + 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; + case MSG_STOP_SCHEDULE: + handleStopSchedule(); + 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..dd744f80 --- /dev/null +++ b/src/com/android/tv/dvr/RecordedProgram.java @@ -0,0 +1,868 @@ +/* + * 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.annotation.TargetApi; +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.os.Build; +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.GenreItems; +import com.android.tv.data.InternalDataUtils; +import com.android.tv.util.Utils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Immutable instance of {@link android.media.tv.TvContract.RecordedPrograms}. + */ +@TargetApi(Build.VERSION_CODES.N) +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; + } + + /** + * Returns array of canonical genre ID's for this recorded program. + */ + @Override + public int[] getCanonicalGenreIds() { + if (mCanonicalGenres == null) { + return null; + } + int[] genreIds = new int[mCanonicalGenres.length]; + for (int i = 0; i < mCanonicalGenres.length; i++) { + genreIds[i] = GenreItems.getId(mCanonicalGenres[i]); + } + return genreIds; + } + + @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); + } + } + + @Nullable + public String getEpisodeDisplayNumber(Context context) { + if (!TextUtils.isEmpty(mEpisodeNumber)) { + if (TextUtils.equals(mSeasonNumber, "0")) { + // Do not show "S0: ". + return String.format(context.getResources().getString( + R.string.display_episode_number_format_no_season_number), mEpisodeNumber); + } else { + return String.format(context.getResources().getString( + R.string.display_episode_number_format), mSeasonNumber, mEpisodeNumber); + } + } + return null; + } + + 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; + } + + @Override + 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); + } + + /** + * Returns an array containing all of the elements in the list. + */ + public static RecordedProgram[] toArray(Collection<RecordedProgram> recordedPrograms) { + return recordedPrograms.toArray(new RecordedProgram[recordedPrograms.size()]); + } +} diff --git a/src/com/android/tv/dvr/RecordingTask.java b/src/com/android/tv/dvr/RecordingTask.java index 804485b3..c3d236b0 100644 --- a/src/com/android/tv/dvr/RecordingTask.java +++ b/src/com/android/tv/dvr/RecordingTask.java @@ -16,21 +16,32 @@ 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; +import java.util.Comparator; import java.util.concurrent.TimeUnit; /** @@ -40,22 +51,66 @@ 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; - @VisibleForTesting - static final int MESSAGE_START_RECORDING = 2; - @VisibleForTesting - static final int MESSAGE_STOP_RECORDING = 3; + /** + * Compares the end time in ascending order. + */ + public static final Comparator<RecordingTask> END_TIME_COMPARATOR + = new Comparator<RecordingTask>() { + @Override + public int compare(RecordingTask lhs, RecordingTask rhs) { + return Long.compare(lhs.getEndTimeMs(), rhs.getEndTimeMs()); + } + }; + + /** + * Compares ID in ascending order. + */ + public static final Comparator<RecordingTask> ID_COMPARATOR + = new Comparator<RecordingTask>() { + @Override + public int compare(RecordingTask lhs, RecordingTask rhs) { + return Long.compare(lhs.getScheduleId(), rhs.getScheduleId()); + } + }; + + /** + * Compares the priority in ascending order. + */ + public static final Comparator<RecordingTask> PRIORITY_COMPARATOR + = new Comparator<RecordingTask>() { + @Override + public int compare(RecordingTask lhs, RecordingTask rhs) { + return Long.compare(lhs.getPriority(), rhs.getPriority()); + } + }; @VisibleForTesting - static final long MS_BEFORE_START = TimeUnit.SECONDS.toMillis(5); + static final int MSG_INITIALIZE = 1; @VisibleForTesting - static final long MS_AFTER_END = TimeUnit.SECONDS.toMillis(5); + static final int MSG_START_RECORDING = 2; + @VisibleForTesting + 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 +118,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 +161,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 +195,91 @@ 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 onConnectionFailed(String inputId) { + if (DEBUG) Log.d(TAG, "onConnectionFailed(" + inputId + ")"); + if (mRecordingSession != null) { + 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 +293,12 @@ 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, mScheduledRecording.getEndTimeMs()); + mState = State.SESSION_ACQUIRED; mDvrManager.addListener(this, mHandler); - mTvRecordingClient.tune(inputId, mChannel.getUri()); + mRecordingSession.tune(inputId, mChannel.getUri()); mState = State.CONNECTION_PENDING; } @@ -218,41 +312,86 @@ 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()) { + if (mRecordingSession != null) { + mRecordingSession.setEndTimeMs(schedule.getEndTimeMs()); + } + if (mState == State.RECORDING_STARTED) { + mHandler.removeMessages(MSG_STOP_RECORDING); + if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING, schedule.getEndTimeMs())) { + failAndQuit(); + } + } + } + } + @VisibleForTesting State getState() { return mState; } + private long getScheduleId() { + return mScheduledRecording.getId(); + } + + /** + * 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 +407,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; - mMainThreadHandler.post(new Runnable() { + if (DEBUG) Log.d(TAG, "Updating the state of " + mScheduledRecording + " to " + state); + mScheduledRecording = ScheduledRecording.buildFrom(mScheduledRecording).setState(state) + .build(); + runOnMainThread(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 +434,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 +461,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 +470,50 @@ class RecordingTask extends TvRecordingClient.RecordingCallback } } + /** + * Cancels the task + */ + public void cancel() { + if (DEBUG) Log.d(TAG, "cancel"); + mCanceled = true; + stop(); + removeRecordedProgram(); + } + + /** + * Clean up the task. + */ + public void cleanUp() { + if (mState == State.RECORDING_STARTED || mState == State.RECORDING_STOP_REQUESTED) { + updateRecordingState(ScheduledRecording.STATE_RECORDING_FAILED); + } + release(); + if (mHandler != null) { + mHandler.removeCallbacksAndMessages(null); + } + } + @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..2bda10ea 100644 --- a/src/com/android/tv/dvr/ScheduledRecording.java +++ b/src/com/android/tv/dvr/ScheduledRecording.java @@ -17,87 +17,169 @@ 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.CompositeComparator; 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>() { + /** + * Compares the start time in ascending order. + */ + 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>() { + /** + * Compares 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) { - int value = Long.compare(lhs.mPriority, rhs.mPriority); - if (value == 0) { - value = Long.compare(lhs.mId, rhs.mId); - } - return value; + return Long.compare(lhs.mEndTimeMs, rhs.mEndTimeMs); + } + }; + + /** + * Compares ID in ascending order. The schedule with the larger ID was created later. + */ + public static final Comparator<ScheduledRecording> ID_COMPARATOR + = new Comparator<ScheduledRecording>() { + @Override + public int compare(ScheduledRecording lhs, ScheduledRecording rhs) { + return Long.compare(lhs.mId, rhs.mId); } }; - public static final Comparator<ScheduledRecording> START_TIME_THEN_PRIORITY_COMPARATOR + /** + * Compares the priority in ascending order. + */ + public static final Comparator<ScheduledRecording> PRIORITY_COMPARATOR = new Comparator<ScheduledRecording>() { @Override public int compare(ScheduledRecording lhs, ScheduledRecording rhs) { - int value = START_TIME_COMPARATOR.compare(lhs, rhs); - if (value == 0) { - value = PRIORITY_COMPARATOR.compare(lhs, rhs); - } - return value; + return Long.compare(lhs.mPriority, rhs.mPriority); } }; - public static Builder builder(Program p) { + /** + * Compares start time in ascending order and then priority in descending order and then ID in + * descending order. + */ + public static final Comparator<ScheduledRecording> START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR + = new CompositeComparator<>(START_TIME_COMPARATOR, PRIORITY_COMPARATOR.reversed(), + ID_COMPARATOR.reversed()); + + /** + * 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 +193,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 +208,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 setEndTime(long endTime) { - mEndTime = endTime; + 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 setProgramPosterArtUri(String programPosterArtUri) { + mProgramPosterArtUri = programPosterArtUri; + return this; + } + + public Builder setProgramThumbnailUri(String programThumbnailUri) { + mProgramThumbnailUri = programThumbnailUri; return this; } @@ -141,14 +268,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 +286,37 @@ 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) + .setPriority(orig.mPriority) .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 +324,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 +366,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 +517,13 @@ public final class ScheduledRecording { } /** + * Returns schedules' input id. + */ + public String getInputId() { + return mInputId; + } + + /** * Returns recorded {@link Channel}. */ public long getChannelId() { @@ -295,6 +538,13 @@ public final class ScheduledRecording { } /** + * Return the optional program Title + */ + public String getProgramTitle() { + return mProgramTitle; + } + + /** * Returns started time. */ public long getStartTimeMs() { @@ -309,6 +559,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 +615,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 +700,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 +741,147 @@ 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(); + } + + /** + * Checks if the {@code schedule} overlaps with this schedule. + */ + public boolean isOverLapping(ScheduledRecording schedule) { + return mStartTimeMs < schedule.getEndTimeMs() && mEndTimeMs > schedule.getStartTimeMs(); } @Override public String toString() { return "ScheduledRecording[" + mId + "]" - + "(startTime=" + Utils.toIsoDateTimeString(mStartTimeMs) - + ",endTime=" + Utils.toIsoDateTimeString(mEndTimeMs) + + "(inputId=" + mInputId + + ",channelId=" + mChannelId + + ",programId=" + mProgramId + + ",programTitle=" + mProgramTitle + + ",type=" + mType + + ",startTime=" + Utils.toIsoDateTimeString(mStartTimeMs) + "(" + mStartTimeMs + ")" + + ",endTime=" + Utils.toIsoDateTimeString(mEndTimeMs) + "(" + 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..ce78e1be 100644 --- a/src/com/android/tv/dvr/Scheduler.java +++ b/src/com/android/tv/dvr/Scheduler.java @@ -20,86 +20,121 @@ 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() { + for (InputTaskScheduler inputTaskScheduler : mInputSchedulerMap.values()) { + inputTaskScheduler.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 +145,139 @@ 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) { + InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId()); + 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) { + InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId()); + if (scheduler != null) { + scheduler.updateSchedule(schedule); + } + } + handleScheduleChange(schedules); + } + + 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 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 scheduleRecordingSoon(ScheduledRecording schedule) { + TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId()); + 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. - mAlarmManager.set(AlarmManager.RTC_WAKEUP, wakeAt, alarmIntent); + // This will cancel the previous alarm. + mAlarmManager.setExactAndAllowWhileIdle(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/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..f0690f5f --- /dev/null +++ b/src/com/android/tv/dvr/SeriesRecording.java @@ -0,0 +1,755 @@ +/* + * 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.VisibleForTesting; +import android.text.TextUtils; + +import com.android.tv.data.BaseProgram; +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_STOPPED}) + public @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 stopped. + */ + public static final int STATE_SERIES_STOPPED = 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 BaseProgram}. + */ + public static Builder builder(String inputId, BaseProgram 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(seriesRecordingState(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, seriesRecordingState(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 seriesRecordingState(@SeriesState int state) { + switch (state) { + case STATE_SERIES_NORMAL: + return SeriesRecordings.STATE_SERIES_NORMAL; + case STATE_SERIES_STOPPED: + return SeriesRecordings.STATE_SERIES_STOPPED; + } + return SeriesRecordings.STATE_SERIES_NORMAL; + } + + @SeriesState private static int seriesRecordingState(String state) { + switch (state) { + case SeriesRecordings.STATE_SERIES_NORMAL: + return STATE_SERIES_NORMAL; + case SeriesRecordings.STATE_SERIES_STOPPED: + return STATE_SERIES_STOPPED; + } + 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. The channel ID might not be valid when the channel option is "ALL". + */ + 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. + */ + 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; + } + + /** + * Checks whether the series recording is stopped or not. + */ + public boolean isStopped() { + return mState == STATE_SERIES_STOPPED; + } + + @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, mChannelOption); + } + + /** + * 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, @ChannelOption int channelOption) { + String seriesId = program.getSeriesId(); + long channelId = program.getChannelId(); + String seasonNumber = program.getSeasonNumber(); + String episodeNumber = program.getEpisodeNumber(); + if (!mSeriesId.equals(seriesId) || (channelOption == 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..5ed12ce8 --- /dev/null +++ b/src/com/android/tv/dvr/SeriesRecordingScheduler.java @@ -0,0 +1,579 @@ +/* + * 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.os.AsyncTask; +import android.os.Build; +import android.support.annotation.MainThread; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.ArraySet; +import android.util.Log; +import android.util.LongSparseArray; + +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.dvr.EpisodicProgramLoadTask.ScheduledEpisode; +import com.android.tv.experiments.Experiments; + +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.concurrent.CopyOnWriteArraySet; +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 boolean DEBUG = false; + + 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 boolean mPaused; + private final Set<Long> mPendingSeriesRecordings = new ArraySet<>(); + private final Set<OnSeriesRecordingUpdatedListener> mOnSeriesRecordingUpdatedListeners = + new CopyOnWriteArraySet<>(); + + + 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.getSeriesRecordings(), seriesRecordings, + SeriesRecording.ID_COMPARATOR).isEmpty()) { + task.cancel(true); + iter.remove(); + } + } + } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + List<SeriesRecording> stopped = new ArrayList<>(); + List<SeriesRecording> normal = new ArrayList<>(); + for (SeriesRecording r : seriesRecordings) { + if (r.isStopped()) { + stopped.add(r); + } else { + normal.add(r); + } + } + if (!stopped.isEmpty()) { + onSeriesRecordingRemoved(SeriesRecording.toArray(stopped)); + } + if (!normal.isEmpty()) { + updateSchedules(normal); + } + } + }; + + 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) { + 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; + } + if (DEBUG) Log.d(TAG, "start"); + mStarted = true; + mDataManager.addSeriesRecordingListener(mSeriesRecordingListener); + mDataManager.addScheduledRecordingListener(mScheduledRecordingListener); + startFetchingSeriesInfo(); + updateSchedules(mDataManager.getSeriesRecordings()); + } + + @MainThread + public void stop() { + if (!mStarted) { + return; + } + if (DEBUG) Log.d(TAG, "stop"); + mStarted = false; + for (FetchSeriesInfoTask task : mFetchSeriesInfoTasks) { + task.cancel(true); + } + mFetchSeriesInfoTasks.clear(); + for (SeriesRecordingUpdateTask task : mScheduleTasks) { + task.cancel(true); + } + mScheduleTasks.clear(); + 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); + } + } + + /** + * Pauses the updates of the series recordings. + */ + public void pauseUpdate() { + if (DEBUG) Log.d(TAG, "Schedule paused"); + if (mPaused) { + return; + } + mPaused = true; + if (!mStarted) { + return; + } + for (SeriesRecordingUpdateTask task : mScheduleTasks) { + for (SeriesRecording r : task.getSeriesRecordings()) { + mPendingSeriesRecordings.add(r.getId()); + } + task.cancel(true); + } + } + + /** + * Resumes the updates of the series recordings. + */ + public void resumeUpdate() { + if (DEBUG) Log.d(TAG, "Schedule resumed"); + if (!mPaused) { + return; + } + mPaused = false; + if (!mStarted) { + return; + } + if (!mPendingSeriesRecordings.isEmpty()) { + List<SeriesRecording> seriesRecordings = new ArrayList<>(); + for (long seriesRecordingId : mPendingSeriesRecordings) { + SeriesRecording seriesRecording = + mDataManager.getSeriesRecording(seriesRecordingId); + if (seriesRecording != null) { + seriesRecordings.add(seriesRecording); + } + } + if (!seriesRecordings.isEmpty()) { + updateSchedules(seriesRecordings); + } + } + } + + /** + * Update schedules for the given series recordings. If it's paused, the update will be done + * after it's resumed. + */ + public void updateSchedules(Collection<SeriesRecording> seriesRecordings) { + if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings); + if (!mStarted) { + if (DEBUG) Log.d(TAG, "Not started yet."); + return; + } + if (mPaused) { + for (SeriesRecording r : seriesRecordings) { + mPendingSeriesRecordings.add(r.getId()); + } + if (DEBUG) { + Log.d(TAG, "The scheduler has been paused. Adding to the pending list. size=" + + mPendingSeriesRecordings.size()); + } + return; + } + Set<SeriesRecording> previousSeriesRecordings = new HashSet<>(); + for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); + iter.hasNext(); ) { + SeriesRecordingUpdateTask task = iter.next(); + if (CollectionUtils.containsAny(task.getSeriesRecordings(), seriesRecordings, + SeriesRecording.ID_COMPARATOR)) { + // The task is affected by the seriesRecordings + task.cancel(true); + previousSeriesRecordings.addAll(task.getSeriesRecordings()); + iter.remove(); + } + } + List<SeriesRecording> seriesRecordingsToUpdate = CollectionUtils.union(seriesRecordings, + previousSeriesRecordings, SeriesRecording.ID_COMPARATOR); + for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator(); + iter.hasNext(); ) { + SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId()); + if (seriesRecording == null || seriesRecording.isStopped()) { + // Series recording has been removed or stopped. + iter.remove(); + } + } + if (seriesRecordingsToUpdate.isEmpty()) { + return; + } + if (needToReadAllChannels(seriesRecordingsToUpdate)) { + SeriesRecordingUpdateTask task = + new SeriesRecordingUpdateTask(seriesRecordingsToUpdate); + mScheduleTasks.add(task); + if (DEBUG) Log.d(TAG, "Added schedule task: " + task); + task.execute(); + } else { + for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { + SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask( + Collections.singletonList(seriesRecording)); + mScheduleTasks.add(task); + if (DEBUG) Log.d(TAG, "Added schedule task: " + task); + task.execute(); + } + } + } + + /** + * Adds {@link OnSeriesRecordingUpdatedListener}. + */ + public void addOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) { + mOnSeriesRecordingUpdatedListeners.add(listener); + } + + /** + * Removes {@link OnSeriesRecordingUpdatedListener}. + */ + public void removeOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) { + mOnSeriesRecordingUpdatedListeners.remove(listener); + } + + private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) { + for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) { + if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) { + return true; + } + } + return false; + } + + /** + * 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 LongSparseArray<List<Program>> pickOneProgramPerEpisode( + List<SeriesRecording> seriesRecordings, List<Program> programs) { + return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs); + } + + /** + * @see #pickOneProgramPerEpisode(List, List) + */ + @VisibleForTesting + static LongSparseArray<List<Program>> pickOneProgramPerEpisode( + DvrDataManager dataManager, List<SeriesRecording> seriesRecordings, + List<Program> programs) { + // Initialize. + LongSparseArray<List<Program>> result = new LongSparseArray<>(); + 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 EpisodicProgramLoadTask { + SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) { + super(mContext, seriesRecordings); + } + + @Override + protected void onPostExecute(List<Program> programs) { + if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs); + mScheduleTasks.remove(this); + if (programs == null) { + Log.e(TAG, "Creating schedules for series recording failed: " + + getSeriesRecordings()); + return; + } + LongSparseArray<List<Program>> seriesProgramMap = pickOneProgramPerEpisode( + getSeriesRecordings(), programs); + for (SeriesRecording seriesRecording : getSeriesRecordings()) { + // Check the series recording is still valid. + SeriesRecording actualSeriesRecording = mDataManager.getSeriesRecording( + seriesRecording.getId()); + if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) { + continue; + } + List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId()); + if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null + && !programsToSchedule.isEmpty()) { + mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule); + } + } + if (!mOnSeriesRecordingUpdatedListeners.isEmpty()) { + for (OnSeriesRecordingUpdatedListener listener + : mOnSeriesRecordingUpdatedListeners) { + listener.onSeriesRecordingUpdated( + SeriesRecording.toArray(getSeriesRecordings())); + } + } + } + + @Override + protected void onCancelled(List<Program> programs) { + mScheduleTasks.remove(this); + } + + @Override + public String toString() { + return "SeriesRecordingUpdateTask:{" + + "series_recordings=" + getSeriesRecordings() + + "}"; + } + } + + private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> { + private SeriesRecording mSeriesRecording; + + FetchSeriesInfoTask(SeriesRecording seriesRecording) { + mSeriesRecording = seriesRecording; + } + + @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); + } + } + + /** + * A listener to notify when series recording are updated. + */ + public interface OnSeriesRecordingUpdatedListener { + void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings); + } +} diff --git a/src/com/android/tv/dvr/WritableDvrDataManager.java b/src/com/android/tv/dvr/WritableDvrDataManager.java index 0b8a4c99..bf72d912 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,50 @@ 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 removeScheduledRecording(ScheduledRecording... scheduledRecordings); + + /** + * Removes recordings. If {@code forceRemove} is {@code true}, the schedule will be permanently + * removed instead of changing the state to DELETED. + */ + void removeScheduledRecording(boolean forceRemove, ScheduledRecording... scheduledRecordings); + + /** + * Removes series recordings. */ - void addScheduledRecording(ScheduledRecording scheduledRecording); + void removeSeriesRecording(SeriesRecording... seasonSchedules); /** - * Add a season recording/ + * Updates existing recordings. */ - void addSeasonRecording(SeasonRecording seasonRecording); + void updateScheduledRecording(ScheduledRecording... scheduledRecordings); /** - * Remove a recording. + * Updates existing series recordings. */ - void removeScheduledRecording(ScheduledRecording ScheduledRecording); + void updateSeriesRecording(SeriesRecording... seriesRecordings); /** - * Remove a season schedule. + * Changes the state of the recording. */ - void removeSeasonSchedule(SeasonRecording seasonSchedule); + void changeState(ScheduledRecording scheduledRecording, @RecordingState int newState); /** - * Update an existing recording. + * Remove all the records related to the input. + * <p> + * Note that this should be called after the input was removed. */ - void updateScheduledRecording(ScheduledRecording r); + void forgetStorage(String inputId); } 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..f0aca18e 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 stopped. + */ + public static final String STATE_SERIES_STOPPED = "STATE_SERIES_STOPPED"; + + /** + * 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_STOPPED}. 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..175f05bc --- /dev/null +++ b/src/com/android/tv/dvr/ui/DetailsContentPresenter.java @@ -0,0 +1,300 @@ +/* + * 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.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.graphics.Paint; +import android.graphics.Paint.FontMetricsInt; +import android.support.v17.leanback.widget.Presenter; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.tv.R; +import com.android.tv.ui.ViewUtils; +import com.android.tv.util.Utils; + +/** + * An {@link Presenter} for rendering a detailed description of an DVR item. + * Typically this Presenter will be used in a {@link DetailsOverviewRowPresenter}. + * Most codes of this class is originated from + * {@link android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter}. + * The latter class are re-used to provide a customized version of + * {@link android.support.v17.leanback.widget.DetailsOverviewRow}. + */ +public class DetailsContentPresenter extends Presenter { + /** + * The ViewHolder for the {@link DetailsContentPresenter}. + */ + public static class ViewHolder extends Presenter.ViewHolder { + final TextView mTitle; + final TextView mSubtitle; + final LinearLayout mDescriptionContainer; + final TextView mBody; + final TextView mReadMoreView; + final int mTitleMargin; + final int mUnderTitleBaselineMargin; + final int mUnderSubtitleBaselineMargin; + final int mTitleLineSpacing; + final int mBodyLineSpacing; + final int mBodyMaxLines; + final int mBodyMinLines; + final FontMetricsInt mTitleFontMetricsInt; + final FontMetricsInt mSubtitleFontMetricsInt; + final FontMetricsInt mBodyFontMetricsInt; + final int mTitleMaxLines; + + private Activity mActivity; + private boolean mFullTextMode; + private int mFullTextAnimationDuration; + private boolean mIsListeningToPreDraw; + + private ViewTreeObserver.OnPreDrawListener mPreDrawListener = + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (mSubtitle.getVisibility() == View.VISIBLE + && mSubtitle.getTop() > view.getHeight() + && mTitle.getLineCount() > 1) { + mTitle.setMaxLines(mTitle.getLineCount() - 1); + return false; + } + final int bodyLines = mBody.getLineCount(); + final int maxLines = mFullTextMode ? bodyLines : + (mTitle.getLineCount() > 1 ? mBodyMinLines : mBodyMaxLines); + if (bodyLines > maxLines) { + mReadMoreView.setVisibility(View.VISIBLE); + mDescriptionContainer.setFocusable(true); + mDescriptionContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + mFullTextMode = true; + mReadMoreView.setVisibility(View.GONE); + mDescriptionContainer.setFocusable(false); + mDescriptionContainer.setOnClickListener(null); + mBody.setMaxLines(bodyLines); + // Minus 1 from line difference to eliminate the space + // originally occupied by "READ MORE" + showFullText((bodyLines - maxLines - 1) * mBodyLineSpacing); + } + }); + } + if (mBody.getMaxLines() != maxLines) { + mBody.setMaxLines(maxLines); + return false; + } else { + removePreDrawListener(); + return true; + } + } + }; + + public ViewHolder(final View view) { + super(view); + mTitle = (TextView) view.findViewById(R.id.dvr_details_description_title); + mSubtitle = (TextView) view.findViewById(R.id.dvr_details_description_subtitle); + mBody = (TextView) view.findViewById(R.id.dvr_details_description_body); + mDescriptionContainer = + (LinearLayout) view.findViewById(R.id.dvr_details_description_container); + mReadMoreView = (TextView) view.findViewById(R.id.dvr_details_description_read_more); + + FontMetricsInt titleFontMetricsInt = getFontMetricsInt(mTitle); + final int titleAscent = view.getResources().getDimensionPixelSize( + R.dimen.lb_details_description_title_baseline); + // Ascent is negative + mTitleMargin = titleAscent + titleFontMetricsInt.ascent; + + mUnderTitleBaselineMargin = view.getResources().getDimensionPixelSize( + R.dimen.lb_details_description_under_title_baseline_margin); + mUnderSubtitleBaselineMargin = view.getResources().getDimensionPixelSize( + R.dimen.lb_details_description_under_subtitle_baseline_margin); + + mTitleLineSpacing = view.getResources().getDimensionPixelSize( + R.dimen.lb_details_description_title_line_spacing); + mBodyLineSpacing = view.getResources().getDimensionPixelSize( + R.dimen.lb_details_description_body_line_spacing); + + mBodyMaxLines = view.getResources().getInteger( + R.integer.lb_details_description_body_max_lines); + mBodyMinLines = view.getResources().getInteger( + R.integer.lb_details_description_body_min_lines); + mTitleMaxLines = mTitle.getMaxLines(); + + mTitleFontMetricsInt = getFontMetricsInt(mTitle); + mSubtitleFontMetricsInt = getFontMetricsInt(mSubtitle); + mBodyFontMetricsInt = getFontMetricsInt(mBody); + } + + void addPreDrawListener() { + if (!mIsListeningToPreDraw) { + mIsListeningToPreDraw = true; + view.getViewTreeObserver().addOnPreDrawListener(mPreDrawListener); + } + } + + void removePreDrawListener() { + if (mIsListeningToPreDraw) { + view.getViewTreeObserver().removeOnPreDrawListener(mPreDrawListener); + mIsListeningToPreDraw = false; + } + } + + public TextView getTitle() { + return mTitle; + } + + public TextView getSubtitle() { + return mSubtitle; + } + + public TextView getBody() { + return mBody; + } + + private FontMetricsInt getFontMetricsInt(TextView textView) { + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setTextSize(textView.getTextSize()); + paint.setTypeface(textView.getTypeface()); + return paint.getFontMetricsInt(); + } + + private void showFullText(int heightDiff) { + final ViewGroup detailsFrame = (ViewGroup) mActivity.findViewById(R.id.details_frame); + int nowHeight = ViewUtils.getLayoutHeight(detailsFrame); + Animator expandAnimator = ViewUtils.createHeightAnimator( + detailsFrame, nowHeight, nowHeight + heightDiff); + expandAnimator.setDuration(mFullTextAnimationDuration); + Animator shiftAnimator = ObjectAnimator.ofPropertyValuesHolder(detailsFrame, + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, + 0f, -(heightDiff / 2))); + shiftAnimator.setDuration(mFullTextAnimationDuration); + AnimatorSet fullTextAnimator = new AnimatorSet(); + fullTextAnimator.playTogether(expandAnimator, shiftAnimator); + fullTextAnimator.start(); + } + } + + private final Activity mActivity; + private final int mFullTextAnimationDuration; + + public DetailsContentPresenter(Activity activity) { + super(); + mActivity = activity; + mFullTextAnimationDuration = mActivity.getResources() + .getInteger(R.integer.dvr_details_full_text_animation_duration); + } + + @Override + public final ViewHolder onCreateViewHolder(ViewGroup parent) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.dvr_details_description, parent, false); + return new ViewHolder(v); + } + + @Override + public final void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) { + final ViewHolder vh = (ViewHolder) viewHolder; + final DetailsContent detailsContent = (DetailsContent) item; + + vh.mActivity = mActivity; + vh.mFullTextAnimationDuration = mFullTextAnimationDuration; + + boolean hasTitle = true; + if (TextUtils.isEmpty(detailsContent.getTitle())) { + vh.mTitle.setVisibility(View.GONE); + hasTitle = false; + } else { + vh.mTitle.setText(detailsContent.getTitle()); + vh.mTitle.setVisibility(View.VISIBLE); + vh.mTitle.setLineSpacing(vh.mTitleLineSpacing - vh.mTitle.getLineHeight() + + vh.mTitle.getLineSpacingExtra(), vh.mTitle.getLineSpacingMultiplier()); + vh.mTitle.setMaxLines(vh.mTitleMaxLines); + } + setTopMargin(vh.mTitle, vh.mTitleMargin); + + boolean hasSubtitle = true; + if (detailsContent.getStartTimeUtcMillis() != DetailsContent.INVALID_TIME + && detailsContent.getEndTimeUtcMillis() != DetailsContent.INVALID_TIME) { + vh.mSubtitle.setText(Utils.getDurationString(viewHolder.view.getContext(), + detailsContent.getStartTimeUtcMillis(), + detailsContent.getEndTimeUtcMillis(), false)); + vh.mSubtitle.setVisibility(View.VISIBLE); + if (hasTitle) { + setTopMargin(vh.mSubtitle, vh.mUnderTitleBaselineMargin + + vh.mSubtitleFontMetricsInt.ascent - vh.mTitleFontMetricsInt.descent); + } else { + setTopMargin(vh.mSubtitle, 0); + } + } else { + vh.mSubtitle.setVisibility(View.GONE); + hasSubtitle = false; + } + + if (TextUtils.isEmpty(detailsContent.getDescription())) { + vh.mBody.setVisibility(View.GONE); + } else { + vh.mBody.setText(detailsContent.getDescription()); + vh.mBody.setVisibility(View.VISIBLE); + vh.mBody.setLineSpacing(vh.mBodyLineSpacing - vh.mBody.getLineHeight() + + vh.mBody.getLineSpacingExtra(), vh.mBody.getLineSpacingMultiplier()); + if (hasSubtitle) { + setTopMargin(vh.mDescriptionContainer, vh.mUnderSubtitleBaselineMargin + + vh.mBodyFontMetricsInt.ascent - vh.mSubtitleFontMetricsInt.descent + - vh.mBody.getPaddingTop()); + } else if (hasTitle) { + setTopMargin(vh.mDescriptionContainer, vh.mUnderTitleBaselineMargin + + vh.mBodyFontMetricsInt.ascent - vh.mTitleFontMetricsInt.descent + - vh.mBody.getPaddingTop()); + } else { + setTopMargin(vh.mDescriptionContainer, 0); + } + } + } + + @Override + public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { } + + @Override + public void onViewAttachedToWindow(Presenter.ViewHolder holder) { + // In case predraw listener was removed in detach, make sure + // we have the proper layout. + ViewHolder vh = (ViewHolder) holder; + vh.addPreDrawListener(); + super.onViewAttachedToWindow(holder); + } + + @Override + public void onViewDetachedFromWindow(Presenter.ViewHolder holder) { + ViewHolder vh = (ViewHolder) holder; + vh.removePreDrawListener(); + super.onViewDetachedFromWindow(holder); + } + + private void setTopMargin(View view, int topMargin) { + ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + lp.topMargin = topMargin; + view.setLayoutParams(lp); + } +}
\ No newline at end of file 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..6714ecd3 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java @@ -0,0 +1,92 @@ +/* + * 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) { + if (mBackgroundManager.isAttached()) { + mBackgroundManager.setColor(color); + } + } + + /** + * Sets the background scrim. + */ + public void setScrim(int color) { + if (mBackgroundManager.isAttached()) { + 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..9df228d1 --- /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, false); + } + 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..a6dd31d1 100644 --- a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java +++ b/src/com/android/tv/dvr/ui/DvrBrowseFragment.java @@ -16,140 +16,586 @@ 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.Presenter; +import android.support.v17.leanback.widget.TitleViewAdapter; +import android.text.TextUtils; import android.util.Log; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.data.GenreItems; import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.RecordedProgram; import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; -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 SeriesAdapter 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 DvrScheduleManager mDvrScheudleManager; 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_THEN_ID_COMPARATOR + .compare((ScheduledRecording) lhs, (ScheduledRecording) rhs); + } else { + return -1; + } + } else if (rhs instanceof ScheduledRecording) { + return 1; + } else { + return 0; + } + } + }; + + private final DvrScheduleManager.OnConflictStateChangeListener mOnConflictStateChangeListener = + new DvrScheduleManager.OnConflictStateChangeListener() { + @Override + public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) { + if (mScheduleAdapter != null) { + for (ScheduledRecording schedule : schedules) { + onScheduledRecordingStatusChanged(schedule); + } + } + } + }; + + 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(); + ApplicationSingletons singletons = TvApplication.getSingletons(context); + mDvrDataManager = singletons.getDvrDataManager(); + mDvrScheudleManager = singletons.getDvrScheduleManager(); + 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(); + mDvrScheudleManager.addOnConflictStateChangeListener(mOnConflictStateChangeListener); 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(); + mHandler.removeCallbacks(mUpdateRowsRunnable); + mDvrScheudleManager.removeOnConflictStateChangeListener(mOnConflictStateChangeListener); + mDvrDataManager.removeRecordedProgramListener(this); + mDvrDataManager.removeScheduledRecordingListener(this); + mDvrDataManager.removeSeriesRecordingListener(this); + mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + mRowsAdapter.clear(); + mSeriesId2LatestProgram.clear(); + for (Presenter presenter : mPresenterSelector.getPresenters()) { + if (presenter instanceof DvrItemPresenter) { + ((DvrItemPresenter) presenter).unbindAllViewHolders(); + } + } super.onDestroy(); } + @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()) { + handleRecordedProgramAdded(recordedProgram, true); + } + updateRows(); + if (mDvrDataManager.isInitialized()) { + startEntranceTransition(); + } + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + } + + @Override + public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + handleRecordedProgramAdded(recordedProgram, true); + } + postUpdateRows(); + } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + handleRecordedProgramChanged(recordedProgram); + } + postUpdateRows(); + } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + 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 SeriesAdapter(); + 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()) { + 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); } - case DVR_SCHEDULED_RECORDINGS: { - gridRowAdapter = mRecordingsNotStatedAdapter; + } + } + 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); + } + } + } + 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); + } + } else if (latestProgram.getId() == recordedProgram.getId()) { + if (seriesRecording != null) { + updateLatestRecordedProgram(seriesRecording); + onSeriesRecordingChanged(seriesRecording); + } + } + } + if (seriesRecording == null) { + updateGenreAdapters(getGenreAdapters( + recordedProgram.getCanonicalGenres()), recordedProgram); + } else { + updateGenreAdapters(new ArrayList<>(), recordedProgram); + } + } + + private void handleSeriesRecordingsAdded(List<SeriesRecording> seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + mSeriesAdapter.add(seriesRecording); + if (mSeriesId2LatestProgram.get(seriesRecording.getSeriesId()) != null) { + for (RecordedProgramAdapter adapter + : getGenreAdapters(seriesRecording.getCanonicalGenreIds())) { + adapter.add(seriesRecording); } - break; - case DVR_RECORDED_PROGRAMS: { - gridRowAdapter = mRecordedProgramsAdapter; + } + } + } + + 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) { + 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++; } - break; - case DVR_SETTINGS: - gridRowAdapter = new ArrayObjectAdapter(new EmptyItemPresenter(this)); - // TODO: provide setup rows. - break; } - if (gridRowAdapter != null) { - mRowsAdapter.add(new ListRow(gridHeader, gridRowAdapter)); + } + } + + private boolean needToShowScheduledRecording(ScheduledRecording recording) { + int state = recording.getState(); + return 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 (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 SeriesAdapter extends SortedArrayAdapter<SeriesRecording> { + SeriesAdapter() { + super(mPresenterSelector, new Comparator<SeriesRecording>() { + @Override + public int compare(SeriesRecording lhs, SeriesRecording rhs) { + if (lhs.isStopped() && !rhs.isStopped()) { + return 1; + } else if (!lhs.isStopped() && rhs.isStopped()) { + return -1; + } + return SeriesRecording.PRIORITY_COMPARATOR.compare(lhs, rhs); + } + }); + } + + @Override + public long getId(SeriesRecording item) { + return item.getId(); + } + } + + 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/DvrChannelRecordDurationOptionFragment.java b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java new file mode 100644 index 00000000..837d8ab2 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java @@ -0,0 +1,109 @@ +/* + * 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.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; + + @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..806c775c --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrDetailsActivity.java @@ -0,0 +1,98 @@ +/* + * 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; +import com.android.tv.TvApplication; + +/** + * 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) { + TvApplication.setCurrentRunningProcess(this, true); + 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..21f9c4b4 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrDetailsFragment.java @@ -0,0 +1,344 @@ +/* + * 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.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputManager; +import android.net.Uri; +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 android.widget.Toast; + +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.data.ChannelDataManager; +import com.android.tv.dialog.PinDialogFragment; +import com.android.tv.dvr.DvrPlaybackActivity; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.parental.ParentalControlSettings; +import com.android.tv.util.ImageLoader; +import com.android.tv.util.ToastUtils; +import com.android.tv.util.Utils; + +import java.io.File; + +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(); + onCreateInternal(); + } + + @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(getActivity())); + 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; + } + + /** + * Does customized initialization of subclasses. Since {@link #onCreate(Bundle)} might finish + * activity early when it cannot fetch valid recordings, subclasses' onCreate method should not + * do anything after calling {@link #onCreate(Bundle)}. If there's something subclasses have to + * do after the super class did onCreate, it should override this method and put the codes here. + */ + protected void onCreateInternal() { } + + /** + * 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())); + } + } + + protected void startPlayback(RecordedProgram recordedProgram, long seekTimeMs) { + if (Utils.isInBundledPackageSet(recordedProgram.getPackageName()) && + !isDataUriAccessible(recordedProgram.getDataUri())) { + // Since cleaning RecordedProgram from forgotten storage will take some time, + // ignore playback until cleaning is finished. + ToastUtils.show(getContext(), + getContext().getResources().getString(R.string.dvr_toast_recording_deleted), + Toast.LENGTH_SHORT); + return; + } + ParentalControlSettings parental = TvApplication.getSingletons(getActivity()) + .getTvInputManagerHelper().getParentalControlSettings(); + if (!parental.isParentalControlsEnabled()) { + launchPlaybackActivity(recordedProgram, seekTimeMs, false); + return; + } + ChannelDataManager channelDataManager = + TvApplication.getSingletons(getActivity()).getChannelDataManager(); + Channel channel = channelDataManager.getChannel(recordedProgram.getChannelId()); + if (channel != null && channel.isLocked()) { + checkPinToPlay(recordedProgram, seekTimeMs); + return; + } + String ratingString = recordedProgram.getContentRating(); + if (TextUtils.isEmpty(ratingString)) { + launchPlaybackActivity(recordedProgram, 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) { + checkPinToPlay(recordedProgram, seekTimeMs); + } else { + launchPlaybackActivity(recordedProgram, seekTimeMs, false); + } + } + + 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 checkPinToPlay(RecordedProgram recordedProgram, long seekTimeMs) { + new PinDialogFragment(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM, + new PinDialogFragment.ResultListener() { + @Override + public void done(boolean success) { + if (success) { + launchPlaybackActivity(recordedProgram, seekTimeMs, true); + } + } + }).show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG); + } + + private void launchPlaybackActivity(RecordedProgram mRecordedProgram, 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); + } + + 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..73ddcdd0 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java @@ -0,0 +1,87 @@ +/* + * 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 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(); + dvrManager.forgetStorage(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..d26e6836 100644 --- a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java +++ b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java @@ -1,32 +1,41 @@ +/* + * 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.GuidedAction; 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; +import com.android.tv.dvr.ui.HalfSizedDialogFragment.OnActionClickListener; public class DvrGuidedStepFragment extends GuidedStepFragment { - private final TableEntry mEntry; private DvrManager mDvrManager; - - public DvrGuidedStepFragment(TableEntry entry) { - mEntry = entry; - } - - protected TableEntry getEntry() { - return mEntry; - } + private OnActionClickListener mOnActionClickListener; protected DvrManager getDvrManager() { return mDvrManager; @@ -42,32 +51,39 @@ 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; - } - }; + public int onProvideTheme() { + return R.style.Theme_TV_Dvr_GuidedStep; } @Override - public int onProvideTheme() { - return R.style.Theme_TV_Dvr_GuidedStep; + public void onGuidedActionClicked(GuidedAction action) { + if (mOnActionClickListener != null) { + mOnActionClickListener.onActionClick(action.getId()); + } + dismissDialog(); } 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(); } } -} + + protected void setOnActionClickListener(OnActionClickListener listener) { + mOnActionClickListener = listener; + } +}
\ No newline at end of file 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..2b132db8 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java @@ -0,0 +1,228 @@ +/* + * 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.support.v17.leanback.widget.GuidedAction; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +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.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelWatchConflictFragment; +import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment; +import com.android.tv.guide.ProgramGuide; + +import java.util.List; + +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 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 { + private DvrGuidedStepFragment mFragment; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + mFragment = onCreateGuidedStepFragment(); + mFragment.setArguments(getArguments()); + mFragment.setOnActionClickListener(getOnActionClickListener()); + GuidedStepFragment.add(getChildFragmentManager(), + mFragment, R.id.halfsized_dialog_host); + return view; + } + + @Override + public void setOnActionClickListener(OnActionClickListener listener) { + super.setOnActionClickListener(listener); + if (mFragment != null) { + mFragment.setOnActionClickListener(listener); + } + } + + protected abstract DvrGuidedStepFragment onCreateGuidedStepFragment(); + } + + /** A dialog fragment for {@link DvrScheduleFragment}. */ + public static class DvrScheduleDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrScheduleFragment(); + } + } + + /** A dialog fragment for {@link DvrProgramConflictFragment}. */ + public static class DvrProgramConflictDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrProgramConflictFragment(); + } + } + + /** A dialog fragment for {@link DvrChannelWatchConflictFragment}. */ + public static class DvrChannelWatchConflictDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrChannelWatchConflictFragment(); + } + } + + /** A dialog fragment for {@link DvrChannelRecordDurationOptionFragment}. */ + public static class DvrChannelRecordDurationOptionDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrChannelRecordDurationOptionFragment(); + } + } + + /** A dialog fragment for {@link DvrInsufficientSpaceErrorFragment}. */ + public static class DvrInsufficientSpaceErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrInsufficientSpaceErrorFragment(); + } + } + + /** A dialog fragment for {@link DvrMissingStorageErrorFragment}. */ + public static class DvrMissingStorageErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrMissingStorageErrorFragment(); + } + } + + /** + * A dialog fragment to show error message when the current storage is too small to + * support DVR + */ + public static class DvrSmallSizedStorageErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrGuidedStepFragment() { + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getResources().getString( + R.string.dvr_error_small_sized_storage_title); + String description = getResources().getString( + R.string.dvr_error_small_sized_storage_description, + DvrStorageStatusManager.MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES / 1024 + / 1024 / 1024); + 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(GuidedAction.ACTION_ID_OK) + .title(android.R.string.ok) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + dismissDialog(); + } + }; + } + } + + /** A dialog fragment for {@link DvrStopRecordingFragment}. */ + public static class DvrStopRecordingDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrStopRecordingFragment(); + } + } + + /** A dialog fragment for {@link DvrAlreadyScheduledFragment}. */ + public static class DvrAlreadyScheduledDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrAlreadyScheduledFragment(); + } + } + + /** A dialog fragment for {@link DvrAlreadyRecordedFragment}. */ + public static class DvrAlreadyRecordedDialogFragment extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrAlreadyRecordedFragment(); + } + } +}
\ No newline at end of file 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/DvrItemPresenter.java b/src/com/android/tv/dvr/ui/DvrItemPresenter.java new file mode 100644 index 00000000..339e5d2f --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrItemPresenter.java @@ -0,0 +1,80 @@ +/* + * 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.support.annotation.CallSuper; +import android.support.v17.leanback.widget.Presenter; +import android.view.View; +import android.view.View.OnClickListener; + +import com.android.tv.dvr.DvrUiHelper; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + +/** + * An abstract class to present DVR items in {@link RecordingCardView}, which is mainly used in + * {@link DvrBrowseFragment}. DVR items might include: {@link ScheduledRecording}, + * {@link RecordedProgram}, and {@link SeriesRecording}. + */ +public abstract class DvrItemPresenter extends Presenter { + private final Set<ViewHolder> mBoundViewHolders = new HashSet<>(); + private final OnClickListener mOnClickListener = onCreateOnClickListener(); + + @Override + @CallSuper + public void onBindViewHolder(ViewHolder viewHolder, Object o) { + viewHolder.view.setTag(o); + viewHolder.view.setOnClickListener(mOnClickListener); + mBoundViewHolders.add(viewHolder); + } + + @Override + @CallSuper + public void onUnbindViewHolder(ViewHolder viewHolder) { + mBoundViewHolders.remove(viewHolder); + } + + /** + * Unbinds all bound view holders. + */ + public void unbindAllViewHolders() { + // When browse fragments are destroyed, RecyclerView would not call presenters' + // onUnbindViewHolder(). We should handle it by ourselves to prevent resources leaks. + for (ViewHolder viewHolder : new HashSet<>(mBoundViewHolders)) { + onUnbindViewHolder(viewHolder); + } + } + + /** + * Creates {@link OnClickListener} for DVR library's card views. + */ + protected OnClickListener onCreateOnClickListener() { + return new OnClickListener() { + @Override + public void onClick(View view) { + if (view instanceof RecordingCardView) { + RecordingCardView v = (RecordingCardView) view; + DvrUiHelper.startDetailsActivity((Activity) v.getContext(), + v.getTag(), v.getImageView(), false); + } + } + }; + } +}
\ No newline at end of file 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..8c4c856c --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java @@ -0,0 +1,82 @@ +/* + * 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.View.OnClickListener; +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 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 + protected OnClickListener onCreateOnClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + long programId = ((RecordedProgram) v.getTag()).getId(); + 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..0bc4ecb1 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java @@ -0,0 +1,313 @@ +/* + * 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) { + if (keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE && event.getAction() == KeyEvent.ACTION_DOWN + && (mPlaybackState == PlaybackState.STATE_FAST_FORWARDING + || mPlaybackState == PlaybackState.STATE_REWINDING)) { + // Workaround of b/31489271. Clicks play/pause button first to reset play controls + // to "play" state. Then we can pass MEDIA_PAUSE to let playback be paused. + onActionClicked(getControlsRow().getActionForKeyCode(keyCode)); + } + return super.onKey(v, keyCode, event); + } + return false; + } + + @Override + public boolean hasValidMedia() { + PlaybackState playbackState = mMediaController.getPlaybackState(); + return playbackState != null; + } + + @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..51ec93b8 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.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; + +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.support.v17.leanback.widget.SinglePresenterSelector; +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.data.BaseProgram; +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 SortedArrayAdapter<BaseProgram> mRelatedRecordingsRowAdapter; + private DvrPlaybackCardPresenter mRelatedRecordingCardPresenter; + 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), this); + 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"); + mPlaybackControlHelper.unregisterCallback(); + mMediaSessionHelper.release(); + mRelatedRecordingCardPresenter.unbindAllViewHolders(); + super.onDestroy(); + } + + /** + * 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); + } + + public RecordedProgram getNextEpisode(RecordedProgram program) { + int position = mRelatedRecordingsRowAdapter.findInsertPosition(program); + if (position == mRelatedRecordingsRowAdapter.size()) { + return null; + } else { + return (RecordedProgram) mRelatedRecordingsRowAdapter.get(position); + } + } + + 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() { + mRelatedRecordingCardPresenter = new DvrPlaybackCardPresenter(getActivity()); + mRelatedRecordingsRowAdapter = new RelatedRecordingsAdapter(mRelatedRecordingCardPresenter); + 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); + } + + private class RelatedRecordingsAdapter extends SortedArrayAdapter<BaseProgram> { + RelatedRecordingsAdapter(DvrPlaybackCardPresenter presenter) { + super(new SinglePresenterSelector(presenter), BaseProgram.EPISODE_COMPARATOR); + } + + @Override + long getId(BaseProgram item) { + return item.getId(); + } + } +}
\ 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..da6d1637 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java @@ -0,0 +1,147 @@ +/* + * 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.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidanceStylist.Guidance; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.format.DateUtils; + +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.ui.DvrConflictFragment.DvrProgramConflictFragment; +import com.android.tv.util.Utils; + +import java.util.Collections; +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 String TAG = "DvrScheduleFragment"; + + 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(), TAG, + "The program should be episodic: " + mProgram); + SeriesRecording seriesRecording = dvrManager.getSeriesRecording(mProgram); + SoftPreconditions.checkArgument(seriesRecording == null + || seriesRecording.isStopped(), TAG, + "The series recording should be stopped or null: " + seriesRecording); + 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) { + SeriesRecording seriesRecording = TvApplication.getSingletons(getContext()) + .getDvrDataManager().getSeriesRecording(mProgram.getSeriesId()); + if (seriesRecording == null) { + seriesRecording = getDvrManager().addSeriesRecording(mProgram, + Collections.emptyList(), SeriesRecording.STATE_SERIES_STOPPED); + } else { + // Reset priority to the highest. + seriesRecording = SeriesRecording.buildFrom(seriesRecording) + .setPriority(TvApplication.getSingletons(getContext()) + .getDvrScheduleManager().suggestNewSeriesPriority()) + .build(); + getDvrManager().updateSeriesRecording(seriesRecording); + } + DvrUiHelper.startSeriesSettingsActivity(getContext(), + seriesRecording.getId(), null, true, true, true); + 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..f6e6ac26 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java @@ -0,0 +1,104 @@ +/* + * 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.ProgressDialog; +import android.os.Bundle; +import android.support.annotation.IntDef; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.Program; +import com.android.tv.dvr.EpisodicProgramLoadTask; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.SeriesRecordingScheduler; +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; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 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 static final int TYPE_SERIES_SCHEDULE = 1; + + @Override + public void onCreate(final Bundle savedInstanceState) { + TvApplication.setCurrentRunningProcess(this, true); + // Pass null to prevent automatically re-creating fragments + super.onCreate(null); + setContentView(R.layout.activity_dvr_schedules); + int scheduleType = getIntent().getIntExtra(KEY_SCHEDULES_TYPE, TYPE_FULL_SCHEDULE); + if (scheduleType == TYPE_FULL_SCHEDULE) { + DvrSchedulesFragment schedulesFragment = new DvrSchedulesFragment(); + schedulesFragment.setArguments(getIntent().getExtras()); + getFragmentManager().beginTransaction().add( + R.id.fragment_container, schedulesFragment).commit(); + } else if (scheduleType == TYPE_SERIES_SCHEDULE) { + final ProgressDialog dialog = ProgressDialog.show(this, null, getString( + R.string.dvr_series_schedules_progress_message_reading_programs)); + SeriesRecording seriesRecording = getIntent().getExtras() + .getParcelable(DvrSeriesSchedulesFragment + .SERIES_SCHEDULES_KEY_SERIES_RECORDING); + // To get programs faster, hold the update of the series schedules. + SeriesRecordingScheduler.getInstance(this).pauseUpdate(); + new EpisodicProgramLoadTask(this, Collections.singletonList(seriesRecording)) { + @Override + protected void onPostExecute(List<Program> programs) { + SeriesRecordingScheduler.getInstance(DvrSchedulesActivity.this).resumeUpdate(); + dialog.dismiss(); + Bundle args = getIntent().getExtras(); + args.putParcelableArrayList(DvrSeriesSchedulesFragment + .SERIES_SCHEDULES_KEY_SERIES_PROGRAMS, new ArrayList<>(programs)); + DvrSeriesSchedulesFragment schedulesFragment = new DvrSeriesSchedulesFragment(); + schedulesFragment.setArguments(args); + getFragmentManager().beginTransaction().add( + R.id.fragment_container, schedulesFragment).commit(); + } + }.setLoadCurrentProgram(true) + .setLoadDisallowedProgram(true) + .setLoadScheduledEpisode(true) + .setIgnoreChannelOption(true) + .execute(); + } else { + finish(); + } + } +} 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..f57e4b05 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.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.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) { + TvApplication.setCurrentRunningProcess(this, true); + 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/DvrSeriesScheduledDialogActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.java new file mode 100644 index 00000000..1a0d13d3 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.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.Activity; +import android.os.Bundle; +import android.support.v17.leanback.app.GuidedStepFragment; + +import com.android.tv.R; + +public class DvrSeriesScheduledDialogActivity extends Activity { + /** + * Name of series recording id added to the Intent. + */ + public static final String SERIES_RECORDING_ID = "series_recording_id"; + + /** + * Name of flag to check if the dialog should show view schedule option. + */ + public static final String SHOW_VIEW_SCHEDULE_OPTION = "show_view_schedule_option"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.halfsized_dialog); + if (savedInstanceState == null) { + DvrSeriesScheduledFragment dvrSeriesScheduledFragment = + new DvrSeriesScheduledFragment(); + dvrSeriesScheduledFragment.setArguments(getIntent().getExtras()); + GuidedStepFragment.addAsRoot(this, dvrSeriesScheduledFragment, + R.id.halfsized_dialog_host); + } + } +} diff --git a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java new file mode 100644 index 00000000..1173df46 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java @@ -0,0 +1,154 @@ +/* + * 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.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 com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment; + +import java.util.List; + +public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { + private final static long SERIES_RECORDING_ID_NOT_SET = -1; + + private final static int ACTION_VIEW_SCHEDULES = 1; + + private DvrScheduleManager mDvrScheduleManager; + private SeriesRecording mSeriesRecording; + private boolean mShowViewScheduleOption; + + private int mSchedulesAddedCount = 0; + private boolean mHasConflict = false; + private int mInThisSeriesConflictCount = 0; + private int mOutThisSeriesConflictCount = 0; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + long seriesRecordingId = getArguments().getLong( + DvrSeriesScheduledDialogActivity.SERIES_RECORDING_ID, SERIES_RECORDING_ID_NOT_SET); + if (seriesRecordingId == SERIES_RECORDING_ID_NOT_SET) { + getActivity().finish(); + return; + } + mShowViewScheduleOption = getArguments().getBoolean( + DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION); + mDvrScheduleManager = TvApplication.getSingletons(context).getDvrScheduleManager(); + mSeriesRecording = TvApplication.getSingletons(context).getDvrDataManager() + .getSeriesRecording(seriesRecordingId); + if (mSeriesRecording == null) { + getActivity().finish(); + return; + } + mSchedulesAddedCount = TvApplication.getSingletons(getContext()).getDvrManager() + .getAvailableScheduledRecording(mSeriesRecording.getId()).size(); + List<ScheduledRecording> conflictingRecordings = + mDvrScheduleManager.getConflictingSchedules(mSeriesRecording); + mHasConflict = !conflictingRecordings.isEmpty(); + for (ScheduledRecording recording : conflictingRecordings) { + if (recording.getSeriesRecordingId() == mSeriesRecording.getId()) { + ++mInThisSeriesConflictCount; + } else { + ++mOutThisSeriesConflictCount; + } + } + } + + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_series_recording_dialog_title); + Drawable icon; + if (!mHasConflict) { + icon = getResources().getDrawable(R.drawable.ic_check_circle_white_48dp, null); + } else { + icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null); + } + return new GuidanceStylist.Guidance(title, getDescription(), null, icon); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + Context context = getContext(); + actions.add(new GuidedAction.Builder(context) + .clickAction(GuidedAction.ACTION_ID_OK) + .build()); + if (mShowViewScheduleOption) { + actions.add(new GuidedAction.Builder(context) + .id(ACTION_VIEW_SCHEDULES) + .title(R.string.dvr_action_view_schedules) + .build()); + } + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_VIEW_SCHEDULES) { + Intent intent = new Intent(getActivity(), DvrSchedulesActivity.class); + intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE, DvrSchedulesActivity + .TYPE_SERIES_SCHEDULE); + intent.putExtra(DvrSeriesSchedulesFragment.SERIES_SCHEDULES_KEY_SERIES_RECORDING, + mSeriesRecording); + startActivity(intent); + } + getActivity().finish(); + } + + private String getDescription() { + if (!mHasConflict) { + return getResources().getQuantityString( + R.plurals.dvr_series_recording_scheduled_no_conflict, mSchedulesAddedCount, + mSchedulesAddedCount, mSeriesRecording.getTitle()); + } else { + // mInThisSeriesConflictCount equals 0 and mOutThisSeriesConflictCount equals 0 means + // mHasConflict is false. So we don't need to check that case. + if (mInThisSeriesConflictCount != 0 && mOutThisSeriesConflictCount != 0) { + return getResources().getQuantityString(R.plurals + .dvr_series_recording_scheduled_this_and_other_series_conflict, + mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(), + mInThisSeriesConflictCount + mOutThisSeriesConflictCount); + } else if (mInThisSeriesConflictCount != 0) { + return getResources().getQuantityString(R.plurals + .dvr_series_recording_scheduled_only_this_series_conflict, + mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(), + mInThisSeriesConflictCount); + } else { + if (mOutThisSeriesConflictCount == 1) { + return getResources().getQuantityString(R.plurals + .dvr_series_recording_scheduled_only_other_series_one_conflict, + mSchedulesAddedCount, mSchedulesAddedCount, + mSeriesRecording.getTitle()); + } else { + return getResources().getQuantityString(R.plurals + .dvr_series_recording_scheduled_only_other_series_conflict, + mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(), + mOutThisSeriesConflictCount); + } + } + } + } +} 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..3f7671b3 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java @@ -0,0 +1,82 @@ +/* + * 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.Color; +import android.graphics.drawable.ColorDrawable; +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; + +/** + * Activity to show details view in DVR. + */ +public class DvrSeriesSettingsActivity extends Activity { + /** + * Name of series id added to the Intent. + * Type: Long + */ + public static final String SERIES_RECORDING_ID = "series_recording_id"; + /** + * Name of the boolean flag to decide if the series recording with empty schedule and recording + * will be removed. + */ + public static final String REMOVE_EMPTY_SERIES_RECORDING = "remove_empty_series_recording"; + /** + * Name of the boolean flag to decide if the setting fragment should be translucent. + */ + public static final String IS_WINDOW_TRANSLUCENT = "windows_translucent"; + /** + * Name of the channel id list. If the channel list is given, we show the channels + * from the values in channel option. + * Type: Long array + */ + public static final String CHANNEL_ID_LIST = "channel_id_list"; + + /** + * Name of the boolean flag to check if the confirm dialog should show view schedule option. + */ + public static final String SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG = + "show_view_schedule_option_in_dialog"; + + @Override + public void onCreate(Bundle savedInstanceState) { + TvApplication.setCurrentRunningProcess(this, true); + 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) { + SeriesSettingsFragment settingFragment = new SeriesSettingsFragment(); + settingFragment.setArguments(getIntent().getExtras()); + GuidedStepFragment.addAsRoot(this, settingFragment, R.id.dvr_settings_view_frame); + } + } + + @Override + public void onAttachedToWindow() { + if (!getIntent().getExtras().getBoolean(IS_WINDOW_TRANSLUCENT, true)) { + getWindow().setBackgroundDrawable( + new ColorDrawable(getColor(R.color.common_tv_background))); + } + } +}
\ No newline at end of file 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..c3867886 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java @@ -0,0 +1,161 @@ +/* + * 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.IntDef; +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.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.ScheduledRecording; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +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 { + /** + * The action ID for the stop action. + */ + public static final int ACTION_STOP = 1; + /** + * Key for the program. + * Type: {@link com.android.tv.data.Program}. + */ + public static final String KEY_REASON = "DvrStopRecordingFragment.type"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_USER_STOP, REASON_ON_CONFLICT}) + public @interface ReasonType {} + /** + * The dialog is shown because users want to stop some currently recording program. + */ + public static final int REASON_USER_STOP = 1; + /** + * The dialog is shown because users want to record some program that is conflict to the + * current recording program. + */ + public static final int REASON_ON_CONFLICT = 2; + + private ScheduledRecording mSchedule; + private DvrDataManager mDvrDataManager; + private @ReasonType int mStopReason; + + 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); + mStopReason = args.getInt(KEY_REASON); + } + + @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; + if (mStopReason == REASON_ON_CONFLICT) { + String programTitle = mSchedule.getProgramTitle(); + if (TextUtils.isEmpty(programTitle)) { + ChannelDataManager channelDataManager = + TvApplication.getSingletons(getActivity()).getChannelDataManager(); + Channel channel = channelDataManager.getChannel(mSchedule.getChannelId()); + programTitle = channel.getDisplayName(); + } + description = getString(R.string.dvr_stop_recording_dialog_description_on_conflict, + mSchedule.getProgramTitle()); + } else { + 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()); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java new file mode 100644 index 00000000..5b880bd6 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.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 DvrStopSeriesRecordingFragment}. + */ +public class DvrStopSeriesRecordingDialogFragment 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 DvrStopSeriesRecordingFragment(); + 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/DvrStopSeriesRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java new file mode 100644 index 00000000..feaa2357 --- /dev/null +++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java @@ -0,0 +1,104 @@ +/* + * 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.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.widget.GuidanceStylist; +import android.support.v17.leanback.widget.GuidedAction; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.ApplicationSingletons; +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.ScheduledRecording; +import com.android.tv.dvr.SeriesRecording; + +import java.util.ArrayList; +import java.util.List; + +/** + * A fragment which asks the user to stop series recording. + */ +public class DvrStopSeriesRecordingFragment extends DvrGuidedStepFragment { + /** + * Key for the series recording to be stopped. + */ + public static final String KEY_SERIES_RECORDING = "key_series_recoridng"; + + private static final int ACTION_STOP_SERIES_RECORDING = 1; + + private SeriesRecording mSeriesRecording; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + mSeriesRecording = getArguments().getParcelable(KEY_SERIES_RECORDING); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.dvr_series_schedules_stop_dialog_title); + String description = getString(R.string.dvr_series_schedules_stop_dialog_description); + Drawable icon = getContext().getDrawable(R.drawable.ic_dvr_delete); + return new GuidanceStylist.Guidance(title, description, null, icon); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_STOP_SERIES_RECORDING) + .title(R.string.dvr_series_schedules_stop_dialog_action_stop) + .build()); + actions.add(new GuidedAction.Builder(activity) + .clickAction(GuidedAction.ACTION_ID_CANCEL) + .build()); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_STOP_SERIES_RECORDING) { + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); + DvrManager dvrManager = singletons.getDvrManager(); + DvrDataManager dataManager = singletons.getDvrDataManager(); + List<ScheduledRecording> toDelete = new ArrayList<>(); + for (ScheduledRecording r : dataManager.getAvailableScheduledRecordings()) { + if (r.getSeriesRecordingId() == mSeriesRecording.getId()) { + if (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + toDelete.add(r); + } else { + dvrManager.stopRecording(r); + } + } + } + if (!toDelete.isEmpty()) { + dvrManager.forceRemoveScheduledRecording(ScheduledRecording.toArray(toDelete)); + } + dvrManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording) + .setState(SeriesRecording.STATE_SERIES_STOPPED).build()); + } + 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/FullScheduleCardHolder.java b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java new file mode 100644 index 00000000..d4d4d8ab --- /dev/null +++ b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** + * Special object for schedule preview; + */ +final class FullScheduleCardHolder { + /** + * Full schedule card holder. + */ + static final FullScheduleCardHolder FULL_SCHEDULE_CARD_HOLDER = new FullScheduleCardHolder(); + + 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..d320816e 100644 --- a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java +++ b/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java @@ -1,21 +1,83 @@ +/* + * 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 OnActionClickListener mOnActionClickListener; + + 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 onPause() { + super.onPause(); + if (mOnActionClickListener != null) { + // Dismisses the dialog to prevent the callback being forgotten during + // fragment re-creating. + dismiss(); + } + } + + @Override + public void onStop() { + super.onStop(); + mHandler.removeCallbacks(mAutoDismisser); } @Override @@ -27,4 +89,29 @@ public class HalfSizedDialogFragment extends SafeDismissDialogFragment { public String getTrackerLabel() { return TRACKER_LABEL; } -} + + /** + * Sets {@link OnActionClickListener} for the dialog fragment. If listener is set, the dialog + * will be automatically closed when it's paused to prevent the fragment being re-created by + * the framework, which will result the listener being forgotten. + */ + public void setOnActionClickListener(OnActionClickListener listener) { + mOnActionClickListener = listener; + } + + /** + * Returns {@link OnActionClickListener} for sub-classes or any inner fragments. + */ + protected OnActionClickListener getOnActionClickListener() { + return mOnActionClickListener; + } + + /** + * An interface to provide callbacks for half-sized dialogs. Subclasses or inner fragments + * should invoke {@link OnActionClickListener#onActionClick(long)} and provide the identifier + * of the action user clicked. + */ + public interface OnActionClickListener { + void onActionClick(long actionId); + } +}
\ 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..158bd824 --- /dev/null +++ b/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java @@ -0,0 +1,251 @@ +/* + * 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 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); + 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()); + DvrDataManager dvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + long comeFromSeriesRecordingId = + getArguments().getLong(COME_FROM_SERIES_RECORDING_ID, -1); + for (SeriesRecording series : dvrDataManager.getSeriesRecordings()) { + if (series.getState() == SeriesRecording.STATE_SERIES_NORMAL + || series.getId() == comeFromSeriesRecordingId) { + mSeriesRecordings.add(series); + } + } + mSeriesRecordings.sort(SeriesRecording.PRIORITY_COMPARATOR); + mComeFromSeriesRecording = dvrDataManager.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..e698b8a2 --- /dev/null +++ b/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java @@ -0,0 +1,170 @@ +/* + * 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.media.tv.TvInputManager; +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.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.RecordedProgram; + +/** + * {@link DetailsFragment} for recorded program in DVR. + */ +public class RecordedProgramDetailsFragment extends DvrDetailsFragment + implements DvrDataManager.RecordedProgramListener { + 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 RecordedProgram mRecordedProgram; + private DetailsContent mDetailsContent; + private boolean mPaused; + private DvrDataManager mDvrDataManager; + + @Override + public void onCreate(Bundle savedInstanceState) { + mDvrDataManager = TvApplication.getSingletons(getContext()).getDvrDataManager(); + mDvrDataManager.addRecordedProgramListener(this); + super.onCreate(savedInstanceState); + } + + @Override + public void onCreateInternal() { + mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity()) + .getDvrWatchedPositionManager(); + setDetailsOverviewRow(mDetailsContent); + } + + @Override + public void onResume() { + super.onResume(); + if (mPaused) { + updateActions(); + mPaused = false; + } + } + + @Override + public void onPause() { + super.onPause(); + mPaused = true; + } + + @Override + public void onDestroy() { + mDvrDataManager.removeRecordedProgramListener(this); + super.onDestroy(); + } + + @Override + protected boolean onLoadRecordingDetails(Bundle args) { + long recordedProgramId = args.getLong(DvrDetailsActivity.RECORDING_ID); + mRecordedProgram = mDvrDataManager.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.getWatchedStatus(mRecordedProgram) + == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) { + 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(mRecordedProgram, TvInputManager.TIME_SHIFT_INVALID_TIME); + } else if (action.getId() == ACTION_RESUME_PLAYING) { + startPlayback(mRecordedProgram, mDvrWatchedPositionManager + .getWatchedPosition(mRecordedProgram.getId())); + } else if (action.getId() == ACTION_DELETE_RECORDING) { + DvrManager dvrManager = TvApplication + .getSingletons(getActivity()).getDvrManager(); + dvrManager.removeRecordedProgram(mRecordedProgram); + getActivity().finish(); + } + } + }; + } + + @Override + public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + if (recordedProgram.getId() == mRecordedProgram.getId()) { + getActivity().finish(); + } + } + } +} diff --git a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java index 0b656bdc..1bf34310 100644 --- a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java +++ b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java @@ -17,105 +17,166 @@ 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.support.v17.leanback.widget.Presenter; +import android.media.tv.TvInputManager; +import android.net.Uri; +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 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.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 DvrItemPresenter { 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()); - } else { - cardView.setTitle(resources.getString(R.string.dvr_msg_program_title_unknown)); + Channel channel = mChannelDataManager.getChannel(program.getChannelId()); + String titleString = mShowEpisodeTitle ? program.getEpisodeDisplayTitle(mContext) + : program.getTitleWithEpisodeNumber(mContext); + SpannableString title = titleString == null ? null : new SpannableString(titleString); + if (TextUtils.isEmpty(title)) { + title = new SpannableString(channel != null ? channel.getDisplayName() + : mContext.getResources().getString(R.string.no_program_information)); + } else if (!mShowEpisodeTitle) { + // TODO: Some translation may add delimiters in-between program titles, we should use + // a more robust way to get the span range. + 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); } - 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()); - } + 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.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); - } - }, - }); - } - }); - + 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); + if (viewHolder instanceof RecordedProgramViewHolder) { + RecordedProgramViewHolder cardViewHolder = (RecordedProgramViewHolder) viewHolder; + cardViewHolder.setProgram(program); + mDvrWatchedPositionManager.addListener(cardViewHolder, program.getId()); + cardViewHolder + .setProgressBar(mDvrWatchedPositionManager.getWatchedPosition(program.getId())); + } + super.onBindViewHolder(viewHolder, o); } @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(); + super.onUnbindViewHolder(viewHolder); + } + + /** + * 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..51c3b03b 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,85 @@ 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 View mAffiliatedIconContainer; + private final ImageView mAffiliatedIcon; 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); + mAffiliatedIconContainer = findViewById(R.id.affiliated_icon_container); + mAffiliatedIcon = (ImageView) findViewById(R.id.affiliated_icon); 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); + } + } + + /** + * Sets the color of progress bar. + */ + void setProgressBarColor(int color) { + mProgressBar.getProgressDrawable().setTint(color); } - void setImageUri(String uri) { + 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 +133,31 @@ class RecordingCardView extends BaseCardView { } } - public void setImageUri(Uri uri) { - if (uri != null) { - setImageUri(uri.toString()); + /** + * Set image to card view. + */ + public void setImage(Drawable image) { + if (image != null) { + mImageView.setImageDrawable(image); + } + } + + public void setAffiliatedIcon(int imageResId) { + if (imageResId > 0) { + mAffiliatedIconContainer.setVisibility(View.VISIBLE); + mAffiliatedIcon.setImageResource(imageResId); } else { - setImageUri(""); + mAffiliatedIconContainer.setVisibility(View.INVISIBLE); } } + /** + * Returns image view. + */ + public ImageView getImageView() { + return mImageView; + } + private static class RecordingCardImageLoaderCallback extends ImageLoader.ImageLoaderCallback<RecordingCardView> { private final String mUri; @@ -108,8 +178,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..4e19ec3f --- /dev/null +++ b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java @@ -0,0 +1,87 @@ +/* + * 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; + + @Override + protected void onCreateInternal() { + setDetailsOverviewRow(createDetailsContent()); + } + + @Override + protected boolean onLoadRecordingDetails(Bundle args) { + long scheduledRecordingId = args.getLong(DvrDetailsActivity.RECORDING_ID); + mRecording = TvApplication.getSingletons(getContext()).getDvrDataManager() + .getScheduledRecording(scheduledRecordingId); + return mRecording != null; + } + + /** + * 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..60816bb5 --- /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_32dp; + } 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..5f447f13 100644 --- a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java +++ b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java @@ -16,156 +16,162 @@ 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.support.v17.leanback.widget.Presenter; -import android.view.View; +import android.os.Handler; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; 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.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 { +public class ScheduledRecordingPresenter extends DvrItemPresenter { + private static final long PROGRESS_UPDATE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(5); + private final ChannelDataManager mChannelDataManager; + private final DvrManager mDvrManager; + 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(); + mDvrManager = singletons.getDvrManager(); + 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 - 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; - } - } - } - }; - 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()); + if (mDvrManager.isConflicting(recording)) { + cardView.setAffiliatedIcon(R.drawable.ic_warning_white_32dp); } 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()); - } + cardView.setAffiliatedIcon(0); } + viewHolder.updateProgressBar(); + viewHolder.mScheduledRecording = recording; + viewHolder.startUpdateProgressBar(); + super.onBindViewHolder(viewHolder, o); } @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(); + super.onUnbindViewHolder(viewHolder); } - 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..36e3cfc1 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesDeletionFragment.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.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.ArrayList; +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) { + List<Long> idsToDelete = new ArrayList<>(); + for (GuidedAction guidedAction : getActions()) { + if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID + && guidedAction.isChecked()) { + idsToDelete.add(guidedAction.getId()); + } + } + if (!idsToDelete.isEmpty()) { + DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); + dvrManager.removeRecordedPrograms(idsToDelete); + } + Toast.makeText(getContext(), getResources().getQuantityString( + R.plurals.dvr_msg_episodes_deleted, idsToDelete.size(), idsToDelete.size(), + 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..e9e391d4 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java @@ -0,0 +1,375 @@ +/* + * 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.graphics.drawable.Drawable; +import android.media.tv.TvInputManager; +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.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.ScheduledRecording; +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_WATCH = 1; + private static final int ACTION_SERIES_SCHEDULES = 2; + private static final int ACTION_DELETE = 3; + + private DvrWatchedPositionManager mDvrWatchedPositionManager; + 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 RecordedProgram mRecommendRecordedProgram; + private DetailsContent mDetailsContent; + private int mSeasonRowCount; + private SparseArrayObjectAdapter mActionsAdapter; + private Action mDeleteAction; + + private boolean mPaused; + private long mInitialPlaybackPositionMs; + private String mWatchLabel; + private String mResumeLabel; + private Drawable mWatchDrawable; + private RecordedProgramPresenter mRecordedProgramPresenter; + + @Override + public void onCreate(Bundle savedInstanceState) { + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); + mWatchLabel = getString(R.string.dvr_detail_watch); + mResumeLabel = getString(R.string.dvr_detail_series_resume); + mWatchDrawable = getResources().getDrawable(R.drawable.lb_ic_play, null); + mRecordedProgramPresenter = new RecordedProgramPresenter(getContext(), true); + super.onCreate(savedInstanceState); + } + + @Override + protected void onCreateInternal() { + mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity()) + .getDvrWatchedPositionManager(); + setDetailsOverviewRow(mDetailsContent); + setupRecordedProgramsRow(); + mDvrDataManager.addSeriesRecordingListener(this); + mDvrDataManager.addRecordedProgramListener(this); + mRecordedPrograms = null; + } + + @Override + public void onResume() { + super.onResume(); + if (mPaused) { + updateWatchAction(); + mPaused = false; + } + } + + @Override + public void onPause() { + super.onPause(); + mPaused = true; + } + + private void updateWatchAction() { + List<RecordedProgram> programs = mDvrDataManager.getRecordedPrograms(mSeries.getId()); + Collections.sort(programs, RecordedProgram.EPISODE_COMPARATOR); + mRecommendRecordedProgram = getRecommendProgram(programs); + if (mRecommendRecordedProgram == null) { + mActionsAdapter.clear(ACTION_WATCH); + } else { + String episodeStatus; + if(mDvrWatchedPositionManager.getWatchedStatus(mRecommendRecordedProgram) + == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) { + episodeStatus = mResumeLabel; + mInitialPlaybackPositionMs = mDvrWatchedPositionManager + .getWatchedPosition(mRecommendRecordedProgram.getId()); + } else { + episodeStatus = mWatchLabel; + mInitialPlaybackPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; + } + String episodeDisplayNumber = mRecommendRecordedProgram.getEpisodeDisplayNumber( + getContext()); + mActionsAdapter.set(ACTION_WATCH, new Action(ACTION_WATCH, + episodeStatus, episodeDisplayNumber, mWatchDrawable)); + } + } + + @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(); + updateWatchAction(); + 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 != null) { + DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); + if (dvrManager.canRemoveSeriesRecording(mSeries.getId())) { + dvrManager.removeSeriesRecording(mSeries.getId()); + } + } + mRecordedProgramPresenter.unbindAllViewHolders(); + } + + @Override + protected OnActionClickedListener onCreateOnActionClickedListener() { + return new OnActionClickedListener() { + @Override + public void onActionClicked(Action action) { + if (action.getId() == ACTION_WATCH) { + startPlayback(mRecommendRecordedProgram, mInitialPlaybackPositionMs); + } else if (action.getId() == ACTION_SERIES_SCHEDULES) { + DvrUiHelper.startSchedulesActivityForSeries(getContext(), mSeries); + } else if (action.getId() == ACTION_DELETE) { + DvrUiHelper.startSeriesDeletionActivity(getContext(), mSeries.getId()); + } + } + }; + } + + /** + * The programs are sorted by season number and episode number. + */ + private RecordedProgram getRecommendProgram(List<RecordedProgram> programs) { + for (int i = programs.size() - 1 ; i >= 0 ; i--) { + RecordedProgram program = programs.get(i); + int watchedStatus = mDvrWatchedPositionManager.getWatchedStatus(program); + if (watchedStatus == DvrWatchedPositionManager.DVR_WATCHED_STATUS_NEW) { + continue; + } + if (watchedStatus == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) { + return program; + } + if (i == programs.size() - 1) { + return program; + } else { + return programs.get(i + 1); + } + } + return programs.isEmpty() ? null : programs.get(0); + } + + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + for (SeriesRecording series : seriesRecordings) { + if (mSeries.getId() == series.getId()) { + mSeries = series; + } + } + } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + for (SeriesRecording series : seriesRecordings) { + if (series.getId() == mSeries.getId()) { + mSeries = null; + getActivity().finish(); + return; + } + } + } + + @Override + public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + if (TextUtils.equals(recordedProgram.getSeriesId(), mSeries.getSeriesId())) { + addProgram(recordedProgram); + if (mActionsAdapter.lookup(ACTION_DELETE) == null) { + mActionsAdapter.set(ACTION_DELETE, mDeleteAction); + } + } + } + } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { + // Do nothing + } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + for (RecordedProgram recordedProgram : recordedPrograms) { + 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); + } + } + } + if (recordedProgram.getId() == mRecommendRecordedProgram.getId()) { + updateWatchAction(); + } + } + } + } + + 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 = BaseProgram.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, mRecordedProgramPresenter); + 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..c2c0f596 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java @@ -0,0 +1,234 @@ +/* + * 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.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.text.TextUtils; +import android.view.ViewGroup; + +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.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.RecordedProgramListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrManager; +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 {@link DvrBrowseFragment}. + */ +public class SeriesRecordingPresenter extends DvrItemPresenter { + private final ChannelDataManager mChannelDataManager; + private final DvrDataManager mDvrDataManager; + private final DvrManager mDvrManager; + 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 DvrManager mDvrManager; + private DvrWatchedPositionManager mWatchedPositionManager; + + SeriesRecordingViewHolder(RecordingCardView view, DvrDataManager dvrDataManager, + DvrManager dvrManager, DvrWatchedPositionManager watchedPositionManager) { + super(view); + mCardView = view; + mDvrDataManager = dvrDataManager; + mDvrManager = dvrManager; + 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 onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { + boolean needToUpdateCardView = false; + for (RecordedProgram recordedProgram : recordedPrograms) { + if (TextUtils.equals(recordedProgram.getSeriesId(), + mSeriesRecording.getSeriesId())) { + mDvrDataManager.removeScheduledRecordingListener(this); + mWatchedPositionManager.addListener(this, recordedProgram.getId()); + needToUpdateCardView = true; + } + } + if (needToUpdateCardView) { + updateCardViewContent(); + } + } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + boolean needToUpdateCardView = false; + for (RecordedProgram recordedProgram : recordedPrograms) { + if (TextUtils.equals(recordedProgram.getSeriesId(), + mSeriesRecording.getSeriesId())) { + if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId()) + == TvInputManager.TIME_SHIFT_INVALID_TIME) { + mWatchedPositionManager.removeListener(this, recordedProgram.getId()); + } + needToUpdateCardView = true; + } + } + if (needToUpdateCardView) { + updateCardViewContent(); + } + } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { + // 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 = mDvrManager.getAvailableScheduledRecording(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(); + mDvrManager = singletons.getDvrManager(); + mWatchedPositionManager = singletons.getDvrWatchedPositionManager(); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent) { + Context context = parent.getContext(); + RecordingCardView view = new RecordingCardView(context); + return new SeriesRecordingViewHolder(view, mDvrDataManager, mDvrManager, + 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); + super.onBindViewHolder(baseHolder, o); + } + + @Override + public void onUnbindViewHolder(ViewHolder viewHolder) { + ((RecordingCardView) viewHolder.view).reset(); + ((SeriesRecordingViewHolder) viewHolder).onUnbound(); + super.onUnbindViewHolder(viewHolder); + } + + 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..6c05c9c6 --- /dev/null +++ b/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java @@ -0,0 +1,397 @@ +/* + * 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.app.ProgressDialog; +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 android.util.Log; +import android.util.LongSparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ProgressBar; + +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.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.EpisodicProgramLoadTask; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.SeriesRecording.ChannelOption; +import com.android.tv.dvr.SeriesRecordingScheduler; +import com.android.tv.dvr.SeriesRecordingScheduler.OnSeriesRecordingUpdatedListener; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Fragment for DVR series recording settings. + */ +public class SeriesSettingsFragment extends GuidedStepFragment + implements DvrDataManager.SeriesRecordingListener { + private static final String TAG = "SeriesSettingsFragment"; + private static final boolean DEBUG = false; + + private static final long ACTION_ID_PRIORITY = 10; + private static final long ACTION_ID_CHANNEL = 11; + + private static final long SUB_ACTION_ID_CHANNEL_ALL = 102; + // Each channel's action id = SUB_ACTION_ID_CHANNEL_ONE_BASE + channel id + private static final long SUB_ACTION_ID_CHANNEL_ONE_BASE = 500; + + private DvrDataManager mDvrDataManager; + private ChannelDataManager mChannelDataManager; + private DvrManager mDvrManager; + private SeriesRecording mSeriesRecording; + private long mSeriesRecordingId; + @ChannelOption int mChannelOption; + private Comparator<Channel> mChannelComparator; + private long mSelectedChannelId; + private int mBackStackCount; + private boolean mShowViewScheduleOptionInDialog; + + private String mFragmentTitle; + private String mProrityActionTitle; + private String mProrityActionHighestText; + private String mProrityActionLowestText; + private String mChannelsActionTitle; + private String mChannelsActionAllText; + private LongSparseArray<Channel> mId2Channel = new LongSparseArray<>(); + private List<Channel> mChannels = new ArrayList<>(); + private EpisodicProgramLoadTask mEpisodicProgramLoadTask; + + private GuidedAction mPriorityGuidedAction; + private GuidedAction mChannelsGuidedAction; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mBackStackCount = getFragmentManager().getBackStackEntryCount(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mSeriesRecordingId = getArguments().getLong(DvrSeriesSettingsActivity.SERIES_RECORDING_ID); + mSeriesRecording = mDvrDataManager.getSeriesRecording(mSeriesRecordingId); + if (mSeriesRecording == null) { + getActivity().finish(); + return; + } + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + mShowViewScheduleOptionInDialog = getArguments().getBoolean( + DvrSeriesSettingsActivity.SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG); + mDvrDataManager.addSeriesRecordingListener(this); + long[] channelIds = getArguments().getLongArray(DvrSeriesSettingsActivity.CHANNEL_ID_LIST); + mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); + if (channelIds == null) { + Channel channel = mChannelDataManager.getChannel(mSeriesRecording.getChannelId()); + if (channel != null) { + mId2Channel.put(channel.getId(), channel); + mChannels.add(channel); + } + collectChannelsInBackground(); + } else { + for (long channelId : channelIds) { + Channel channel = mChannelDataManager.getChannel(channelId); + if (channel != null) { + mId2Channel.put(channel.getId(), channel); + mChannels.add(channel); + } + } + } + mChannelOption = mSeriesRecording.getChannelOption(); + mSelectedChannelId = Channel.INVALID_ID; + if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE) { + Channel channel = mChannelDataManager.getChannel(mSeriesRecording.getChannelId()); + if (channel != null) { + mSelectedChannelId = channel.getId(); + } else { + mChannelOption = SeriesRecording.OPTION_CHANNEL_ALL; + } + } + mChannelComparator = new Channel.DefaultComparator(context, + TvApplication.getSingletons(context).getTvInputManagerHelper()); + mChannels.sort(mChannelComparator); + 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); + if (mEpisodicProgramLoadTask != null) { + mEpisodicProgramLoadTask.cancel(true); + mEpisodicProgramLoadTask = null; + } + } + + @Override + public void onDestroy() { + DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); + if (getFragmentManager().getBackStackEntryCount() == mBackStackCount + && getArguments() + .getBoolean(DvrSeriesSettingsActivity.REMOVE_EMPTY_SERIES_RECORDING) + && dvrManager.canRemoveSeriesRecording(mSeriesRecordingId)) { + dvrManager.removeSeriesRecording(mSeriesRecordingId); + } + super.onDestroy(); + } + + @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); + + mChannelsGuidedAction = new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_CHANNEL) + .title(mChannelsActionTitle) + .subActions(buildChannelSubAction()) + .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 (mEpisodicProgramLoadTask != null) { + mEpisodicProgramLoadTask.cancel(true); + mEpisodicProgramLoadTask = null; + } + if (mChannelOption != mSeriesRecording.getChannelOption() + || mSeriesRecording.isStopped() + || (mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE + && mSeriesRecording.getChannelId() != mSelectedChannelId)) { + SeriesRecording.Builder builder = SeriesRecording.buildFrom(mSeriesRecording) + .setChannelOption(mChannelOption) + .setState(SeriesRecording.STATE_SERIES_NORMAL); + if (mSelectedChannelId != Channel.INVALID_ID) { + builder.setChannelId(mSelectedChannelId); + } + TvApplication.getSingletons(getContext()).getDvrManager() + .updateSeriesRecording(builder.build()); + SeriesRecordingScheduler scheduler = + SeriesRecordingScheduler.getInstance(getContext()); + // Since dialog is used even after the fragment is closed, we should + // use application context. + ProgressDialog dialog = ProgressDialog.show(getContext(), null, getString( + R.string.dvr_series_schedules_progress_message_updating_programs)); + scheduler.addOnSeriesRecordingUpdatedListener( + new OnSeriesRecordingUpdatedListener() { + @Override + public void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings) { + for (SeriesRecording seriesRecording : seriesRecordings) { + if (seriesRecording.getId() == mSeriesRecordingId) { + dialog.dismiss(); + scheduler.removeOnSeriesRecordingUpdatedListener(this); + showConfirmDialog(); + return; + } + } + } + }); + } else { + showConfirmDialog(); + } + } 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; + mSelectedChannelId = Channel.INVALID_ID; + updateChannelsGuidedAction(true); + return true; + } else if (actionId > SUB_ACTION_ID_CHANNEL_ONE_BASE) { + mChannelOption = SeriesRecording.OPTION_CHANNEL_ONE; + mSelectedChannelId = actionId - SUB_ACTION_ID_CHANNEL_ONE_BASE; + 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(mId2Channel.get(mSelectedChannelId) + .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(getString( + R.string.dvr_series_settings_priority_rank, priorityOrder + 1)); + } + if (notifyActionChanged) { + notifyActionChanged(findActionPositionById(ACTION_ID_PRIORITY)); + } + } + + private void collectChannelsInBackground() { + if (mEpisodicProgramLoadTask != null) { + mEpisodicProgramLoadTask.cancel(true); + } + mEpisodicProgramLoadTask = new EpisodicProgramLoadTask(getContext(), mSeriesRecording) { + @Override + protected void onPostExecute(List<Program> programs) { + mEpisodicProgramLoadTask = null; + Set<Long> channelIds = new HashSet<>(); + for (Program program : programs) { + channelIds.add(program.getChannelId()); + } + boolean channelAdded = false; + for (Long channelId : channelIds) { + if (mId2Channel.get(channelId) != null) { + continue; + } + Channel channel = mChannelDataManager.getChannel(channelId); + if (channel != null) { + channelAdded = true; + mId2Channel.put(channelId, channel); + mChannels.add(channel); + if (DEBUG) Log.d(TAG, "Added channel: " + channel); + } + } + if (!channelAdded) { + return; + } + mChannels.sort(mChannelComparator); + mChannelsGuidedAction.setSubActions(buildChannelSubAction()); + notifyActionChanged(findActionPositionById(ACTION_ID_CHANNEL)); + if (DEBUG) Log.d(TAG, "Complete EpisodicProgramLoadTask"); + } + }.setLoadCurrentProgram(true) + .setLoadDisallowedProgram(true) + .setLoadScheduledEpisode(true) + .setIgnoreChannelOption(true); + mEpisodicProgramLoadTask.execute(); + } + + private List<GuidedAction> buildChannelSubAction() { + List<GuidedAction> channelSubActions = new ArrayList<>(); + channelSubActions.add(new GuidedAction.Builder(getActivity()) + .id(SUB_ACTION_ID_CHANNEL_ALL) + .title(mChannelsActionAllText) + .build()); + for (Channel channel : mChannels) { + channelSubActions.add(new GuidedAction.Builder(getActivity()) + .id(SUB_ACTION_ID_CHANNEL_ONE_BASE + channel.getId()) + .title(channel.getDisplayText()) + .build()); + } + return channelSubActions; + } + + private void showConfirmDialog() { + DvrUiHelper.StartSeriesScheduledDialogActivity( + getContext(), mSeriesRecording, mShowViewScheduleOptionInDialog); + finishGuidedStepFragments(); + } + + @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..393a5ff3 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,155 @@ 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; + /** + * Finds the position that the given item should be inserted to keep the sorted order. + */ + public 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..d28f026c --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java @@ -0,0 +1,178 @@ +/* +* 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.widget.TextView; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrScheduleManager; +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, + DvrScheduleManager.OnConflictStateChangeListener { + /** + * The key for scheduled recording which has be selected in the list. + */ + public static String SCHEDULES_KEY_SCHEDULED_RECORDING = "schedules_key_scheduled_recording"; + + private ScheduleRowAdapter mRowsAdapter; + private TextView mEmptyInfoScreenView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); + presenterSelector.addClassPresenter(SchedulesHeaderRow.class, onCreateHeaderRowPresenter()); + presenterSelector.addClassPresenter(ScheduleRow.class, onCreateRowPresenter()); + mRowsAdapter = onCreateRowsAdapter(presenterSelector); + setAdapter(mRowsAdapter); + mRowsAdapter.start(); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); + singletons.getDvrDataManager().addScheduledRecordingListener(this); + singletons.getDvrScheduleManager().addOnConflictStateChangeListener(this); + mEmptyInfoScreenView = (TextView) getActivity().findViewById(R.id.empty_info_screen); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + int firstItemPosition = getFirstItemPosition(); + if (firstItemPosition != -1) { + getRowsFragment().setSelectedPosition(firstItemPosition, false); + } + return view; + } + + /** + * Returns rows adapter. + */ + protected ScheduleRowAdapter getRowsAdapter() { + return mRowsAdapter; + } + + /** + * Shows the empty message. + */ + void showEmptyMessage(int messageId) { + mEmptyInfoScreenView.setText(messageId); + if (mEmptyInfoScreenView.getVisibility() != View.VISIBLE) { + mEmptyInfoScreenView.setVisibility(View.VISIBLE); + } + } + + /** + * Hides the empty message. + */ + void hideEmptyMessage() { + if (mEmptyInfoScreenView.getVisibility() == View.VISIBLE) { + mEmptyInfoScreenView.setVisibility(View.GONE); + } + } + + @Override + public View onInflateTitleView(LayoutInflater inflater, ViewGroup parent, + Bundle savedInstanceState) { + // Workaround of b/31046014 + return null; + } + + @Override + public void onDestroy() { + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); + singletons.getDvrScheduleManager().removeOnConflictStateChangeListener(this); + singletons.getDvrDataManager().removeScheduledRecordingListener(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() { + for (int i = 0; i < mRowsAdapter.size(); i++) { + if (mRowsAdapter.get(i) instanceof ScheduleRow) { + return i; + } + } + return -1; + } + + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + if (mRowsAdapter != null) { + for (ScheduledRecording recording : scheduledRecordings) { + mRowsAdapter.onScheduledRecordingAdded(recording); + } + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + if (mRowsAdapter != null) { + for (ScheduledRecording recording : scheduledRecordings) { + mRowsAdapter.onScheduledRecordingRemoved(recording); + } + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + if (mRowsAdapter != null) { + for (ScheduledRecording recording : scheduledRecordings) { + mRowsAdapter.onScheduledRecordingUpdated(recording, false); + } + } + } + + @Override + public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) { + if (mRowsAdapter != null) { + for (ScheduledRecording recording : schedules) { + mRowsAdapter.onScheduledRecordingUpdated(recording, true); + } + } + } +}
\ No newline at end of file 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..722c9b6e --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java @@ -0,0 +1,86 @@ +/* + * 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.dvr.ScheduledRecording; +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); + } + + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + super.onScheduledRecordingAdded(scheduledRecordings); + if (getRowsAdapter().size() > 0) { + hideEmptyMessage(); + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + super.onScheduledRecordingRemoved(scheduledRecordings); + if (getRowsAdapter().size() == 0) { + showEmptyMessage(R.string.dvr_schedules_empty_state); + } + } + + @Override + protected int getFirstItemPosition() { + Bundle args = getArguments(); + ScheduledRecording recording = null; + if (args != null) { + recording = args.getParcelable(SCHEDULES_KEY_SCHEDULED_RECORDING); + } + final int selectedPostion = getRowsAdapter().indexOf( + getRowsAdapter().findRowByScheduledRecording(recording)); + if (selectedPostion != -1) { + return selectedPostion; + } + return super.getFirstItemPosition(); + } +}
\ 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..42a1e72b --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java @@ -0,0 +1,208 @@ +/* +* 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.annotation.TargetApi; +import android.database.ContentObserver; +import android.media.tv.TvContract.Programs; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.transition.Fade; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.data.Program; +import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener; +import com.android.tv.dvr.EpisodicProgramLoadTask; +import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.SeriesRecordingHeaderRowPresenter; + +import java.util.List; + +/** + * A fragment to show the list of series schedule recordings. + */ +@TargetApi(Build.VERSION_CODES.N) +public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { + private static final String TAG = "DvrSeriesSchedulesFragment"; + /** + * The key for series recording whose scheduled recording list will be displayed. + */ + public static final String SERIES_SCHEDULES_KEY_SERIES_RECORDING = + "series_schedules_key_series_recording"; + /** + * The key for programs belong to the series recording whose scheduled recording + * list will be displayed. + */ + public static final String SERIES_SCHEDULES_KEY_SERIES_PROGRAMS = + "series_schedules_key_series_programs"; + + private ChannelDataManager mChannelDataManager; + private SeriesRecording mSeriesRecording; + private List<Program> mPrograms; + private EpisodicProgramLoadTask mProgramLoadTask; + + private final SeriesRecordingListener mSeriesRecordingListener = + new SeriesRecordingListener() { + @Override + public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { } + + @Override + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + for (SeriesRecording r : seriesRecordings) { + if (r.getId() == mSeriesRecording.getId()) { + getActivity().finish(); + return; + } + } + } + + @Override + public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { + for (SeriesRecording r : seriesRecordings) { + if (r.getId() == mSeriesRecording.getId() + && getRowsAdapter() instanceof SeriesScheduleRowAdapter) { + ((SeriesScheduleRowAdapter) getRowsAdapter()) + .onSeriesRecordingUpdated(r); + return; + } + } + } + }; + + private final ContentObserver mContentObserver = + new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange, Uri uri) { + super.onChange(selfChange, uri); + executeProgramLoadingTask(); + } + }; + + private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { } + + @Override + public void onChannelListUpdated() { + executeProgramLoadingTask(); + } + + @Override + public void onChannelBrowsableChanged() { } + }; + + public DvrSeriesSchedulesFragment() { + setEnterTransition(new Fade(Fade.IN)); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + Bundle args = getArguments(); + if (args != null) { + mSeriesRecording = args.getParcelable(SERIES_SCHEDULES_KEY_SERIES_RECORDING); + mPrograms = args.getParcelableArrayList(SERIES_SCHEDULES_KEY_SERIES_PROGRAMS); + } + super.onCreate(savedInstanceState); + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); + singletons.getDvrDataManager().addSeriesRecordingListener(mSeriesRecordingListener); + mChannelDataManager = singletons.getChannelDataManager(); + mChannelDataManager.addListener(mChannelListener); + getContext().getContentResolver().registerContentObserver(Programs.CONTENT_URI, true, + mContentObserver); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + onProgramsUpdated(); + return super.onCreateView(inflater, container, savedInstanceState); + } + + private void onProgramsUpdated() { + ((SeriesScheduleRowAdapter) getRowsAdapter()).setPrograms(mPrograms); + if (mPrograms == null || mPrograms.isEmpty()) { + showEmptyMessage(R.string.dvr_series_schedules_empty_state); + } else { + hideEmptyMessage(); + } + } + + @Override + public void onDestroy() { + if (mProgramLoadTask != null) { + mProgramLoadTask.cancel(true); + mProgramLoadTask = null; + } + getContext().getContentResolver().unregisterContentObserver(mContentObserver); + mChannelDataManager.removeListener(mChannelListener); + TvApplication.getSingletons(getContext()).getDvrDataManager() + .removeSeriesRecordingListener(mSeriesRecordingListener); + super.onDestroy(); + } + + @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, mSeriesRecording); + } + + @Override + protected int getFirstItemPosition() { + if (mSeriesRecording != null + && mSeriesRecording.getState() == SeriesRecording.STATE_SERIES_STOPPED) { + return 0; + } + return super.getFirstItemPosition(); + } + + private void executeProgramLoadingTask() { + if (mProgramLoadTask != null) { + mProgramLoadTask.cancel(true); + } + mProgramLoadTask = new EpisodicProgramLoadTask(getContext(), mSeriesRecording) { + @Override + protected void onPostExecute(List<Program> programs) { + mPrograms = programs; + onProgramsUpdated(); + } + }; + mProgramLoadTask.setLoadCurrentProgram(true) + .setLoadDisallowedProgram(true) + .setLoadScheduledEpisode(true) + .setIgnoreChannelOption(true) + .execute(); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java b/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java new file mode 100644 index 00000000..23aebf59 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java @@ -0,0 +1,89 @@ +/* + * 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 com.android.tv.data.Program; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ScheduledRecording.Builder; + +/** + * A class for the episodic program. + */ +public class EpisodicProgramRow extends ScheduleRow { + private final String mInputId; + private final Program mProgram; + + public EpisodicProgramRow(String inputId, Program program, ScheduledRecording recording, + SchedulesHeaderRow headerRow) { + super(recording, headerRow); + mInputId = inputId; + mProgram = program; + } + + /** + * Returns the program. + */ + public Program getProgram() { + return mProgram; + } + + @Override + public long getChannelId() { + return mProgram.getChannelId(); + } + + @Override + public long getStartTimeMs() { + return mProgram.getStartTimeUtcMillis(); + } + + @Override + public long getEndTimeMs() { + return mProgram.getEndTimeUtcMillis(); + } + + @Override + public Builder createNewScheduleBuilder() { + return ScheduledRecording.builder(mInputId, mProgram); + } + + @Override + public String getProgramTitleWithEpisodeNumber(Context context) { + return mProgram.getTitleWithEpisodeNumber(context); + } + + @Override + public String getEpisodeDisplayTitle(Context context) { + return mProgram.getEpisodeDisplayTitle(context); + } + + @Override + public boolean matchSchedule(ScheduledRecording schedule) { + return schedule.getType() == ScheduledRecording.TYPE_PROGRAM + && mProgram.getId() == schedule.getProgramId(); + } + + @Override + public String toString() { + return super.toString() + + "(inputId=" + mInputId + + ",program=" + mProgram + + ")"; + } +} 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..3fc92e8a --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRow.java @@ -0,0 +1,203 @@ +/* + * 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.annotation.Nullable; + +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.ScheduledRecording; + +/** + * A class for schedule recording row. + */ +public class ScheduleRow { + private final SchedulesHeaderRow mHeaderRow; + @Nullable private ScheduledRecording mSchedule; + private boolean mStopRecordingRequested; + private boolean mStartRecordingRequested; + + public ScheduleRow(@Nullable ScheduledRecording recording, SchedulesHeaderRow headerRow) { + mSchedule = recording; + mHeaderRow = headerRow; + } + + /** + * Gets which {@link SchedulesHeaderRow} this schedule row belongs to. + */ + public SchedulesHeaderRow getHeaderRow() { + return mHeaderRow; + } + + /** + * Returns the recording schedule. + */ + @Nullable + public ScheduledRecording getSchedule() { + return mSchedule; + } + + /** + * Checks if the stop recording has been requested or not. + */ + public boolean isStopRecordingRequested() { + return mStopRecordingRequested; + } + + /** + * Sets the flag of stop recording request. + */ + public void setStopRecordingRequested(boolean stopRecordingRequested) { + SoftPreconditions.checkState(!mStartRecordingRequested); + mStopRecordingRequested = stopRecordingRequested; + } + + /** + * Checks if the start recording has been requested or not. + */ + public boolean isStartRecordingRequested() { + return mStartRecordingRequested; + } + + /** + * Sets the flag of start recording request. + */ + public void setStartRecordingRequested(boolean startRecordingRequested) { + SoftPreconditions.checkState(!mStopRecordingRequested); + mStartRecordingRequested = startRecordingRequested; + } + + /** + * Sets the recording schedule. + */ + public void setSchedule(@Nullable ScheduledRecording schedule) { + mSchedule = schedule; + } + + /** + * Returns the channel ID. + */ + public long getChannelId() { + return mSchedule != null ? mSchedule.getChannelId() : -1; + } + + /** + * Returns the start time. + */ + public long getStartTimeMs() { + return mSchedule != null ? mSchedule.getStartTimeMs() : -1; + } + + /** + * Returns the end time. + */ + public long getEndTimeMs() { + return mSchedule != null ? mSchedule.getEndTimeMs() : -1; + } + + /** + * Returns the duration. + */ + public final long getDuration() { + return getEndTimeMs() - getStartTimeMs(); + } + + /** + * Checks if the program is on air. + */ + public final boolean isOnAir() { + long currentTimeMs = System.currentTimeMillis(); + return getStartTimeMs() <= currentTimeMs && getEndTimeMs() > currentTimeMs; + } + + /** + * Checks if the schedule is not started. + */ + public final boolean isRecordingNotStarted() { + return mSchedule != null + && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED; + } + + /** + * Checks if the schedule is in progress. + */ + public final boolean isRecordingInProgress() { + return mSchedule != null + && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS; + } + + /** + * Checks if the schedule has been canceled or not. + */ + public final boolean isScheduleCanceled() { + return mSchedule != null + && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_CANCELED; + } + + public boolean isRecordingFinished() { + return mSchedule != null + && (mSchedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED + || mSchedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED + || mSchedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED); + } + + /** + * Creates and returns the new schedule with the existing information. + */ + public ScheduledRecording.Builder createNewScheduleBuilder() { + return mSchedule != null ? ScheduledRecording.buildFrom(mSchedule) : null; + } + + /** + * Returns the program title with episode number. + */ + public String getProgramTitleWithEpisodeNumber(Context context) { + return mSchedule != null ? mSchedule.getProgramTitleWithEpisodeNumber(context) : null; + } + + /** + * Returns the program title including the season/episode number. + */ + public String getEpisodeDisplayTitle(Context context) { + return mSchedule != null ? mSchedule.getEpisodeDisplayTitle(context) : null; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "(schedule=" + mSchedule + + ",stopRecordingRequested=" + mStopRecordingRequested + + ",startRecordingRequested=" + mStartRecordingRequested + + ")"; + } + + /** + * Checks if the {@code schedule} is for the program or channel. + */ + public boolean matchSchedule(ScheduledRecording schedule) { + if (mSchedule == null) { + return false; + } + if (mSchedule.getType() == ScheduledRecording.TYPE_TIMED) { + return mSchedule.getChannelId() == schedule.getChannelId() + && mSchedule.getStartTimeMs() == schedule.getStartTimeMs() + && mSchedule.getEndTimeMs() == schedule.getEndTimeMs(); + } else { + return mSchedule.getProgramId() == schedule.getProgramId(); + } + } +} 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..9cc82653 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java @@ -0,0 +1,425 @@ +/* + * 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.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.text.format.DateUtils; +import android.util.ArraySet; +import android.util.Log; + +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.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.Set; +import java.util.concurrent.TimeUnit; + +/** + * An adapter for {@link ScheduleRow}. + */ +public class ScheduleRowAdapter extends ArrayObjectAdapter { + private static final String TAG = "ScheduleRowAdapter"; + private static final boolean DEBUG = false; + + private final static long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); + + private static final int MSG_UPDATE_ROW = 1; + + private Context mContext; + private final List<String> mTitles = new ArrayList<>(); + private final Set<ScheduleRow> mPendingUpdate = new ArraySet<>(); + + private final Handler mHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + if (msg.what == MSG_UPDATE_ROW) { + long currentTimeMs = System.currentTimeMillis(); + handleUpdateRow(currentTimeMs); + sendNextUpdateMessage(currentTimeMs); + } + } + }; + + 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_THEN_ID_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; + } + sendNextUpdateMessage(System.currentTimeMillis()); + } + + 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_SHOW_DATE + | DateUtils.FORMAT_ABBREV_MONTH); + } + return headerDate; + } + + /** + * Stops schedules row adapter. + */ + public void stop() { + mHandler.removeCallbacksAndMessages(null); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + for (int i = 0; i < size(); i++) { + if (get(i) instanceof ScheduleRow) { + ScheduleRow row = (ScheduleRow) get(i); + if (row.isScheduleCanceled()) { + dvrManager.removeScheduledRecording(row.getSchedule()); + } + } + } + } + + /** + * 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 && ((ScheduleRow) item).getSchedule() != null) { + if (((ScheduleRow) item).getSchedule().getId() == recording.getId()) { + return (ScheduleRow) item; + } + } + } + return null; + } + + private ScheduleRow findRowWithStartRequest(ScheduledRecording schedule) { + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (!(item instanceof ScheduleRow)) { + continue; + } + ScheduleRow row = (ScheduleRow) item; + if (row.getSchedule() != null && row.isStartRecordingRequested() + && row.matchSchedule(schedule)) { + return row; + } + } + return null; + } + + private void addScheduleRow(ScheduledRecording recording) { + // This method must not be called from inherited class. + SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class)); + 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_THEN_ID_COMPARATOR.compare( + scheduleRow.getSchedule(), 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); + updateHeaderDescription(headerRow); + } 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); + updateHeaderDescription(headerRow); + } 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()); + } + + private void removeScheduleRow(ScheduleRow scheduleRow) { + // This method must not be called from inherited class. + SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class)); + if (scheduleRow != null) { + scheduleRow.setSchedule(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 { + replace(indexOf(headerRow), headerRow); + updateHeaderDescription(headerRow); + } + } + } + } + + private void updateHeaderDescription(SchedulesHeaderRow headerRow) { + headerRow.setDescription(mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_section_subtitle, + headerRow.getItemCount(), headerRow.getItemCount())); + } + + /** + * Called when a schedule recording is added to dvr date manager. + */ + public void onScheduledRecordingAdded(ScheduledRecording schedule) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + schedule); + ScheduleRow row = findRowWithStartRequest(schedule); + // If the start recording is requested, onScheduledRecordingAdded is called with NOT_STARTED + // state. And then onScheduleRecordingUpdated will be called with IN_PROGRESS. + // It happens in a short time and causes blinking. To avoid this intermediate state change, + // update the row in onScheduleRecordingUpdated when the state changes to IN_PROGRESS + // instead of in this method. + if (row == null) { + addScheduleRow(schedule); + sendNextUpdateMessage(System.currentTimeMillis()); + } + } + + /** + * Called when a schedule recording is removed from dvr date manager. + */ + public void onScheduledRecordingRemoved(ScheduledRecording schedule) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + schedule); + ScheduleRow row = findRowByScheduledRecording(schedule); + if (row != null) { + removeScheduleRow(row); + notifyArrayItemRangeChanged(indexOf(row), 1); + sendNextUpdateMessage(System.currentTimeMillis()); + } + } + + /** + * Called when a schedule recording is updated in dvr date manager. + */ + public void onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingUpdated: " + schedule); + ScheduleRow row = findRowByScheduledRecording(schedule); + if (row != null) { + if (conflictChange && isStartOrStopRequested()) { + // Delay the conflict update until it gets the response of the start/stop request. + // The purpose is to avoid the intermediate conflict change. + addPendingUpdate(row); + return; + } + if (row.isStopRecordingRequested()) { + // Wait until the recording is finished + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED) { + row.setStopRecordingRequested(false); + if (!isStartOrStopRequested()) { + executePendingUpdate(); + } + row.setSchedule(schedule); + } + } else { + row.setSchedule(schedule); + if (!willBeKept(schedule)) { + removeScheduleRow(row); + } + } + notifyArrayItemRangeChanged(indexOf(row), 1); + sendNextUpdateMessage(System.currentTimeMillis()); + } else { + row = findRowWithStartRequest(schedule); + // When the start recording was requested, we give the highest priority. So it is + // guaranteed that the state will be changed from NOT_STARTED to the other state. + // Update the row with the next state not to show the intermediate state which causes + // blinking. + if (row != null + && schedule.getState() != ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + // This can be called multiple times, so do not call + // ScheduleRow.setStartRecordingRequested(false) here. + row.setStartRecordingRequested(false); + if (!isStartOrStopRequested()) { + executePendingUpdate(); + } + row.setSchedule(schedule); + notifyArrayItemRangeChanged(indexOf(row), 1); + sendNextUpdateMessage(System.currentTimeMillis()); + } + } + } + + /** + * Checks if there is a row which requested start/stop recording. + */ + protected boolean isStartOrStopRequested() { + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (item instanceof ScheduleRow) { + ScheduleRow row = (ScheduleRow) item; + if (row.isStartRecordingRequested() || row.isStopRecordingRequested()) { + return true; + } + } + } + return false; + } + + /** + * Delays update of the row. + */ + protected void addPendingUpdate(ScheduleRow row) { + mPendingUpdate.add(row); + } + + /** + * Executes the pending updates. + */ + protected void executePendingUpdate() { + for (ScheduleRow row : mPendingUpdate) { + int index = indexOf(row); + if (index != -1) { + notifyArrayItemRangeChanged(index, 1); + } + } + mPendingUpdate.clear(); + } + + /** + * To check whether the recording should be kept or not. + */ + protected boolean willBeKept(ScheduledRecording schedule) { + // CANCELED state means that the schedule was removed temporarily, which should be shown + // in the list so that the user can reschedule it. + return schedule.getEndTimeMs() > System.currentTimeMillis() + && (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS + || schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_CANCELED); + } + + /** + * Handle the message to update/remove rows. + */ + protected void handleUpdateRow(long currentTimeMs) { + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (item instanceof ScheduleRow) { + ScheduleRow row = (ScheduleRow) item; + if (row.getEndTimeMs() <= currentTimeMs) { + removeScheduleRow(row); + } + } + } + } + + /** + * Returns the next update time. Return {@link Long#MAX_VALUE} if no timer is necessary. + */ + protected long getNextTimerMs(long currentTimeMs) { + long earliest = Long.MAX_VALUE; + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (item instanceof ScheduleRow) { + // If the schedule was finished earlier than the end time, it should be removed + // when it reaches the end time in this class. + ScheduleRow row = (ScheduleRow) item; + if (earliest > row.getEndTimeMs()) { + earliest = row.getEndTimeMs(); + } + } + } + return earliest; + } + + /** + * Send update message at the time returned by {@link #getNextTimerMs}. + */ + protected final void sendNextUpdateMessage(long currentTimeMs) { + mHandler.removeMessages(MSG_UPDATE_ROW); + long nextTime = getNextTimerMs(currentTimeMs); + if (nextTime != Long.MAX_VALUE) { + mHandler.sendEmptyMessageDelayed(MSG_UPDATE_ROW, + nextTime - System.currentTimeMillis()); + } + } +} 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..1257e725 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java @@ -0,0 +1,795 @@ +/* + * 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.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.os.Build; +import android.support.annotation.IntDef; +import android.support.v17.leanback.widget.RowPresenter; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnFocusChangeListener; +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.common.SoftPreconditions; +import com.android.tv.data.Channel; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.DvrStopRecordingFragment; +import com.android.tv.dvr.ui.HalfSizedDialogFragment; +import com.android.tv.util.ToastUtils; +import com.android.tv.util.Utils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * A RowPresenter for {@link ScheduleRow}. + */ +@TargetApi(Build.VERSION_CODES.N) +public class ScheduleRowPresenter extends RowPresenter { + private static final String TAG = "ScheduleRowPresenter"; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ACTION_START_RECORDING, ACTION_STOP_RECORDING, ACTION_CREATE_SCHEDULE, + ACTION_REMOVE_SCHEDULE}) + public @interface ScheduleRowAction {} + /** An action to start recording. */ + public static final int ACTION_START_RECORDING = 1; + /** An action to stop recording. */ + public static final int ACTION_STOP_RECORDING = 2; + /** An action to create schedule for the row. */ + public static final int ACTION_CREATE_SCHEDULE = 3; + /** An action to remove the schedule. */ + public static final int ACTION_REMOVE_SCHEDULE = 4; + + private final Context mContext; + private final DvrManager mDvrManager; + private final DvrScheduleManager mDvrScheduleManager; + + private final String mTunerConflictWillNotBeRecordedInfo; + private final String mTunerConflictWillBePartiallyRecordedInfo; + private final int mAnimationDuration; + + private int mLastFocusedViewId; + + /** + * A ViewHolder for {@link ScheduleRow} + */ + public static class ScheduleRowViewHolder extends RowPresenter.ViewHolder { + private ScheduleRowPresenter mPresenter; + @ScheduleRowAction private int[] mActions; + private boolean mLtr; + private LinearLayout mInfoContainer; + // The first action is on the right of the second action. + private RelativeLayout mSecondActionContainer; + private RelativeLayout mFirstActionContainer; + private View mSelectorView; + private TextView mTimeView; + private TextView mProgramTitleView; + private TextView mInfoSeparatorView; + private TextView mChannelNameView; + private TextView mConflictInfoView; + private ImageView mSecondActionView; + private ImageView mFirstActionView; + + private Runnable mPendingAnimationRunnable; + + private final int mSelectorTranslationDelta; + private final int mSelectorWidthDelta; + private final int mInfoContainerTargetWidthWithNoAction; + private final int mInfoContainerTargetWidthWithOneAction; + private final int mInfoContainerTargetWidthWithTwoAction; + private final int mRoundRectRadius; + + private final OnFocusChangeListener mOnFocusChangeListener = + new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + view.post(new Runnable() { + @Override + public void run() { + if (view.isFocused()) { + mPresenter.mLastFocusedViewId = view.getId(); + } + updateSelector(); + } + }); + } + }; + + public ScheduleRowViewHolder(View view, ScheduleRowPresenter presenter) { + super(view); + mPresenter = presenter; + mLtr = view.getContext().getResources().getConfiguration().getLayoutDirection() + == View.LAYOUT_DIRECTION_LTR; + mInfoContainer = (LinearLayout) view.findViewById(R.id.info_container); + mSecondActionContainer = (RelativeLayout) view.findViewById( + R.id.action_second_container); + mSecondActionView = (ImageView) view.findViewById(R.id.action_second); + mFirstActionContainer = (RelativeLayout) view.findViewById( + R.id.action_first_container); + mFirstActionView = (ImageView) view.findViewById(R.id.action_first); + 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); + Resources res = view.getResources(); + mSelectorTranslationDelta = + res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin) + - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_focus_translation_delta); + mSelectorWidthDelta = res.getDimensionPixelSize( + R.dimen.dvr_schedules_item_focus_width_delta); + mRoundRectRadius = res.getDimensionPixelSize(R.dimen.dvr_schedules_selector_radius); + int fullWidth = res.getDimensionPixelSize( + R.dimen.dvr_schedules_item_width) + - 2 * res.getDimensionPixelSize(R.dimen.dvr_schedules_layout_padding); + mInfoContainerTargetWidthWithNoAction = fullWidth + 2 * mRoundRectRadius; + mInfoContainerTargetWidthWithOneAction = fullWidth + - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin) + - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_delete_width) + + mRoundRectRadius + mSelectorWidthDelta; + mInfoContainerTargetWidthWithTwoAction = mInfoContainerTargetWidthWithOneAction + - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin) + - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_icon_size); + + mInfoContainer.setOnFocusChangeListener(mOnFocusChangeListener); + mFirstActionContainer.setOnFocusChangeListener(mOnFocusChangeListener); + mSecondActionContainer.setOnFocusChangeListener(mOnFocusChangeListener); + } + + /** + * Returns time view. + */ + public TextView getTimeView() { + return mTimeView; + } + + /** + * Returns title view. + */ + public TextView getProgramTitleView() { + return mProgramTitleView; + } + + private void updateSelector() { + int animationDuration = mSelectorView.getResources().getInteger( + android.R.integer.config_shortAnimTime); + DecelerateInterpolator interpolator = new DecelerateInterpolator(); + + if (mInfoContainer.isFocused() || mSecondActionContainer.isFocused() + || mFirstActionContainer.isFocused()) { + final ViewGroup.LayoutParams lp = mSelectorView.getLayoutParams(); + final int targetWidth; + if (mInfoContainer.isFocused()) { + // Use actions to check the visibility of the actions instead of calling + // View.getVisibility() because the view could be on the hiding animation. + if (mActions == null || mActions.length == 0) { + targetWidth = mInfoContainerTargetWidthWithNoAction; + } else if (mActions.length == 1) { + targetWidth = mInfoContainerTargetWidthWithOneAction; + } else { + targetWidth = mInfoContainerTargetWidthWithTwoAction; + } + } else if (mSecondActionContainer.isFocused()) { + targetWidth = Math.max(mSecondActionContainer.getWidth(), 2 * mRoundRectRadius); + } else { + targetWidth = mFirstActionContainer.getWidth() + mRoundRectRadius + + mSelectorTranslationDelta; + } + + float targetTranslationX; + if (mInfoContainer.isFocused()) { + targetTranslationX = mLtr ? mInfoContainer.getLeft() - mRoundRectRadius + - mSelectorView.getLeft() : + mInfoContainer.getRight() + mRoundRectRadius - mSelectorView.getRight(); + } else if (mSecondActionContainer.isFocused()) { + if (mSecondActionContainer.getWidth() > 2 * mRoundRectRadius) { + targetTranslationX = mLtr ? mSecondActionContainer.getLeft() - + mSelectorView.getLeft() + : mSecondActionContainer.getRight() - mSelectorView.getRight(); + } else { + targetTranslationX = mLtr ? mSecondActionContainer.getLeft() - + (mRoundRectRadius - mSecondActionContainer.getWidth() / 2) - + mSelectorView.getLeft() + : mSecondActionContainer.getRight() + + (mRoundRectRadius - mSecondActionContainer.getWidth() / 2) - + mSelectorView.getRight(); + } + } else { + targetTranslationX = mLtr ? mFirstActionContainer.getLeft() + - mSelectorTranslationDelta - mSelectorView.getLeft() + : mFirstActionContainer.getRight() + mSelectorTranslationDelta + - 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(); + if (mPendingAnimationRunnable != null) { + mPendingAnimationRunnable.run(); + mPendingAnimationRunnable = null; + } + } else { + mSelectorView.animate().cancel(); + mSelectorView.animate().alpha(0f).setDuration(animationDuration) + .setInterpolator(interpolator).setUpdateListener(null).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; + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDvrScheduleManager = TvApplication.getSingletons(context).getDvrScheduleManager(); + 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); + mAnimationDuration = mContext.getResources().getInteger( + android.R.integer.config_shortAnimTime); + } + + @Override + public ViewHolder createRowViewHolder(ViewGroup parent) { + return onGetScheduleRowViewHolder(LayoutInflater.from(mContext) + .inflate(R.layout.dvr_schedules_item, parent, false)); + } + + /** + * Returns context. + */ + protected Context getContext() { + return mContext; + } + + /** + * Returns DVR manager. + */ + protected DvrManager getDvrManager() { + return mDvrManager; + } + + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) { + super.onBindRowViewHolder(vh, item); + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + ScheduleRow row = (ScheduleRow) item; + @ScheduleRowAction int[] actions = getAvailableActions(row); + viewHolder.mActions = actions; + viewHolder.mInfoContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onInfoClicked(row); + } + }); + + viewHolder.mFirstActionContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onActionClicked(actions[0], row); + } + }); + + viewHolder.mSecondActionContainer.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onActionClicked(actions[1], row); + } + }); + + viewHolder.mTimeView.setText(onGetRecordingTimeText(row)); + String programInfoText = onGetProgramInfoText(row); + if (TextUtils.isEmpty(programInfoText)) { + int durationMins = + Math.max((int) TimeUnit.MILLISECONDS.toMinutes(row.getDuration()), 1); + programInfoText = mContext.getResources().getQuantityString( + R.plurals.dvr_schedules_recording_duration, durationMins, durationMins); + } + String channelName = getChannelNameText(row); + viewHolder.mProgramTitleView.setText(programInfoText); + viewHolder.mInfoSeparatorView.setVisibility((!TextUtils.isEmpty(programInfoText) + && !TextUtils.isEmpty(channelName)) ? View.VISIBLE : View.GONE); + viewHolder.mChannelNameView.setText(channelName); + if (actions != null) { + switch (actions.length) { + case 2: + viewHolder.mSecondActionView.setImageResource(getImageForAction(actions[1])); + // pass through + case 1: + viewHolder.mFirstActionView.setImageResource(getImageForAction(actions[0])); + break; + } + } + if (mDvrManager.isConflicting(row.getSchedule())) { + String conflictInfo; + if (mDvrScheduleManager.isPartiallyConflicting(row.getSchedule())) { + conflictInfo = mTunerConflictWillBePartiallyRecordedInfo; + } else { + conflictInfo = mTunerConflictWillNotBeRecordedInfo; + } + viewHolder.mConflictInfoView.setText(conflictInfo); + viewHolder.mConflictInfoView.setVisibility(View.VISIBLE); + } else { + viewHolder.mConflictInfoView.setVisibility(View.GONE); + } + if (shouldBeGrayedOut(row)) { + viewHolder.greyOutInfo(); + } else { + viewHolder.whiteBackInfo(); + } + updateActionContainer(viewHolder, viewHolder.isSelected()); + } + + private int getImageForAction(@ScheduleRowAction int action) { + switch (action) { + case ACTION_START_RECORDING: + return R.drawable.ic_record_start; + case ACTION_STOP_RECORDING: + return R.drawable.ic_record_stop; + case ACTION_CREATE_SCHEDULE: + return R.drawable.ic_scheduled_recording; + case ACTION_REMOVE_SCHEDULE: + return R.drawable.ic_dvr_cancel; + default: + return 0; + } + } + + /** + * Returns view holder for schedule row. + */ + protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) { + return new ScheduleRowViewHolder(view, this); + } + + /** + * Returns time text for time view from scheduled recording. + */ + protected String onGetRecordingTimeText(ScheduleRow row) { + return Utils.getDurationString(mContext, row.getStartTimeMs(), row.getEndTimeMs(), true, + false, true, 0); + } + + /** + * Returns program info text for program title view. + */ + protected String onGetProgramInfoText(ScheduleRow row) { + return row.getProgramTitleWithEpisodeNumber(mContext); + } + + private String getChannelNameText(ScheduleRow row) { + Channel channel = TvApplication.getSingletons(mContext).getChannelDataManager() + .getChannel(row.getChannelId()); + return channel == null ? null : + TextUtils.isEmpty(channel.getDisplayName()) ? channel.getDisplayNumber() : + channel.getDisplayName().trim() + " " + channel.getDisplayNumber(); + } + + /** + * Called when user click Info in {@link ScheduleRow}. + */ + protected void onInfoClicked(ScheduleRow scheduleRow) { + ScheduledRecording schedule = scheduleRow.getSchedule(); + if (schedule != null) { + DvrUiHelper.startDetailsActivity((Activity) mContext, schedule, null, true); + } + } + + /** + * Called when the button in a row is clicked. + */ + protected void onActionClicked(@ScheduleRowAction final int action, ScheduleRow row) { + switch (action) { + case ACTION_START_RECORDING: + onStartRecording(row); + break; + case ACTION_STOP_RECORDING: + onStopRecording(row); + break; + case ACTION_CREATE_SCHEDULE: + onCreateSchedule(row); + break; + case ACTION_REMOVE_SCHEDULE: + onRemoveSchedule(row); + break; + } + } + + /** + * Action handler for {@link #ACTION_START_RECORDING}. + */ + protected void onStartRecording(ScheduleRow row) { + ScheduledRecording schedule = row.getSchedule(); + if (schedule == null) { + // This row has been deleted. + return; + } + // Checks if there are current recordings that will be stopped by schedule this program. + // If so, shows confirmation dialog to users. + List<ScheduledRecording> conflictSchedules = mDvrScheduleManager.getConflictingSchedules( + schedule.getChannelId(), System.currentTimeMillis(), schedule.getEndTimeMs()); + for (int i = conflictSchedules.size() - 1; i >= 0; i--) { + ScheduledRecording conflictSchedule = conflictSchedules.get(i); + if (conflictSchedule.isInProgress()) { + DvrUiHelper.showStopRecordingDialog((Activity) mContext, + conflictSchedule.getChannelId(), + DvrStopRecordingFragment.REASON_ON_CONFLICT, + new HalfSizedDialogFragment.OnActionClickListener() { + @Override + public void onActionClick(long actionId) { + if (actionId == DvrStopRecordingFragment.ACTION_STOP) { + onStartRecordingInternal(row); + } + } + }); + return; + } + } + onStartRecordingInternal(row); + } + + private void onStartRecordingInternal(ScheduleRow row) { + if (row.isOnAir() && !row.isRecordingInProgress() && !row.isStartRecordingRequested()) { + row.setStartRecordingRequested(true); + if (row.isRecordingNotStarted()) { + mDvrManager.setHighestPriority(row.getSchedule()); + } else if (row.isRecordingFinished()) { + mDvrManager.addSchedule(ScheduledRecording.buildFrom(row.getSchedule()) + .setId(ScheduledRecording.ID_NOT_SET) + .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED) + .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule())) + .build()); + } else { + SoftPreconditions.checkState(false, TAG, "Invalid row state to start recording: " + + row); + return; + } + String msg = mContext.getString(R.string.dvr_msg_current_program_scheduled, + row.getSchedule().getProgramTitle(), + Utils.toTimeString(row.getEndTimeMs(), false)); + ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT); + } + } + + /** + * Action handler for {@link #ACTION_STOP_RECORDING}. + */ + protected void onStopRecording(ScheduleRow row) { + if (row.getSchedule() == null) { + // This row has been deleted. + return; + } + if (row.isOnAir() && row.isRecordingInProgress() && !row.isStopRecordingRequested()) { + row.setStopRecordingRequested(true); + mDvrManager.stopRecording(row.getSchedule()); + CharSequence deletedInfo = onGetProgramInfoText(row); + if (TextUtils.isEmpty(deletedInfo)) { + deletedInfo = getChannelNameText(row); + } + ToastUtils.show(mContext, mContext.getResources() + .getString(R.string.dvr_schedules_deletion_info, deletedInfo), + Toast.LENGTH_SHORT); + } + } + + /** + * Action handler for {@link #ACTION_CREATE_SCHEDULE}. + */ + protected void onCreateSchedule(ScheduleRow row) { + if (row.getSchedule() == null) { + // This row has been deleted. + return; + } + if (!row.isOnAir()) { + if (row.isScheduleCanceled()) { + mDvrManager.updateScheduledRecording(ScheduledRecording.buildFrom(row.getSchedule()) + .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED) + .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule())) + .build()); + String msg = mContext.getString(R.string.dvr_msg_program_scheduled, + row.getSchedule().getProgramTitle()); + ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT); + } else if (mDvrManager.isConflicting(row.getSchedule())) { + mDvrManager.setHighestPriority(row.getSchedule()); + } + } + } + + /** + * Action handler for {@link #ACTION_REMOVE_SCHEDULE}. + */ + protected void onRemoveSchedule(ScheduleRow row) { + if (row.getSchedule() == null) { + // This row has been deleted. + return; + } + CharSequence deletedInfo = null; + if (row.isOnAir()) { + if (row.isRecordingNotStarted()) { + deletedInfo = getDeletedInfo(row); + mDvrManager.removeScheduledRecording(row.getSchedule()); + } + } else { + if (mDvrManager.isConflicting(row.getSchedule()) + && !shouldKeepScheduleAfterRemoving()) { + deletedInfo = getDeletedInfo(row); + mDvrManager.removeScheduledRecording(row.getSchedule()); + } else if (row.isRecordingNotStarted()) { + deletedInfo = getDeletedInfo(row); + mDvrManager.updateScheduledRecording(ScheduledRecording.buildFrom(row.getSchedule()) + .setState(ScheduledRecording.STATE_RECORDING_CANCELED) + .build()); + } + } + if (deletedInfo != null) { + ToastUtils.show(mContext, mContext.getResources() + .getString(R.string.dvr_schedules_deletion_info, deletedInfo), + Toast.LENGTH_SHORT); + } + } + + private CharSequence getDeletedInfo(ScheduleRow row) { + CharSequence deletedInfo = onGetProgramInfoText(row); + if (TextUtils.isEmpty(deletedInfo)) { + return getChannelNameText(row); + } + return deletedInfo; + } + + @Override + protected void onRowViewSelected(ViewHolder vh, boolean selected) { + super.onRowViewSelected(vh, selected); + updateActionContainer(vh, selected); + } + + /** + * Internal method for onRowViewSelected, can be customized by subclass. + */ + private void updateActionContainer(ViewHolder vh, boolean selected) { + ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh; + viewHolder.mSecondActionContainer.animate().setListener(null).cancel(); + viewHolder.mFirstActionContainer.animate().setListener(null).cancel(); + if (selected && viewHolder.mActions != null) { + switch (viewHolder.mActions.length) { + case 2: + prepareShowActionView(viewHolder.mSecondActionContainer); + prepareShowActionView(viewHolder.mFirstActionContainer); + viewHolder.mPendingAnimationRunnable = new Runnable() { + @Override + public void run() { + showActionView(viewHolder.mSecondActionContainer); + showActionView(viewHolder.mFirstActionContainer); + } + }; + break; + case 1: + prepareShowActionView(viewHolder.mFirstActionContainer); + viewHolder.mPendingAnimationRunnable = new Runnable() { + @Override + public void run() { + hideActionView(viewHolder.mSecondActionContainer, View.GONE); + showActionView(viewHolder.mFirstActionContainer); + } + }; + if (mLastFocusedViewId == R.id.action_second_container) { + mLastFocusedViewId = R.id.info_container; + } + break; + case 0: + default: + viewHolder.mPendingAnimationRunnable = new Runnable() { + @Override + public void run() { + hideActionView(viewHolder.mSecondActionContainer, View.GONE); + hideActionView(viewHolder.mFirstActionContainer, View.GONE); + } + }; + if (mLastFocusedViewId == R.id.action_first_container + || mLastFocusedViewId == R.id.action_second_container) { + mLastFocusedViewId = R.id.info_container; + } + break; + } + View view = viewHolder.view.findViewById(mLastFocusedViewId); + if (view != null && view.getVisibility() == View.VISIBLE) { + // When the row is selected, information container gets the initial focus. + // To give the focus to the same control as the previous row, we need to call + // requestFocus() explicitly. + if (view.hasFocus()) { + viewHolder.mPendingAnimationRunnable.run(); + } else { + view.requestFocus(); + } + } + } else { + viewHolder.mPendingAnimationRunnable = null; + hideActionView(viewHolder.mFirstActionContainer, View.GONE); + hideActionView(viewHolder.mSecondActionContainer, View.GONE); + } + } + + private void prepareShowActionView(View view) { + if (view.getVisibility() != View.VISIBLE) { + view.setAlpha(0.0f); + } + view.setVisibility(View.VISIBLE); + } + + /** + * Add animation when view is visible. + */ + private void showActionView(View view) { + view.animate().alpha(1.0f).setInterpolator(new DecelerateInterpolator()) + .setDuration(mAnimationDuration).start(); + } + + /** + * Add animation when view change to invisible. + */ + private void hideActionView(View view, int visibility) { + if (view.getVisibility() != View.VISIBLE) { + if (view.getVisibility() != visibility) { + view.setVisibility(visibility); + } + return; + } + view.animate().alpha(0.0f).setInterpolator(new DecelerateInterpolator()) + .setDuration(mAnimationDuration) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + view.setVisibility(visibility); + view.animate().setListener(null); + } + }).start(); + } + + /** + * Returns the available actions according to the row's state. It should be the reverse order + * with that in the screen. + */ + @ScheduleRowAction + protected int[] getAvailableActions(ScheduleRow row) { + if (row.getSchedule() != null) { + if (row.isOnAir()) { + if (row.isRecordingInProgress()) { + return new int[] {ACTION_STOP_RECORDING}; + } else if (row.isRecordingNotStarted()) { + if (canResolveConflict()) { + // The "START" action can change the conflict states. + return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_START_RECORDING}; + } else { + return new int[] {ACTION_REMOVE_SCHEDULE}; + } + } else if (row.isRecordingFinished()) { + return new int[] {ACTION_START_RECORDING}; + } else { + SoftPreconditions.checkState(false, TAG, "Invalid row state in checking the" + + " available actions(on air): " + row); + } + } else { + if (row.isScheduleCanceled()) { + return new int[] {ACTION_CREATE_SCHEDULE}; + } else if (mDvrManager.isConflicting(row.getSchedule()) && canResolveConflict()) { + return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_CREATE_SCHEDULE}; + } else if (row.isRecordingNotStarted()) { + return new int[] {ACTION_REMOVE_SCHEDULE}; + } else { + SoftPreconditions.checkState(false, TAG, "Invalid row state in checking the" + + " available actions(future schedule): " + row); + } + } + } + return null; + } + + /** + * Check if the conflict can be resolved in this screen. + */ + protected boolean canResolveConflict() { + return true; + } + + /** + * Check if the schedule should be kept after removing it. + */ + protected boolean shouldKeepScheduleAfterRemoving() { + return false; + } + + /** + * Checks if the row should be grayed out. + */ + protected boolean shouldBeGrayedOut(ScheduleRow row) { + return row.getSchedule() == null + || (row.isOnAir() && !row.isRecordingInProgress()) + || mDvrManager.isConflicting(row.getSchedule()) + || row.isScheduleCanceled(); + } +} 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..0fb0924d --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java @@ -0,0 +1,122 @@ +/* + * 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.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; + } + + /** + * 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 mSeriesRecording; + + public SeriesRecordingHeaderRow(String title, String description, int itemCount, + SeriesRecording series) { + super(title, description, itemCount); + mSeriesRecording = series; + } + + /** + * Returns the series recording, it is for series schedules list. + */ + public SeriesRecording getSeriesRecording() { + return mSeriesRecording; + } + + /** + * Sets the series recording. + */ + public void setSeriesRecording(SeriesRecording seriesRecording) { + mSeriesRecording = seriesRecording; + } + } +} 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..69c33a96 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java @@ -0,0 +1,273 @@ +/* + * 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.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; + +/** + * A base class for RowPresenter for {@link SchedulesHeaderRow} + */ +public abstract class SchedulesHeaderRowPresenter extends RowPresenter { + private Context mContext; + + public SchedulesHeaderRowPresenter(Context context) { + setHeaderPresenter(null); + setSelectEffectEnabled(false); + mContext = context; + } + + /** + * Returns the context. + */ + Context getContext() { + return mContext; + } + + /** + * 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_stop); + mResumeInfo = context.getString(R.string.dvr_series_schedules_start); + } + + @Override + protected ViewHolder createRowViewHolder(ViewGroup parent) { + return new SeriesHeaderRowViewHolder(getContext(), parent); + } + + @Override + protected void onBindRowViewHolder(RowPresenter.ViewHolder viewHolder, Object item) { + super.onBindRowViewHolder(viewHolder, item); + SeriesHeaderRowViewHolder headerViewHolder = + (SeriesHeaderRowViewHolder) viewHolder; + SeriesRecordingHeaderRow header = (SeriesRecordingHeaderRow) item; + headerViewHolder.mSeriesSettingsButton.setVisibility( + header.getSeriesRecording().isStopped() ? View.INVISIBLE : View.VISIBLE); + headerViewHolder.mSeriesSettingsButton.setText(mSettingsInfo); + setTextDrawable(headerViewHolder.mSeriesSettingsButton, mSettingsDrawable); + if (header.getSeriesRecording().isStopped()) { + headerViewHolder.mToggleStartStopButton.setText(mResumeInfo); + setTextDrawable(headerViewHolder.mToggleStartStopButton, mResumeDrawable); + } else { + headerViewHolder.mToggleStartStopButton.setText(mCancelAllInfo); + setTextDrawable(headerViewHolder.mToggleStartStopButton, mCancelDrawable); + } + headerViewHolder.mSeriesSettingsButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + // TODO: pass channel list for settings. + DvrUiHelper.startSeriesSettingsActivity(getContext(), + header.getSeriesRecording().getId(), null, false, false, false); + } + }); + headerViewHolder.mToggleStartStopButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + if (header.getSeriesRecording().isStopped()) { + // Reset priority to the highest. + SeriesRecording seriesRecording = SeriesRecording + .buildFrom(header.getSeriesRecording()) + .setPriority(TvApplication.getSingletons(getContext()) + .getDvrScheduleManager().suggestNewSeriesPriority()) + .build(); + TvApplication.getSingletons(getContext()).getDvrManager() + .updateSeriesRecording(seriesRecording); + // TODO: pass channel list for settings. + DvrUiHelper.startSeriesSettingsActivity(getContext(), + header.getSeriesRecording().getId(), null, false, false, false); + } else { + DvrUiHelper.showCancelAllSeriesRecordingDialog( + (DvrSchedulesActivity) view.getContext(), + header.getSeriesRecording()); + } + } + }); + } + + private void setTextDrawable(TextView textView, Drawable drawableStart) { + if (mLtr) { + textView.setCompoundDrawablesWithIntrinsicBounds(drawableStart, null, null, null); + } else { + textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawableStart, null); + } + } + + /** + * A ViewHolder for {@link SeriesRecordingHeaderRow}. + */ + public static class SeriesHeaderRowViewHolder extends SchedulesHeaderRowViewHolder { + private final TextView mSeriesSettingsButton; + private final TextView mToggleStartStopButton; + private final boolean mLtr; + + private final View mSelector; + + private View mLastFocusedView; + public SeriesHeaderRowViewHolder(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); + mToggleStartStopButton = + (TextView) view.findViewById(R.id.series_toggle_start_stop); + mSelector = view.findViewById(R.id.selector); + OnFocusChangeListener onFocusChangeListener = new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean focused) { + view.post(new Runnable() { + @Override + public void run() { + updateSelector(view); + } + }); + } + }; + mSeriesSettingsButton.setOnFocusChangeListener(onFocusChangeListener); + mToggleStartStopButton.setOnFocusChangeListener(onFocusChangeListener); + } + + private void updateSelector(View focusedView) { + int animationDuration = mSelector.getContext().getResources() + .getInteger(android.R.integer.config_shortAnimTime); + DecelerateInterpolator interpolator = new DecelerateInterpolator(); + + if (focusedView.hasFocus()) { + ViewGroup.LayoutParams lp = mSelector.getLayoutParams(); + final int targetWidth = focusedView.getWidth(); + float targetTranslationX; + if (mLtr) { + targetTranslationX = focusedView.getLeft() - mSelector.getLeft(); + } else { + targetTranslationX = focusedView.getRight() - mSelector.getRight(); + } + + // if the selector is invisible, set the width and translation X directly - + // don't animate. + if (mSelector.getAlpha() == 0) { + mSelector.setTranslationX(targetTranslationX); + lp.width = targetWidth; + mSelector.requestLayout(); + } + + // animate the selector in and to the proper width and translation X. + final float deltaWidth = lp.width - targetWidth; + mSelector.animate().cancel(); + mSelector.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())); + mSelector.requestLayout(); + } + }).setDuration(animationDuration).setInterpolator(interpolator).start(); + mLastFocusedView = focusedView; + } else if (mLastFocusedView == focusedView) { + mSelector.animate().setUpdateListener(null).cancel(); + mSelector.animate().alpha(0f).setDuration(animationDuration) + .setInterpolator(interpolator).start(); + mLastFocusedView = null; + } + } + } + } +} 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..3b493774 --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java @@ -0,0 +1,269 @@ +/* +* 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.annotation.TargetApi; +import android.content.Context; +import android.media.tv.TvInputInfo; +import android.os.Build; +import android.support.v17.leanback.widget.ClassPresenterSelector; +import android.util.ArrayMap; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +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.DvrDataManager; +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.Iterator; +import java.util.List; +import java.util.Map; + +/** + * An adapter for series schedule row. + */ +@TargetApi(Build.VERSION_CODES.N) +public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { + private static final String TAG = "SeriesRowAdapter"; + private static final boolean DEBUG = false; + + private final SeriesRecording mSeriesRecording; + private final String mInputId; + private final DvrManager mDvrManager; + private final DvrDataManager mDataManager; + private final Map<Long, Program> mPrograms = new ArrayMap<>(); + private SeriesRecordingHeaderRow mHeaderRow; + + public SeriesScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector, + SeriesRecording seriesRecording) { + super(context, classPresenterSelector); + mSeriesRecording = seriesRecording; + TvInputInfo input = Utils.getTvInputInfoForInputId(context, mSeriesRecording.getInputId()); + if (SoftPreconditions.checkNotNull(input) != null) { + mInputId = input.getId(); + } else { + mInputId = null; + } + ApplicationSingletons singletons = TvApplication.getSingletons(context); + mDvrManager = singletons.getDvrManager(); + mDataManager = singletons.getDvrDataManager(); + setHasStableIds(true); + } + + @Override + public void start() { + setPrograms(Collections.emptyList()); + } + + @Override + public void stop() { + super.stop(); + } + + /** + * Sets the programs to show. + */ + public void setPrograms(List<Program> programs) { + if (programs == null) { + programs = Collections.emptyList(); + } + clear(); + mPrograms.clear(); + List<Program> sortedPrograms = new ArrayList<>(programs); + Collections.sort(sortedPrograms); + List<EpisodicProgramRow> rows = new ArrayList<>(); + mHeaderRow = new SeriesRecordingHeaderRow(mSeriesRecording.getTitle(), + null, sortedPrograms.size(), mSeriesRecording); + for (Program program : sortedPrograms) { + ScheduledRecording schedule = + mDataManager.getScheduledRecordingForProgramId(program.getId()); + if (schedule != null && !willBeKept(schedule)) { + schedule = null; + } + rows.add(new EpisodicProgramRow(mInputId, program, schedule, mHeaderRow)); + mPrograms.put(program.getId(), program); + } + mHeaderRow.setDescription(getDescription()); + add(mHeaderRow); + for (EpisodicProgramRow row : rows) { + add(row); + } + sendNextUpdateMessage(System.currentTimeMillis()); + } + + private String getDescription() { + int conflicts = 0; + for (long programId : mPrograms.keySet()) { + if (mDvrManager.isConflicting( + mDataManager.getScheduledRecordingForProgramId(programId))) { + ++conflicts; + } + } + return conflicts == 0 ? null : getContext().getResources().getQuantityString( + R.plurals.dvr_series_schedules_header_description, conflicts, conflicts); + } + + @Override + public long getId(int position) { + Object obj = get(position); + if (obj instanceof EpisodicProgramRow) { + return ((EpisodicProgramRow) obj).getProgram().getId(); + } + if (obj instanceof SeriesRecordingHeaderRow) { + return 0; + } + return super.getId(position); + } + + @Override + public void onScheduledRecordingAdded(ScheduledRecording schedule) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + schedule); + int index = findRowIndexByProgramId(schedule.getProgramId()); + if (index != -1) { + EpisodicProgramRow row = (EpisodicProgramRow) get(index); + if (!row.isStartRecordingRequested()) { + row.setSchedule(schedule); + notifyArrayItemRangeChanged(index, 1); + } + } + } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording schedule) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + schedule); + int index = findRowIndexByProgramId(schedule.getProgramId()); + if (index != -1) { + EpisodicProgramRow row = (EpisodicProgramRow) get(index); + row.setSchedule(null); + notifyArrayItemRangeChanged(index, 1); + } + } + + @Override + public void onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange) { + if (DEBUG) Log.d(TAG, "onScheduledRecordingUpdated: " + schedule); + int index = findRowIndexByProgramId(schedule.getProgramId()); + if (index != -1) { + EpisodicProgramRow row = (EpisodicProgramRow) get(index); + if (conflictChange && isStartOrStopRequested()) { + // Delay the conflict update until it gets the response of the start/stop request. + // The purpose is to avoid the intermediate conflict change. + addPendingUpdate(row); + return; + } + if (row.isStopRecordingRequested()) { + // Wait until the recording is finished + if (schedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED + || schedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED) { + row.setStopRecordingRequested(false); + if (!isStartOrStopRequested()) { + executePendingUpdate(); + } + row.setSchedule(null); + } + } else if (row.isStartRecordingRequested()) { + // When the start recording was requested, we give the highest priority. So it is + // guaranteed that the state will be changed from NOT_STARTED to the other state. + // Update the row with the next state not to show the intermediate state to avoid + // blinking. + if (schedule.getState() != ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + row.setStartRecordingRequested(false); + if (!isStartOrStopRequested()) { + executePendingUpdate(); + } + row.setSchedule(schedule); + } + } else if (willBeKept(schedule)) { + row.setSchedule(schedule); + } else { + row.setSchedule(null); + } + notifyArrayItemRangeChanged(index, 1); + } + } + + public void onSeriesRecordingUpdated(SeriesRecording seriesRecording) { + if (seriesRecording.getId() == mSeriesRecording.getId()) { + mHeaderRow.setSeriesRecording(seriesRecording); + notifyArrayItemRangeChanged(0, 1); + } + } + + private int findRowIndexByProgramId(long programId) { + for (int i = 0; i < size(); i++) { + Object item = get(i); + if (item instanceof EpisodicProgramRow) { + if (((EpisodicProgramRow) item).getProgram().getId() == programId) { + return i; + } + } + } + return -1; + } + + @Override + public void notifyArrayItemRangeChanged(int positionStart, int itemCount) { + mHeaderRow.setDescription(getDescription()); + super.notifyArrayItemRangeChanged(0, 1); + super.notifyArrayItemRangeChanged(positionStart, itemCount); + } + + @Override + protected void handleUpdateRow(long currentTimeMs) { + for (Iterator<Program> iter = mPrograms.values().iterator(); iter.hasNext(); ) { + Program program = iter.next(); + if (program.getEndTimeUtcMillis() <= currentTimeMs) { + // Remove the old program. + removeItems(findRowIndexByProgramId(program.getId()), 1); + iter.remove(); + } else if (program.getStartTimeUtcMillis() < currentTimeMs) { + // Change the button "START RECORDING" + notifyItemRangeChanged(findRowIndexByProgramId(program.getId()), 1); + } + } + } + + /** + * Should take the current time argument which is the time when the programs are checked in + * handler. + */ + @Override + protected long getNextTimerMs(long currentTimeMs) { + long earliest = Long.MAX_VALUE; + for (Program program : mPrograms.values()) { + if (earliest > program.getStartTimeUtcMillis() + && program.getStartTimeUtcMillis() >= currentTimeMs) { + // Need the button from "CREATE SCHEDULE" to "START RECORDING" + earliest = program.getStartTimeUtcMillis(); + } else if (earliest > program.getEndTimeUtcMillis()) { + // Need to remove the row. + earliest = program.getEndTimeUtcMillis(); + } + } + return earliest; + } +} 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..5d88579a --- /dev/null +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java @@ -0,0 +1,143 @@ +/* +* 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.common.SoftPreconditions; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.util.Utils; + +/** + * A RowPresenter for series schedule row. + */ +public class SeriesScheduleRowPresenter extends ScheduleRowPresenter { + private static final String TAG = "SeriesRowPresenter"; + + 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, ScheduleRowPresenter presenter) { + super(view, presenter); + 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, this); + } + + @Override + protected String onGetRecordingTimeText(ScheduleRow row) { + return Utils.getDurationString(getContext(), row.getStartTimeMs(), row.getEndTimeMs(), + false, true, true, 0); + } + + @Override + protected String onGetProgramInfoText(ScheduleRow row) { + return row.getEpisodeDisplayTitle(getContext()); + } + + @Override + protected void onBindRowViewHolder(ViewHolder vh, Object item) { + super.onBindRowViewHolder(vh, item); + SeriesScheduleRowViewHolder viewHolder = (SeriesScheduleRowViewHolder) vh; + EpisodicProgramRow row = (EpisodicProgramRow) item; + if (getDvrManager().isConflicting(row.getSchedule())) { + 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); + } + } + + @Override + protected void onInfoClicked(ScheduleRow row) { + if (row.getSchedule() != null) { + DvrUiHelper.startSchedulesActivity(getContext(), row.getSchedule()); + } + } + + @Override + protected void onStartRecording(ScheduleRow row) { + SoftPreconditions.checkState(row.getSchedule() == null, TAG, + "Start request with the existing schedule: " + row); + row.setStartRecordingRequested(true); + getDvrManager().addScheduleWithHighestPriority(((EpisodicProgramRow) row).getProgram()); + } + + @Override + protected void onStopRecording(ScheduleRow row) { + SoftPreconditions.checkState(row.getSchedule() != null, TAG, + "Stop request with the null schedule: " + row); + row.setStopRecordingRequested(true); + getDvrManager().stopRecording(row.getSchedule()); + } + + @Override + protected void onCreateSchedule(ScheduleRow row) { + if (row.getSchedule() == null) { + getDvrManager().addScheduleWithHighestPriority(((EpisodicProgramRow) row).getProgram()); + } else { + super.onCreateSchedule(row); + } + } + + @Override + @ScheduleRowAction + protected int[] getAvailableActions(ScheduleRow row) { + if (row.getSchedule() == null) { + if (row.isOnAir()) { + return new int[] {ACTION_START_RECORDING}; + } else { + return new int[] {ACTION_CREATE_SCHEDULE}; + } + } + return super.getAvailableActions(row); + } + + @Override + protected boolean canResolveConflict() { + return false; + } + + @Override + protected boolean shouldKeepScheduleAfterRemoving() { + return true; + } +} diff --git a/src/com/android/tv/experiments/ExperimentFlag.java b/src/com/android/tv/experiments/ExperimentFlag.java new file mode 100644 index 00000000..8f60c2b5 --- /dev/null +++ b/src/com/android/tv/experiments/ExperimentFlag.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.experiments; + + +/** + * Experiments return values based on user, device and other criteria. + */ +public final class ExperimentFlag<T> { + private final T mDefaultValue; + + /** Returns a boolean experiment */ + public static ExperimentFlag<Boolean> createFlag( + boolean defaultValue) { + return new ExperimentFlag<>( + defaultValue); + } + + private ExperimentFlag( + T defaultValue) { + mDefaultValue = defaultValue; + } + + /** Returns value for this experiment */ + public T get() { + return mDefaultValue; + } +} diff --git a/src/com/android/tv/experiments/Experiments.java b/src/com/android/tv/experiments/Experiments.java new file mode 100644 index 00000000..f16c8d1e --- /dev/null +++ b/src/com/android/tv/experiments/Experiments.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.experiments; + +import static com.android.tv.experiments.ExperimentFlag.createFlag; + +import com.android.tv.common.BuildConfig; + +/** + * Set of experiments visible in AOSP. + * + * <p> + * This file is maintained by hand. + */ +public final class Experiments { + public static final ExperimentFlag<Boolean> CLOUD_EPG = createFlag( + false); + + /** + * Allow developer features such as the dev menu and other aids. + * + * <p>These features are available to select users(aka fishfooders) on production builds. + */ + public static final ExperimentFlag<Boolean> ENABLE_DEVELOPER_FEATURES = createFlag( + BuildConfig.ENG); + + private Experiments() {} +} diff --git a/src/com/android/tv/guide/ProgramGuide.java b/src/com/android/tv/guide/ProgramGuide.java index bfcb8b0d..120b3dba 100644 --- a/src/com/android/tv/guide/ProgramGuide.java +++ b/src/com/android/tv/guide/ProgramGuide.java @@ -22,7 +22,7 @@ import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; -import android.animation.ValueAnimator; +import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Point; @@ -42,6 +42,7 @@ import android.view.View.MeasureSpec; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; import com.android.tv.ChannelTuner; import com.android.tv.Features; @@ -54,7 +55,9 @@ import com.android.tv.data.ChannelDataManager; import com.android.tv.data.GenreItems; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter; +import com.android.tv.ui.ViewUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -89,6 +92,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { private final MainActivity mActivity; private final ProgramManager mProgramManager; + private final AccessibilityManager mAccessibilityManager; private final ChannelTuner mChannelTuner; private final Tracker mTracker; private final DurationTimer mVisibleDuration = new DurationTimer(); @@ -163,10 +167,11 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { public ProgramGuide(MainActivity activity, ChannelTuner channelTuner, TvInputManagerHelper tvInputManagerHelper, ChannelDataManager channelDataManager, ProgramDataManager programDataManager, @Nullable DvrDataManager dvrDataManager, - Tracker tracker, Runnable preShowRunnable, Runnable postHideRunnable) { + @Nullable DvrScheduleManager dvrScheduleManager, Tracker tracker, + Runnable preShowRunnable, Runnable postHideRunnable) { mActivity = activity; mProgramManager = new ProgramManager(tvInputManagerHelper, channelDataManager, - programDataManager, dvrDataManager); + programDataManager, dvrDataManager, dvrScheduleManager); mChannelTuner = channelTuner; mTracker = tracker; mPreShowRunnable = preShowRunnable; @@ -372,7 +377,10 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { mProgramTableFadeInAnimator.setTarget(mTable); mProgramTableFadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable)); mSharedPreference = PreferenceManager.getDefaultSharedPreferences(mActivity); - mShowGuidePartial = mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true); + mAccessibilityManager = + (AccessibilityManager) mActivity.getSystemService(Context.ACCESSIBILITY_SERVICE); + mShowGuidePartial = mAccessibilityManager.isEnabled() + || mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true); } private void updateGuidePosition() { @@ -604,7 +612,9 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } private void startFull() { - if (isFull()) { + if (isFull() || mAccessibilityManager.isEnabled()) { + // If accessibility service is enabled, focus cannot be moved to side panel due to it's + // hidden. Therefore, we don't hide side panel when accessibility service is enabled. return; } mShowGuidePartial = false; @@ -741,7 +751,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { View detailView = row.findViewById(R.id.detail); detailView.findViewById(R.id.detail_content_full).setAlpha(1); detailView.findViewById(R.id.detail_content_full).setTranslationY(0); - setLayoutHeight(detailView, mDetailHeight); + ViewUtils.setLayoutHeight(detailView, mDetailHeight); detailView.setVisibility(View.VISIBLE); final ProgramRow programRow = (ProgramRow) row.findViewById(R.id.row); @@ -783,8 +793,8 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { fadeOutAnimator.setDuration(mAnimationDuration); fadeOutAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(outDetailContent)); - Animator collapseAnimator = - createHeightAnimator(outDetail, getLayoutHeight(outDetail), 0); + Animator collapseAnimator = ViewUtils + .createHeightAnimator(outDetail, ViewUtils.getLayoutHeight(outDetail), 0); collapseAnimator.setStartDelay(mAnimationDuration); collapseAnimator.setDuration(mTableFadeAnimDuration); collapseAnimator.addListener(new AnimatorListenerAdapter() { @@ -815,7 +825,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { if (inDetail != null) { final View inDetailContent = inDetail.findViewById(R.id.detail_content_full); - Animator expandAnimator = createHeightAnimator(inDetail, 0, mDetailHeight); + Animator expandAnimator = ViewUtils.createHeightAnimator(inDetail, 0, mDetailHeight); expandAnimator.setStartDelay(mAnimationDuration); expandAnimator.setDuration(mTableFadeAnimDuration); expandAnimator.addListener(new AnimatorListenerAdapter() { @@ -830,17 +840,15 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { inDetailContent.setAlpha(0); } }); - Animator fadeInAnimator = ObjectAnimator.ofPropertyValuesHolder(inDetailContent, PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f), PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, direction * -mDetailPadding, 0f)); - fadeInAnimator.setStartDelay(mAnimationDuration + mTableFadeAnimDuration); fadeInAnimator.setDuration(mAnimationDuration); fadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(inDetailContent)); AnimatorSet inAnimator = new AnimatorSet(); - inAnimator.playTogether(expandAnimator, fadeInAnimator); + inAnimator.playSequentially(expandAnimator, fadeInAnimator); inAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animator) { @@ -852,41 +860,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } } - private Animator createHeightAnimator( - final View target, int initialHeight, int targetHeight) { - ValueAnimator animator = ValueAnimator.ofInt(initialHeight, targetHeight); - animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - int value = (Integer) animation.getAnimatedValue(); - if (value == 0) { - if (target.getVisibility() != View.GONE) { - target.setVisibility(View.GONE); - } - } else { - if (target.getVisibility() != View.VISIBLE) { - target.setVisibility(View.VISIBLE); - } - setLayoutHeight(target, value); - } - } - }); - return animator; - } - - private int getLayoutHeight(View view) { - LayoutParams layoutParams = view.getLayoutParams(); - return layoutParams.height; - } - - private void setLayoutHeight(View view, int height) { - LayoutParams layoutParams = view.getLayoutParams(); - if (height != layoutParams.height) { - layoutParams.height = height; - view.setLayoutParams(layoutParams); - } - } - private class GlobalFocusChangeListener implements ViewTreeObserver.OnGlobalFocusChangeListener { private static final int UNKNOWN = 0; diff --git a/src/com/android/tv/guide/ProgramItemView.java b/src/com/android/tv/guide/ProgramItemView.java index 172ee070..4c7a4404 100644 --- a/src/com/android/tv/guide/ProgramItemView.java +++ b/src/com/android/tv/guide/ProgramItemView.java @@ -16,6 +16,7 @@ package com.android.tv.guide; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; @@ -24,7 +25,6 @@ import android.graphics.drawable.LayerDrawable; import android.graphics.drawable.StateListDrawable; import android.os.Handler; import android.os.SystemClock; -import android.support.v4.os.BuildCompat; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; @@ -34,6 +34,7 @@ import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import android.widget.Toast; import com.android.tv.ApplicationSingletons; import com.android.tv.MainActivity; @@ -43,10 +44,10 @@ import com.android.tv.analytics.Tracker; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.ui.DvrDialogFragment; -import com.android.tv.dvr.ui.DvrRecordDeleteFragment; -import com.android.tv.dvr.ui.DvrRecordScheduleFragment; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; import com.android.tv.guide.ProgramManager.TableEntry; +import com.android.tv.util.ToastUtils; import com.android.tv.util.Utils; import java.lang.reflect.InvocationTargetException; @@ -66,11 +67,13 @@ public class ProgramItemView extends TextView { private static int sVisibleThreshold; private static int sItemPadding; + private static int sCompoundDrawablePadding; private static TextAppearanceSpan sProgramTitleStyle; private static TextAppearanceSpan sGrayedOutProgramTitleStyle; private static TextAppearanceSpan sEpisodeTitleStyle; private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; + private DvrManager mDvrManager; private TableEntry mTableEntry; private int mMaxWidthForRipple; private int mTextWidth; @@ -91,10 +94,9 @@ public class ProgramItemView extends TextView { ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext()); Tracker tracker = singletons.getTracker(); tracker.sendEpgItemClicked(); + final MainActivity tvActivity = (MainActivity) view.getContext(); + final Channel channel = tvActivity.getChannelDataManager().getChannel(entry.channelId); if (entry.isCurrentProgram()) { - final MainActivity tvActivity = (MainActivity) view.getContext(); - final Channel channel = tvActivity.getChannelDataManager() - .getChannel(entry.channelId); view.postDelayed(new Runnable() { @Override public void run() { @@ -104,37 +106,30 @@ public class ProgramItemView extends TextView { }, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0 : view.getResources() .getInteger(R.integer.program_guide_ripple_anim_duration)); - } else if (CommonFeatures.DVR.isEnabled(view.getContext()) && BuildCompat - .isAtLeastN()) { - final MainActivity tvActivity = (MainActivity) view.getContext(); - final DvrManager dvrManager = singletons.getDvrManager(); - final Channel channel = tvActivity.getChannelDataManager() - .getChannel(entry.channelId); - if (dvrManager.canRecord(channel.getInputId()) && entry.program != null) { + } else if (CommonFeatures.DVR.isEnabled(view.getContext())) { + DvrManager dvrManager = singletons.getDvrManager(); + if (entry.entryStartUtcMillis > System.currentTimeMillis() + && dvrManager.isProgramRecordable(entry.program)) { if (entry.scheduledRecording == null) { - showDvrDialog(view, entry); + if (DvrUiHelper.checkStorageStatusAndShowErrorMessage(tvActivity, + channel.getInputId()) + && DvrUiHelper.handleCreateSchedule(tvActivity, entry.program)) { + String msg = view.getContext().getString( + R.string.dvr_msg_program_scheduled, entry.program.getTitle()); + ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT); + } } else { - showRecordDeleteDialog(view, entry); + dvrManager.removeScheduledRecording(entry.scheduledRecording); + String msg = view.getResources().getString( + R.string.dvr_schedules_deletion_info, entry.program.getTitle()); + ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT); } + } else { + ToastUtils.show(view.getContext(), view.getResources() + .getString(R.string.dvr_msg_cannot_record_program), Toast.LENGTH_SHORT); } } } - - private void showDvrDialog(final View view, TableEntry entry) { - Utils.showToastMessageForDeveloperFeature(view.getContext()); - DvrRecordScheduleFragment dvrRecordScheduleFragment = - new DvrRecordScheduleFragment(entry); - DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(dvrRecordScheduleFragment); - ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment( - DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true); - } - - private void showRecordDeleteDialog(final View view, final TableEntry entry) { - DvrRecordDeleteFragment recordDeleteDialogFragment = new DvrRecordDeleteFragment(entry); - DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(recordDeleteDialogFragment); - ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment( - DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true); - } }; private static final View.OnFocusChangeListener ON_FOCUS_CHANGED = @@ -185,6 +180,7 @@ public class ProgramItemView extends TextView { super(context, attrs, defStyle); setOnClickListener(ON_CLICKED); setOnFocusChangeListener(ON_FOCUS_CHANGED); + mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); } private void initIfNeeded() { @@ -197,15 +193,18 @@ public class ProgramItemView extends TextView { R.dimen.program_guide_table_item_visible_threshold); sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding); - - ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res, - R.color.program_guide_table_item_program_title_text_color)); - ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res, - R.color.program_guide_table_item_grayed_out_program_text_color); - ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, - R.color.program_guide_table_item_program_episode_title_text_color)); - ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res, - R.color.program_guide_table_item_grayed_out_program_episode_title_text_color)); + sCompoundDrawablePadding = res.getDimensionPixelOffset( + R.dimen.program_guide_table_item_compound_drawable_padding); + + ColorStateList programTitleColor = ColorStateList.valueOf(res.getColor( + R.color.program_guide_table_item_program_title_text_color, null)); + ColorStateList grayedOutProgramTitleColor = res.getColorStateList( + R.color.program_guide_table_item_grayed_out_program_text_color, null); + ColorStateList episodeTitleColor = ColorStateList.valueOf(res.getColor( + R.color.program_guide_table_item_program_episode_title_text_color, null)); + ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(res.getColor( + R.color.program_guide_table_item_grayed_out_program_episode_title_text_color, + null)); int programTitleSize = res.getDimensionPixelSize( R.dimen.program_guide_table_item_program_title_font_size); int episodeTitleSize = res.getDimensionPixelSize( @@ -247,6 +246,7 @@ public class ProgramItemView extends TextView { return mTableEntry; } + @SuppressLint("SwitchIntDef") public void setValues(TableEntry entry, int selectedGenreId, long fromUtcMillis, long toUtcMillis, String gapTitle) { mTableEntry = entry; @@ -275,11 +275,6 @@ public class ProgramItemView extends TextView { if (TextUtils.isEmpty(title)) { title = getResources().getString(R.string.program_title_for_no_information); } - if (mTableEntry.scheduledRecording != null) { - //TODO(dvr): use a proper icon for UI status. - title = "®" + title; - } - SpannableStringBuilder description = new SpannableStringBuilder(); description.append(title); if (!TextUtils.isEmpty(episode)) { @@ -302,18 +297,48 @@ public class ProgramItemView extends TextView { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } setText(description); + + // Sets recording icons if needed. + int iconResId = 0; + if (mTableEntry.scheduledRecording != null) { + if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) { + iconResId = R.drawable.ic_warning_white_18dp; + } else { + switch (mTableEntry.scheduledRecording.getState()) { + case ScheduledRecording.STATE_RECORDING_NOT_STARTED: + iconResId = R.drawable.ic_scheduled_recording; + break; + case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: + iconResId = R.drawable.ic_recording_program; + break; + } + } + } + setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0); + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0); } measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd(); - int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis); - int guideStart = GuideUtils.convertMillisToPixel(fromUtcMillis); - layoutVisibleArea(guideStart - start); - // Maximum width for us to use a ripple mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis); } /** + * Update programItemView to handle alignments of text. + */ + public void updateVisibleArea() { + View parentView = ((View) getParent()); + if (parentView == null) { + return; + } + if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) { + layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight()); + } else { + layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft()); + } + } + + /** * Layout title and episode according to visible area. * * Here's the spec. @@ -322,19 +347,25 @@ public class ProgramItemView extends TextView { * but do not wrap text less than 30min. * 3. Episode title is visible only if title isn't multi-line. * - * @param offset Offset of the start position from the enclosing view's start position. + * @param startOffset Offset of the start position from the enclosing view's start position. + * @param endOffset Offset of the end position from the enclosing view's end position. */ - public void layoutVisibleArea(int offset) { + private void layoutVisibleArea(int startOffset, int endOffset) { int width = mTableEntry.getWidth(); - int startPadding = Math.max(0, offset); + int startPadding = Math.max(0, startOffset); + int endPadding = Math.max(0, endOffset); int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding); if (startPadding > 0 && width - startPadding < minWidth) { startPadding = Math.max(0, width - minWidth); } + if (endPadding > 0 && width - endPadding < minWidth) { + endPadding = Math.max(0, width - minWidth); + } - if (startPadding + sItemPadding != getPaddingStart()) { + if (startPadding + sItemPadding != getPaddingStart() + || endPadding + sItemPadding != getPaddingEnd()) { mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent. - setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0); + setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0); mPreventParentRelayout = false; } } diff --git a/src/com/android/tv/guide/ProgramManager.java b/src/com/android/tv/guide/ProgramManager.java index fe1a981f..e3d919df 100644 --- a/src/com/android/tv/guide/ProgramManager.java +++ b/src/com/android/tv/guide/ProgramManager.java @@ -27,6 +27,8 @@ import com.android.tv.data.GenreItems; import com.android.tv.data.Program; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.DvrScheduleManager.OnConflictStateChangeListener; import com.android.tv.dvr.ScheduledRecording; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -59,12 +61,12 @@ public class ProgramManager { private final ChannelDataManager mChannelDataManager; private final ProgramDataManager mProgramDataManager; private final DvrDataManager mDvrDataManager; // Only set if DVR is enabled + private final DvrScheduleManager mDvrScheduleManager; private long mStartUtcMillis; private long mEndUtcMillis; private long mFromUtcMillis; private long mToUtcMillis; - private Program mSelectedProgram; /** * Entry for program guide table. An "entry" can be either an actual program or a gap between @@ -177,27 +179,42 @@ public class ProgramManager { // Channel list after applying genre filter. // Should be matched with mSelectedGenreId always. private List<Channel> mFilteredChannels = mChannels; + private boolean mChannelDataLoaded; private final Set<Listener> mListeners = new ArraySet<>(); private final Set<TableEntriesUpdatedListener> mTableEntriesUpdatedListeners = new ArraySet<>(); private final Set<TableEntryChangedListener> mTableEntryChangedListeners = new ArraySet<>(); + private final DvrDataManager.OnDvrScheduleLoadFinishedListener mDvrLoadedListener = + new DvrDataManager.OnDvrScheduleLoadFinishedListener() { + @Override + public void onDvrScheduleLoadFinished() { + if (mChannelDataLoaded) { + for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) { + mScheduledRecordingListener.onScheduledRecordingAdded(r); + } + } + mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); + } + }; + private final ChannelDataManager.Listener mChannelDataManagerListener = new ChannelDataManager.Listener() { @Override public void onLoadFinished() { - updateChannels(true, false); + mChannelDataLoaded = true; + updateChannels(false); } @Override public void onChannelListUpdated() { - updateChannels(true, false); + updateChannels(false); } @Override public void onChannelBrowsableChanged() { - updateChannels(true, false); + updateChannels(false); } }; @@ -205,53 +222,75 @@ public class ProgramManager { new ProgramDataManager.Listener() { @Override public void onProgramUpdated() { - updateTableEntries(true, true); + updateTableEntries(true); } }; private final DvrDataManager.ScheduledRecordingListener mScheduledRecordingListener = new DvrDataManager.ScheduledRecordingListener() { @Override - public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) { - TableEntry oldEntry = getTableEntry(scheduledRecording); - if (oldEntry != null) { - TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, - scheduledRecording, oldEntry.entryStartUtcMillis, - oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); - updateEntry(oldEntry, newEntry); + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording schedule : scheduledRecordings) { + TableEntry oldEntry = getTableEntry(schedule); + if (oldEntry != null) { + TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, + schedule, oldEntry.entryStartUtcMillis, + oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); + updateEntry(oldEntry, newEntry); + } } } @Override - public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) { - TableEntry oldEntry = getTableEntry(scheduledRecording); - if (oldEntry != null) { - TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, null, - oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis, - oldEntry.isBlocked()); - updateEntry(oldEntry, newEntry); + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording schedule : scheduledRecordings) { + TableEntry oldEntry = getTableEntry(schedule); + if (oldEntry != null) { + TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, null, + oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis, + oldEntry.isBlocked()); + updateEntry(oldEntry, newEntry); + } } } @Override - public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) { - TableEntry oldEntry = getTableEntry(scheduledRecording); - if (oldEntry != null) { - TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, - scheduledRecording, oldEntry.entryStartUtcMillis, - oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); - updateEntry(oldEntry, newEntry); + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + for (ScheduledRecording schedule : scheduledRecordings) { + TableEntry oldEntry = getTableEntry(schedule); + if (oldEntry != null) { + TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, + schedule, oldEntry.entryStartUtcMillis, + oldEntry.entryEndUtcMillis, oldEntry.isBlocked()); + updateEntry(oldEntry, newEntry); + } } } }; + private final OnConflictStateChangeListener mOnConflictStateChangeListener = + new OnConflictStateChangeListener() { + @Override + public void onConflictStateChange(boolean conflict, + ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + TableEntry entry = getTableEntry(schedule); + if (entry != null) { + notifyTableEntryUpdated(entry); + } + } + } + }; + public ProgramManager(TvInputManagerHelper tvInputManagerHelper, ChannelDataManager channelDataManager, ProgramDataManager programDataManager, - @Nullable DvrDataManager dvrDataManager) { + @Nullable DvrDataManager dvrDataManager, + @Nullable DvrScheduleManager dvrScheduleManager) { mTvInputManagerHelper = tvInputManagerHelper; mChannelDataManager = channelDataManager; mProgramDataManager = programDataManager; mDvrDataManager = dvrDataManager; + mDvrScheduleManager = dvrScheduleManager; } public void programGuideVisibilityChanged(boolean visible) { @@ -260,14 +299,26 @@ public class ProgramManager { mChannelDataManager.addListener(mChannelDataManagerListener); mProgramDataManager.addListener(mProgramDataManagerListener); if (mDvrDataManager != null) { + if (!mDvrDataManager.isDvrScheduleLoadFinished()) { + mDvrDataManager.addDvrScheduleLoadFinishedListener(mDvrLoadedListener); + } mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener); } + if (mDvrScheduleManager != null) { + mDvrScheduleManager.addOnConflictStateChangeListener( + mOnConflictStateChangeListener); + } } else { mChannelDataManager.removeListener(mChannelDataManagerListener); mProgramDataManager.removeListener(mProgramDataManagerListener); if (mDvrDataManager != null) { + mDvrDataManager.removeDvrScheduleLoadFinishedListener(mDvrLoadedListener); mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); } + if (mDvrScheduleManager != null) { + mDvrScheduleManager.removeOnConflictStateChangeListener( + mOnConflictStateChangeListener); + } } } @@ -325,7 +376,7 @@ public class ProgramManager { mGenreChannelList.clear(); for (int i = 0; i < GenreItems.getGenreCount(); i++) { - mGenreChannelList.add(new ArrayList<Channel>()); + mGenreChannelList.add(new ArrayList<>()); } for (Channel channel : mChannels) { // TODO: Use programs in visible area instead of using current programs only. @@ -383,18 +434,16 @@ public class ProgramManager { // Note that This can be happens only if program guide isn't shown // because an user has to select channels as browsable through UI. - private void updateChannels(boolean notify, boolean clearPreviousTableEntries) { + private void updateChannels(boolean clearPreviousTableEntries) { if (DEBUG) Log.d(TAG, "updateChannels"); mChannels = mChannelDataManager.getBrowsableChannelList(); mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; mFilteredChannels = mChannels; - if (notify) { - notifyChannelsUpdated(); - } - updateTableEntries(notify, clearPreviousTableEntries); + notifyChannelsUpdated(); + updateTableEntries(clearPreviousTableEntries); } - private void updateTableEntries(boolean notify, boolean clear) { + private void updateTableEntries(boolean clear) { if (clear) { mChannelIdEntriesMap.clear(); } @@ -443,9 +492,7 @@ public class ProgramManager { } } - if (notify) { - notifyTableEntriesUpdated(); - } + notifyTableEntriesUpdated(); buildGenreFilters(); } @@ -528,7 +575,7 @@ public class ProgramManager { } mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis); - updateChannels(true, true); + updateChannels(true); setTimeRange(startUtcMillis, endUtcMillis); } @@ -643,20 +690,6 @@ public class ProgramManager { return entries; } - /** - * Get the currently selected channel. - */ - public Channel getSelectedChannel() { - return mChannelDataManager.getChannel(mSelectedProgram.getChannelId()); - } - - /** - * Get the currently selected program. - */ - public Program getSelectedProgram() { - return mSelectedProgram; - } - public interface Listener { void onGenresUpdated(); void onChannelsUpdated(); diff --git a/src/com/android/tv/guide/ProgramRow.java b/src/com/android/tv/guide/ProgramRow.java index 54b864db..2c98ab2d 100644 --- a/src/com/android/tv/guide/ProgramRow.java +++ b/src/com/android/tv/guide/ProgramRow.java @@ -22,8 +22,7 @@ import android.support.v7.widget.LinearLayoutManager; import android.util.AttributeSet; import android.util.Log; import android.view.View; -import android.view.ViewParent; -import android.view.ViewTreeObserver; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; import com.android.tv.data.Channel; import com.android.tv.guide.ProgramManager.TableEntry; @@ -45,25 +44,26 @@ public class ProgramRow extends TimelineGridView { interface ChildFocusListener { /** - * Is called after focus is moved. Only children to {@code ProgramRow} will be passed. + * Is called after focus is moved. It used {@link ChildFocusListener#isChild} to decide if + * old and new focuses are listener's children. * See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}. */ void onChildFocus(View oldFocus, View newFocus); } - private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener = - new ViewTreeObserver.OnGlobalFocusChangeListener() { - @Override - public void onGlobalFocusChanged(View oldFocus, View newFocus) { - updateCurrentFocus(oldFocus, newFocus); - } - }; - /** * Used only for debugging. */ private Channel mChannel; + private final OnGlobalLayoutListener mLayoutListener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getViewTreeObserver().removeOnGlobalLayoutListener(this); + updateChildVisibleArea(); + } + }; + public ProgramRow(Context context) { this(context, null); } @@ -84,21 +84,25 @@ public class ProgramRow extends TimelineGridView { } @Override + public void onViewAdded(View child) { + super.onViewAdded(child); + ProgramItemView itemView = (ProgramItemView) child; + if (getLeft() <= itemView.getRight() && itemView.getLeft() <= getRight()) { + itemView.updateVisibleArea(); + } + } + + @Override public void onScrolled(int dx, int dy) { + // Remove callback to prevent updateChildVisibleArea being called twice. + getViewTreeObserver().removeOnGlobalLayoutListener(mLayoutListener); super.onScrolled(dx, dy); - int childCount = getChildCount(); if (DEBUG) { Log.d(TAG, "onScrolled by " + dx); - Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + childCount); + Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + getChildCount()); Log.d(TAG, "ProgramRow {" + Utils.toRectString(this) + "}"); } - for (int i = 0; i < childCount; ++i) { - ProgramItemView child = (ProgramItemView) getChildAt(i); - if (getLeft() <= child.getRight() && child.getLeft() <= getRight()) { - child.layoutVisibleArea(getLayoutDirection() == LAYOUT_DIRECTION_LTR - ? getLeft() - child.getLeft() : child.getRight() - getRight()); - } - } + updateChildVisibleArea(); } /** @@ -109,29 +113,9 @@ public class ProgramRow extends TimelineGridView { if (currentProgram == null) { currentProgram = getChildAt(0); } - updateCurrentFocus(null, currentProgram); - } - - private void updateCurrentFocus(View oldFocus, View newFocus) { - if (mChildFocusListener == null) { - return; - } - - mChildFocusListener.onChildFocus(isChild(oldFocus) ? oldFocus : null, - isChild(newFocus) ? newFocus : null); - } - - private boolean isChild(View view) { - if (view == null) { - return false; + if (mChildFocusListener != null) { + mChildFocusListener.onChildFocus(null, currentProgram); } - - for (ViewParent p = view.getParent(); p != null; p = p.getParent()) { - if (p == this) { - return true; - } - } - return false; } // Call this API after RTL is resolved. (i.e. View is measured.) @@ -160,7 +144,7 @@ public class ProgramRow extends TimelineGridView { return focused; } } else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { - if (focusedEntry.entryEndUtcMillis > toMillis + ONE_HOUR_MILLIS) { + if (focusedEntry.entryEndUtcMillis >= toMillis + ONE_HOUR_MILLIS) { // The current entry ends outside of the view; Scroll to the right. scrollByTime(ONE_HOUR_MILLIS); return focused; @@ -172,7 +156,7 @@ public class ProgramRow extends TimelineGridView { if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) { if (focusedEntry.entryEndUtcMillis != toMillis) { // The focused entry is the last entry; Align to the right edge. - scrollByTime(focusedEntry.entryEndUtcMillis - mProgramManager.getToUtcMillis()); + scrollByTime(focusedEntry.entryEndUtcMillis - toMillis); return focused; } } @@ -208,23 +192,21 @@ public class ProgramRow extends TimelineGridView { } @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - getViewTreeObserver().addOnGlobalFocusChangeListener(mGlobalFocusChangeListener); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - getViewTreeObserver().removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener); - } - - @Override public void onChildDetachedFromWindow(View child) { if (child.hasFocus()) { // Focused view can be detached only if it's updated. TableEntry entry = ((ProgramItemView) child).getTableEntry(); - if (entry.isCurrentProgram()) { + if (entry.program == null) { + // The focus is lost due to information loaded. Requests focus immediately. + // (Because this entry is detached after real entries attached, we can't take + // the below approach to resume focus on entry being attached.) + post(new Runnable() { + @Override + public void run() { + requestFocus(); + } + }); + } else if (entry.isCurrentProgram()) { if (DEBUG) Log.d(TAG, "Keep focus to the current program"); // Current program is visible in the guide. // Updated entries including current program's will be attached again soon @@ -242,13 +224,13 @@ public class ProgramRow extends TimelineGridView { if (mKeepFocusToCurrentProgram) { TableEntry entry = ((ProgramItemView) child).getTableEntry(); if (entry.isCurrentProgram()) { + mKeepFocusToCurrentProgram = false; post(new Runnable() { @Override public void run() { requestFocus(); } }); - mKeepFocusToCurrentProgram = false; } } } @@ -316,6 +298,22 @@ public class ProgramRow extends TimelineGridView { mProgramManager.getStartTime(), entry.entryStartUtcMillis) - scrollOffset; ((LinearLayoutManager) getLayoutManager()) .scrollToPositionWithOffset(position, offset); + // Workaround to b/31598505. When a program's duration is too long, + // RecyclerView.onScrolled() will not be called after scrollToPositionWithOffset(). + // Therefore we have to update children's visible areas by ourselves in theis case. + // Since scrollToPositionWithOffset() will call requestLayout(), we can listen to this + // behavior to ensure program items' visible areas are correctly updated after layouts + // are adjusted, i.e., scrolling is over. + getViewTreeObserver().addOnGlobalLayoutListener(mLayoutListener); + } + } + + private void updateChildVisibleArea() { + for (int i = 0; i < getChildCount(); ++i) { + ProgramItemView child = (ProgramItemView) getChildAt(i); + if (getLeft() < child.getRight() && child.getLeft() < getRight()) { + child.updateVisibleArea(); + } } } } diff --git a/src/com/android/tv/guide/ProgramTableAdapter.java b/src/com/android/tv/guide/ProgramTableAdapter.java index 83755b5f..e4a67972 100644 --- a/src/com/android/tv/guide/ProgramTableAdapter.java +++ b/src/com/android/tv/guide/ProgramTableAdapter.java @@ -32,6 +32,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.RecycledViewPool; +import android.text.Html; import android.text.Spannable; import android.text.SpannableString; import android.text.TextUtils; @@ -41,13 +42,22 @@ import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.TextView; import com.android.tv.R; import com.android.tv.TvApplication; +import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; import com.android.tv.data.Program; +import com.android.tv.data.Program.CriticScore; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.ScheduledRecording; import com.android.tv.guide.ProgramManager.TableEntriesUpdatedListener; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter; @@ -70,11 +80,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte private final Context mContext; private final TvInputManagerHelper mTvInputManagerHelper; + private final DvrManager mDvrManager; + private final DvrDataManager mDvrDataManager; private final ProgramManager mProgramManager; + private final AccessibilityManager mAccessibilityManager; private final ProgramGuide mProgramGuide; private final Handler mHandler = new Handler(); private final List<ProgramListAdapter> mProgramListAdapters = new ArrayList<>(); private final RecycledViewPool mRecycledViewPool; + // views to be be reused when displaying critic scores + private final List<LinearLayout> mCriticScoreViews; private final int mChannelLogoWidth; private final int mChannelLogoHeight; @@ -89,11 +104,27 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte private final int mAnimationDuration; private final int mDetailPadding; private final TextAppearanceSpan mEpisodeTitleStyle; + private final String mProgramRecordableText; + private final String mRecordingScheduledText; + private final String mRecordingConflictText; + private final String mRecordingFailedText; + private final String mRecordingInProgressText; + private final int mDvrPaddingStartWithTrack; + private final int mDvrPaddingStartWithOutTrack; public ProgramTableAdapter(Context context, ProgramManager programManager, ProgramGuide programGuide) { mContext = context; + mAccessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); mTvInputManagerHelper = TvApplication.getSingletons(context).getTvInputManagerHelper(); + if (CommonFeatures.DVR.isEnabled(context)) { + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + } else { + mDvrManager = null; + mDvrDataManager = null; + } mProgramManager = programManager; mProgramGuide = programGuide; @@ -110,26 +141,36 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte R.string.program_title_for_no_information); mProgramTitleForBlockedChannel = res.getString( R.string.program_title_for_blocked_channel); - mChannelTextColor = Utils.getColor(res, - R.color.program_guide_table_header_column_channel_number_text_color); - mChannelBlockedTextColor = Utils.getColor(res, - R.color.program_guide_table_header_column_channel_number_blocked_text_color); - mDetailTextColor = Utils.getColor(res, - R.color.program_guide_table_detail_title_text_color); - mDetailGrayedTextColor = Utils.getColor(res, - R.color.program_guide_table_detail_title_grayed_text_color); + mChannelTextColor = res.getColor( + R.color.program_guide_table_header_column_channel_number_text_color, null); + mChannelBlockedTextColor = res.getColor( + R.color.program_guide_table_header_column_channel_number_blocked_text_color, null); + mDetailTextColor = res.getColor( + R.color.program_guide_table_detail_title_text_color, null); + mDetailGrayedTextColor = res.getColor( + R.color.program_guide_table_detail_title_grayed_text_color, null); mAnimationDuration = res.getInteger(R.integer.program_guide_table_detail_fade_anim_duration); mDetailPadding = res.getDimensionPixelOffset( R.dimen.program_guide_table_detail_padding); + mProgramRecordableText = res.getString(R.string.dvr_epg_program_recordable); + mRecordingScheduledText = res.getString(R.string.dvr_epg_program_recording_scheduled); + mRecordingConflictText = res.getString(R.string.dvr_epg_program_recording_conflict); + mRecordingFailedText = res.getString(R.string.dvr_epg_program_recording_failed); + mRecordingInProgressText = res.getString(R.string.dvr_epg_program_recording_in_progress); + mDvrPaddingStartWithTrack = res.getDimensionPixelOffset( + R.dimen.program_guide_table_detail_dvr_margin_start); + mDvrPaddingStartWithOutTrack = res.getDimensionPixelOffset( + R.dimen.program_guide_table_detail_dvr_margin_start_without_track); int episodeTitleSize = res.getDimensionPixelSize( R.dimen.program_guide_table_detail_episode_title_text_size); ColorStateList episodeTitleColor = ColorStateList.valueOf( - Utils.getColor(res, R.color.program_guide_table_detail_episode_title_text_color)); + res.getColor(R.color.program_guide_table_detail_episode_title_text_color, null)); mEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize, episodeTitleColor, null); + mCriticScoreViews = new ArrayList<>(); mRecycledViewPool = new RecycledViewPool(); mRecycledViewPool.setMaxRecycledViews(R.layout.program_guide_table_item, context.getResources().getInteger( @@ -137,12 +178,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mProgramManager.addListener(new ProgramManager.ListenerAdapter() { @Override public void onChannelsUpdated() { - mHandler.post(new Runnable() { - @Override - public void run() { - update(); - } - }); + update(); } }); update(); @@ -180,6 +216,15 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } @Override + public void onBindViewHolder(ProgramRowHolder holder, int position, List<Object> payloads) { + if (!payloads.isEmpty()) { + holder.updateDetailView(); + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + + @Override public ProgramRowHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); ProgramRow programRow = (ProgramRow) itemView.findViewById(R.id.row); @@ -193,6 +238,17 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte int pos = mProgramManager.getProgramIdIndex(tableEntry.channelId, tableEntry.getId()); if (DEBUG) Log.d(TAG, "update(" + channelIndex + ", " + pos + ")"); mProgramListAdapters.get(channelIndex).notifyItemChanged(pos, tableEntry); + notifyItemChanged(channelIndex, true); + } + + @Override + public void onViewAttachedToWindow(ProgramRowHolder holder) { + holder.onAttachedToWindow(); + } + + @Override + public void onViewDetachedFromWindow(ProgramRowHolder holder) { + holder.onDetachedFromWindow(); } // TODO: make it static @@ -222,15 +278,29 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } }; + private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener = + new ViewTreeObserver.OnGlobalFocusChangeListener() { + @Override + public void onGlobalFocusChanged(View oldFocus, View newFocus) { + onChildFocus(isChild(oldFocus) ? oldFocus : null, + isChild(newFocus) ? newFocus : null); + } + }; + // Members of Program Details private final ViewGroup mDetailView; private final ImageView mImageView; private final ImageView mBlockView; private final TextView mTitleView; private final TextView mTimeView; + private final LinearLayout mCriticScoresLayout; private final TextView mDescriptionView; private final TextView mAspectRatioView; private final TextView mResolutionView; + private final ImageView mDvrIconView; + private final TextView mDvrTextIconView; + private final TextView mDvrStatusView; + private final ViewGroup mDvrIndicator; // Members of Channel Header private Channel mChannel; @@ -257,6 +327,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mDescriptionView = (TextView) mDetailView.findViewById(R.id.desc); mAspectRatioView = (TextView) mDetailView.findViewById(R.id.aspect_ratio); mResolutionView = (TextView) mDetailView.findViewById(R.id.resolution); + mDvrIconView = (ImageView) mDetailView.findViewById(R.id.dvr_icon); + mDvrTextIconView = (TextView) mDetailView.findViewById(R.id.dvr_text_icon); + mDvrStatusView = (TextView) mDetailView.findViewById(R.id.dvr_status); + mDvrIndicator = (ViewGroup) mContainer.findViewById(R.id.dvr_indicator); + mCriticScoresLayout = (LinearLayout) mDetailView.findViewById(R.id.critic_scores); mChannelHeaderView = mContainer.findViewById(R.id.header_column); mChannelNumberView = (TextView) mContainer.findViewById(R.id.channel_number); @@ -264,6 +339,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mChannelLogoView = (ImageView) mContainer.findViewById(R.id.channel_logo); mChannelBlockView = (ImageView) mContainer.findViewById(R.id.channel_block); mInputLogoView = (ImageView) mContainer.findViewById(R.id.input_logo); + mDetailView.setFocusable(mAccessibilityManager.isEnabled()); + mChannelHeaderView.setFocusable(mAccessibilityManager.isEnabled()); + mAccessibilityManager.addAccessibilityStateChangeListener( + new AccessibilityManager.AccessibilityStateChangeListener() { + @Override + public void onAccessibilityStateChanged(boolean enable) { + mDetailView.setFocusable(enable); + mChannelHeaderView.setFocusable(enable); + } + }); } public void onBind(int position) { @@ -331,12 +416,32 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } } + public boolean isChild(View view) { + if (view == null) { + return false; + } + for (ViewParent p = view.getParent(); p != null; p = p.getParent()) { + if (p == mContainer) { + return true; + } + } + return false; + } + @Override public void onChildFocus(View oldFocus, View newFocus) { if (newFocus == null) { return; } - mSelectedEntry = ((ProgramItemView) newFocus).getTableEntry(); + // When the accessibility service is enabled, focus might be put on channel's header or + // detail view, besides program items. + if (newFocus == mChannelHeaderView) { + mSelectedEntry = ((ProgramItemView) mProgramRow.getChildAt(0)).getTableEntry(); + } else if (newFocus == mDetailView) { + return; + } else { + mSelectedEntry = ((ProgramItemView) newFocus).getTableEntry(); + } if (oldFocus == null) { updateDetailView(); return; @@ -403,7 +508,23 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte }); } + private void onAttachedToWindow() { + mContainer.getViewTreeObserver() + .addOnGlobalFocusChangeListener(mGlobalFocusChangeListener); + } + + private void onDetachedFromWindow() { + mContainer.getViewTreeObserver() + .removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener); + } + private void updateDetailView() { + if (mSelectedEntry == null) { + // The view holder is never on focus before. + return; + } + if (DEBUG) Log.d(TAG, "updateDetailView"); + mCriticScoresLayout.removeAllViews(); if (Program.isValid(mSelectedEntry.program)) { mTitleView.setTextColor(mDetailTextColor); Context context = itemView.getContext(); @@ -417,11 +538,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte createProgramPosterArtCallback(this, program)); } - if (TextUtils.isEmpty(program.getEpisodeTitle())) { + String episodeTitle = program.getEpisodeDisplayTitle(mContext); + if (TextUtils.isEmpty(episodeTitle)) { mTitleView.setText(program.getTitle()); } else { String title = program.getTitle(); - String episodeTitle = program.getEpisodeDisplayTitle(mContext); String fullTitle = title + " " + episodeTitle; SpannableString text = new SpannableString(fullTitle); @@ -435,6 +556,65 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis(), false)); + boolean trackMetaDataVisible = false; + trackMetaDataVisible |= + updateTextView(mAspectRatioView, Utils.getAspectRatioString( + program.getVideoWidth(), program.getVideoHeight())); + + int videoDefinitionLevel = Utils.getVideoDefinitionLevelFromSize( + program.getVideoWidth(), program.getVideoHeight()); + trackMetaDataVisible |= + updateTextView(mResolutionView, Utils.getVideoDefinitionLevelString( + context, videoDefinitionLevel)); + + if (mDvrManager != null && mDvrManager.isProgramRecordable(program)) { + ScheduledRecording scheduledRecording = + mDvrDataManager.getScheduledRecordingForProgramId(program.getId()); + String statusText = mProgramRecordableText; + int iconResId = 0; + if (scheduledRecording != null) { + if (mDvrManager.isConflicting(scheduledRecording)) { + iconResId = R.drawable.ic_warning_white_12dp; + statusText = mRecordingConflictText; + } else { + switch (scheduledRecording.getState()) { + case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: + iconResId = R.drawable.ic_recording_program; + statusText = mRecordingInProgressText; + break; + case ScheduledRecording.STATE_RECORDING_NOT_STARTED: + iconResId = R.drawable.ic_scheduled_white; + statusText = mRecordingScheduledText; + break; + case ScheduledRecording.STATE_RECORDING_FAILED: + iconResId = R.drawable.ic_warning_white_12dp; + statusText = mRecordingFailedText; + break; + default: + iconResId = 0; + } + } + } + if (iconResId == 0) { + mDvrIconView.setVisibility(View.GONE); + mDvrTextIconView.setVisibility(View.VISIBLE); + } else { + mDvrTextIconView.setVisibility(View.GONE); + mDvrIconView.setImageResource(iconResId); + mDvrIconView.setVisibility(View.VISIBLE); + } + if (!trackMetaDataVisible) { + mDvrIndicator.setPaddingRelative(mDvrPaddingStartWithOutTrack, 0, 0, 0); + } else { + mDvrIndicator.setPaddingRelative(mDvrPaddingStartWithTrack, 0, 0, 0); + } + mDvrIndicator.setVisibility(View.VISIBLE); + mDvrStatusView.setText(statusText); + } else { + mDvrIndicator.setVisibility(View.GONE); + } + + if (blockedRating == null) { mBlockView.setVisibility(View.GONE); updateTextView(mDescriptionView, program.getDescription()); @@ -442,14 +622,6 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mBlockView.setVisibility(View.VISIBLE); updateTextView(mDescriptionView, getBlockedDescription(blockedRating)); } - - updateTextView(mAspectRatioView, Utils.getAspectRatioString( - program.getVideoWidth(), program.getVideoHeight())); - - int videoDefinitionLevel = Utils.getVideoDefinitionLevelFromSize( - program.getVideoWidth(), program.getVideoHeight()); - updateTextView(mResolutionView, Utils.getVideoDefinitionLevelString( - context, videoDefinitionLevel)); } else { mTitleView.setTextColor(mDetailGrayedTextColor); if (mSelectedEntry.isBlocked()) { @@ -460,6 +632,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mImageView.setVisibility(View.GONE); mBlockView.setVisibility(View.GONE); mTimeView.setVisibility(View.GONE); + mDvrIndicator.setVisibility(View.GONE); mDescriptionView.setVisibility(View.GONE); mAspectRatioView.setVisibility(View.GONE); mResolutionView.setVisibility(View.GONE); @@ -526,12 +699,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } } - private void updateTextView(TextView textView, String text) { + // The return value of this method will indicate the target view is visible (true) + // or gone (false). + private boolean updateTextView(TextView textView, String text) { if (!TextUtils.isEmpty(text)) { textView.setVisibility(View.VISIBLE); textView.setText(text); + return true; } else { textView.setVisibility(View.GONE); + return false; } } @@ -554,6 +731,26 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mInputLogoView.setVisibility(View.VISIBLE); } + private void updateCriticScoreView(ProgramRowHolder holder, final long programId, + CriticScore criticScore, View view) { + TextView criticScoreSource = (TextView) view.findViewById(R.id.critic_score_source); + TextView criticScoreText = (TextView) view.findViewById(R.id.critic_score_score); + ImageView criticScoreLogo = (ImageView) view.findViewById(R.id.critic_score_logo); + + //set the appropriate information in the views + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + criticScoreSource.setText(Html.fromHtml(criticScore.source, + Html.FROM_HTML_MODE_LEGACY)); + } else { + criticScoreSource.setText(Html.fromHtml(criticScore.source)); + } + criticScoreText.setText(criticScore.score); + criticScoreSource.setVisibility(View.VISIBLE); + criticScoreText.setVisibility(View.VISIBLE); + ImageLoader.loadBitmap(mContext, criticScore.logoUrl, + createCriticScoreLogoCallback(holder, programId, criticScoreLogo)); + } + private void onHorizontalScrolled() { if (mDetailInAnimator != null) { mHandler.removeCallbacks(mDetailInStarter); @@ -562,6 +759,23 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } } + private static ImageLoaderCallback<ProgramRowHolder> createCriticScoreLogoCallback( + ProgramRowHolder holder, final long programId, ImageView logoView) { + return new ImageLoaderCallback<ProgramRowHolder>(holder) { + @Override + public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logoImage) { + if (logoImage == null || holder.mSelectedEntry == null + || holder.mSelectedEntry.program == null + || holder.mSelectedEntry.program.getId() != programId) { + logoView.setVisibility(View.GONE); + } else { + logoView.setImageBitmap(logoImage); + logoView.setVisibility(View.VISIBLE); + } + } + }; + } + private static ImageLoaderCallback<ProgramRowHolder> createProgramPosterArtCallback( ProgramRowHolder holder, final Program program) { return new ImageLoaderCallback<ProgramRowHolder>(holder) { @@ -599,7 +813,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte return new ImageLoaderCallback<ProgramRowHolder>(holder) { @Override public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logo) { - if (logo != null && info.getId() + if (logo != null && holder.mChannel != null && info.getId() .equals(holder.mChannel.getInputId())) { holder.updateInputLogoInternal(logo); } diff --git a/src/com/android/tv/menu/ActionCardView.java b/src/com/android/tv/menu/ActionCardView.java index 2d72b06f..54892cac 100644 --- a/src/com/android/tv/menu/ActionCardView.java +++ b/src/com/android/tv/menu/ActionCardView.java @@ -69,11 +69,13 @@ public class ActionCardView extends FrameLayout implements ItemListRowView.CardV mStateView.setText(action.getActionDescription(getContext())); if (action.isEnabled()) { setEnabled(true); + setFocusable(true); mIconView.setAlpha(OPACITY_ENABLED); mLabelView.setAlpha(OPACITY_ENABLED); mStateView.setAlpha(OPACITY_ENABLED); } else { setEnabled(false); + setFocusable(false); mIconView.setAlpha(OPACITY_DISABLED); mLabelView.setAlpha(OPACITY_DISABLED); mStateView.setAlpha(OPACITY_DISABLED); diff --git a/src/com/android/tv/menu/AppLinkCardView.java b/src/com/android/tv/menu/AppLinkCardView.java index 74375da4..bfb5e3f1 100644 --- a/src/com/android/tv/menu/AppLinkCardView.java +++ b/src/com/android/tv/menu/AppLinkCardView.java @@ -30,7 +30,6 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.view.View; -import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; @@ -40,7 +39,6 @@ import com.android.tv.data.Channel; import com.android.tv.util.BitmapUtils; import com.android.tv.util.ImageLoader; import com.android.tv.util.TvInputManagerHelper; -import com.android.tv.util.Utils; /** * A view to render an app link card. @@ -49,10 +47,6 @@ public class AppLinkCardView extends BaseCardView<Channel> { private static final String TAG = MenuView.TAG; private static final boolean DEBUG = MenuView.DEBUG; - private final float mCardHeight; - private final float mExtendedCardHeight; - private final float mTextViewHeight; - private final float mExtendedTextViewCardHeight; private final int mCardImageWidth; private final int mCardImageHeight; private final int mIconWidth; @@ -63,12 +57,9 @@ public class AppLinkCardView extends BaseCardView<Channel> { private ImageView mImageView; private View mGradientView; private TextView mAppInfoView; - private TextView mMetaViewFocused; - private TextView mMetaViewUnfocused; private View mMetaViewHolder; private Channel mChannel; private Intent mIntent; - private boolean mExtendViewOnFocus; private final PackageManager mPackageManager; private final TvInputManagerHelper mTvInputManagerHelper; @@ -85,19 +76,12 @@ public class AppLinkCardView extends BaseCardView<Channel> { mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width); mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.card_image_layout_height); - mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height); - mExtendedCardHeight = getResources().getDimensionPixelOffset( - R.dimen.card_layout_height_extended); mIconWidth = getResources().getDimensionPixelSize(R.dimen.app_link_card_icon_width); mIconHeight = getResources().getDimensionPixelSize(R.dimen.app_link_card_icon_height); mIconPadding = getResources().getDimensionPixelOffset(R.dimen.app_link_card_icon_padding); mPackageManager = context.getPackageManager(); mTvInputManagerHelper = ((MainActivity) context).getTvInputManagerHelper(); - mTextViewHeight = getResources().getDimensionPixelSize( - R.dimen.card_meta_layout_height); - mExtendedTextViewCardHeight = getResources().getDimensionPixelOffset( - R.dimen.card_meta_layout_height_extended); - mIconColorFilter = Utils.getColor(getResources(), R.color.app_link_card_icon_color_filter); + mIconColorFilter = getResources().getColor(R.color.app_link_card_icon_color_filter, null); } /** @@ -120,7 +104,7 @@ public class AppLinkCardView extends BaseCardView<Channel> { switch (linkType) { case Channel.APP_LINK_TYPE_CHANNEL: - setMetaViewText(mChannel.getAppLinkText()); + setText(mChannel.getAppLinkText()); mAppInfoView.setVisibility(VISIBLE); mGradientView.setVisibility(VISIBLE); mAppInfoView.setCompoundDrawablePadding(mIconPadding); @@ -138,7 +122,7 @@ public class AppLinkCardView extends BaseCardView<Channel> { } break; case Channel.APP_LINK_TYPE_APP: - setMetaViewText(getContext().getString( + setText(getContext().getString( R.string.channels_item_app_link_app_launcher, mPackageManager.getApplicationLabel(appInfo))); mAppInfoView.setVisibility(GONE); @@ -164,17 +148,8 @@ public class AppLinkCardView extends BaseCardView<Channel> { } else { setCardImageWithBanner(appInfo); } - - mMetaViewFocused.measure(MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); - mExtendViewOnFocus = mMetaViewFocused.getLineCount() > 1; - if (mExtendViewOnFocus) { - setMetaViewFocusedAlpha(selected ? 1f : 0f); - } else { - setMetaViewFocusedAlpha(1f); - } - - // Call super.onBind() at the end in order to make getCardHeight() return a proper value. + // Call super.onBind() at the end intentionally. In order to correctly handle extension of + // text view, text should be set before calling super.onBind. super.onBind(channel, selected); } @@ -228,32 +203,6 @@ public class AppLinkCardView extends BaseCardView<Channel> { mGradientView = findViewById(R.id.image_gradient); mAppInfoView = (TextView) findViewById(R.id.app_info); mMetaViewHolder = findViewById(R.id.app_link_text_holder); - mMetaViewFocused = (TextView) findViewById(R.id.app_link_text_focused); - mMetaViewUnfocused = (TextView) findViewById(R.id.app_link_text_unfocused); - } - - @Override - protected void onFocusAnimationStart(boolean selected) { - if (mExtendViewOnFocus) { - setMetaViewFocusedAlpha(selected ? 1f : 0f); - } - } - - @Override - protected void onSetFocusAnimatedValue(float animatedValue) { - super.onSetFocusAnimatedValue(animatedValue); - if (mExtendViewOnFocus) { - ViewGroup.LayoutParams params = mMetaViewUnfocused.getLayoutParams(); - params.height = Math.round(mTextViewHeight - + (mExtendedTextViewCardHeight - mTextViewHeight) * animatedValue); - setMetaViewLayoutParams(params); - setMetaViewFocusedAlpha(animatedValue); - } - } - - @Override - protected float getCardHeight() { - return (mExtendViewOnFocus && isFocused()) ? mExtendedCardHeight : mCardHeight; } // Try to set the card image with following order: @@ -302,23 +251,8 @@ public class AppLinkCardView extends BaseCardView<Channel> { @Override public void onGenerated(Palette palette) { mMetaViewHolder.setBackgroundColor(palette.getDarkVibrantColor( - Utils.getColor(getResources(), R.color.channel_card_meta_background))); + getResources().getColor(R.color.channel_card_meta_background, null))); } }); } - - private void setMetaViewLayoutParams(ViewGroup.LayoutParams params) { - mMetaViewFocused.setLayoutParams(params); - mMetaViewUnfocused.setLayoutParams(params); - } - - private void setMetaViewText(String text) { - mMetaViewFocused.setText(text); - mMetaViewUnfocused.setText(text); - } - - private void setMetaViewFocusedAlpha(float focusedAlpha) { - mMetaViewFocused.setAlpha(focusedAlpha); - mMetaViewUnfocused.setAlpha(1f - focusedAlpha); - } } diff --git a/src/com/android/tv/menu/BaseCardView.java b/src/com/android/tv/menu/BaseCardView.java index b4500dd1..c6a34a5d 100644 --- a/src/com/android/tv/menu/BaseCardView.java +++ b/src/com/android/tv/menu/BaseCardView.java @@ -21,10 +21,13 @@ import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Outline; +import android.support.annotation.Nullable; import android.util.AttributeSet; import android.view.View; +import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.widget.LinearLayout; +import android.widget.TextView; import com.android.tv.R; @@ -44,6 +47,16 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo private final float mVerticalCardMargin; private final float mCardCornerRadius; private float mFocusAnimatedValue; + private boolean mExtendViewOnFocus; + private final float mExtendedCardHeight; + private final float mTextViewHeight; + private final float mExtendedTextViewHeight; + @Nullable + private TextView mTextView; + @Nullable + private TextView mTextViewFocused; + private final int mCardImageWidth; + private final float mCardHeight; public BaseCardView(Context context) { this(context, null); @@ -72,16 +85,42 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCardCornerRadius); } }); + mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width); + mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height); + mExtendedCardHeight = getResources().getDimensionPixelSize( + R.dimen.card_layout_height_extended); + mTextViewHeight = getResources().getDimensionPixelSize(R.dimen.card_meta_layout_height); + mExtendedTextViewHeight = getResources().getDimensionPixelOffset( + R.dimen.card_meta_layout_height_extended); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTextView = (TextView) findViewById(R.id.card_text); + mTextViewFocused = (TextView) findViewById(R.id.card_text_focused); } /** * Called when the view is displayed. + * + * Before onBind is called, this view's text should be set to determine if it'll be extended + * or not in focus state. */ @Override public void onBind(T item, boolean selected) { - // Note that getCardHeight() will be called by setFocusAnimatedValue(). - // Therefore, be sure that getCardHeight() has a proper value before this method is called. - setFocusAnimatedValue(selected ? SCALE_FACTOR_1F : SCALE_FACTOR_0F); + if (mTextView != null && mTextViewFocused != null) { + mTextViewFocused.measure( + MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mExtendViewOnFocus = mTextViewFocused.getLineCount() > 1; + if (mExtendViewOnFocus) { + setTextViewFocusedAlpha(selected ? 1f : 0f); + } else { + setTextViewFocusedAlpha(1f); + } + } + setFocusAnimatedValue(selected ? SCALE_FACTOR_1F : SCALE_FACTOR_0F); } @Override @@ -108,10 +147,48 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo } /** + * Sets text of this card view. + */ + public void setText(int resId) { + if (mTextViewFocused != null) { + mTextViewFocused.setText(resId); + } + if (mTextView != null) { + mTextView.setText(resId); + } + } + + /** + * Sets text of this card view. + */ + public void setText(String text) { + if (mTextViewFocused != null) { + mTextViewFocused.setText(text); + } + if (mTextView != null) { + mTextView.setText(text); + } + } + + /** + * Enables or disables text view of this card view. + */ + public void setTextViewEnabled(boolean enabled) { + if (mTextViewFocused != null) { + mTextViewFocused.setEnabled(enabled); + } + if (mTextView != null) { + mTextView.setEnabled(enabled); + } + } + + /** * Called when the focus animation started. */ protected void onFocusAnimationStart(boolean selected) { - // do nothing. + if (mExtendViewOnFocus) { + setTextViewFocusedAlpha(selected ? 1f : 0f); + } } /** @@ -126,10 +203,19 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo * between {@code SCALE_FACTOR_0F} and {@code SCALE_FACTOR_1F}. */ protected void onSetFocusAnimatedValue(float animatedValue) { - float scale = 1f + (mVerticalCardMargin / getCardHeight()) * animatedValue; + float cardViewHeight = (mExtendViewOnFocus && isFocused()) + ? mExtendedCardHeight : mCardHeight; + float scale = 1f + (mVerticalCardMargin / cardViewHeight) * animatedValue; setScaleX(scale); setScaleY(scale); setTranslationZ(mFocusTranslationZ * animatedValue); + if (mExtendViewOnFocus) { + ViewGroup.LayoutParams params = mTextView.getLayoutParams(); + params.height = Math.round(mTextViewHeight + + (mExtendedTextViewHeight - mTextViewHeight) * animatedValue); + setTextViewLayoutParams(params); + setTextViewFocusedAlpha(animatedValue); + } } private void setFocusAnimatedValue(float animatedValue) { @@ -171,8 +257,13 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo } } - /** - * The implementation should return the height of the card. - */ - protected abstract float getCardHeight(); + private void setTextViewLayoutParams(ViewGroup.LayoutParams params) { + mTextViewFocused.setLayoutParams(params); + mTextView.setLayoutParams(params); + } + + private void setTextViewFocusedAlpha(float focusedAlpha) { + mTextViewFocused.setAlpha(focusedAlpha); + mTextView.setAlpha(1f - focusedAlpha); + } } diff --git a/src/com/android/tv/menu/ChannelCardView.java b/src/com/android/tv/menu/ChannelCardView.java index 860da224..1c8015a6 100644 --- a/src/com/android/tv/menu/ChannelCardView.java +++ b/src/com/android/tv/menu/ChannelCardView.java @@ -23,7 +23,6 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.view.View; -import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; @@ -42,10 +41,6 @@ public class ChannelCardView extends BaseCardView<Channel> { private static final String TAG = MenuView.TAG; private static final boolean DEBUG = MenuView.DEBUG; - private final float mCardHeight; - private final float mExtendedCardHeight; - private final float mProgramNameViewHeight; - private final float mExtendedTextViewCardHeight; private final int mCardImageWidth; private final int mCardImageHeight; @@ -53,11 +48,8 @@ public class ChannelCardView extends BaseCardView<Channel> { private View mGradientView; private TextView mChannelNumberNameView; private ProgressBar mProgressBar; - private TextView mMetaViewFocused; - private TextView mMetaViewUnfocused; private Channel mChannel; private Program mProgram; - private boolean mExtendViewOnFocus; private final MainActivity mMainActivity; public ChannelCardView(Context context) { @@ -70,17 +62,8 @@ public class ChannelCardView extends BaseCardView<Channel> { public ChannelCardView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); - mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width); mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.card_image_layout_height); - mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height); - mExtendedCardHeight = getResources().getDimensionPixelSize( - R.dimen.card_layout_height_extended); - mProgramNameViewHeight = getResources().getDimensionPixelSize( - R.dimen.card_meta_layout_height); - mExtendedTextViewCardHeight = getResources().getDimensionPixelOffset( - R.dimen.card_meta_layout_height_extended); - mMainActivity = (MainActivity) context; } @@ -90,8 +73,6 @@ public class ChannelCardView extends BaseCardView<Channel> { mImageView = (ImageView) findViewById(R.id.image); mGradientView = findViewById(R.id.image_gradient); mChannelNumberNameView = (TextView) findViewById(R.id.channel_number_and_name); - mMetaViewFocused = (TextView) findViewById(R.id.channel_title_focused); - mMetaViewUnfocused = (TextView) findViewById(R.id.channel_title_unfocused); mProgressBar = (ProgressBar) findViewById(R.id.progress); } @@ -103,38 +84,25 @@ public class ChannelCardView extends BaseCardView<Channel> { } mChannel = channel; mProgram = null; - if (TextUtils.isEmpty(mChannel.getDisplayName())) { - mChannelNumberNameView.setText(mChannel.getDisplayNumber()); - } else { - mChannelNumberNameView.setText(mChannel.getDisplayNumber() + " " - + mChannel.getDisplayName()); - } + mChannelNumberNameView.setText(mChannel.getDisplayText()); mChannelNumberNameView.setVisibility(VISIBLE); mImageView.setImageResource(R.drawable.ic_recent_thumbnail_default); mImageView.setBackgroundResource(R.color.channel_card); mGradientView.setVisibility(View.GONE); mProgressBar.setVisibility(GONE); - setMetaViewEnabled(true); + setTextViewEnabled(true); if (mMainActivity.getParentalControlSettings().isParentalControlsEnabled() && mChannel.isLocked()) { - setMetaViewText(R.string.program_title_for_blocked_channel); + setText(R.string.program_title_for_blocked_channel); return; } else { - setMetaViewText(""); + setText(""); } updateProgramInformation(); - mMetaViewFocused.measure( - MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); - if (mExtendViewOnFocus = mMetaViewFocused.getLineCount() > 1) { - setMetaViewFocusedAlpha(selected ? 1f : 0f); - } else { - setMetaViewFocusedAlpha(1f); - } - - // Call super.onBind() at the end in order to make getCardHeight() return a proper value. + // Call super.onBind() at the end intentionally. In order to correctly handle extension of + // text view, text should be set before calling super.onBind. super.onBind(channel, selected); } @@ -158,40 +126,16 @@ public class ChannelCardView extends BaseCardView<Channel> { mGradientView.setVisibility(View.VISIBLE); } - @Override - protected void onFocusAnimationStart(boolean selected) { - if (mExtendViewOnFocus) { - setMetaViewFocusedAlpha(selected ? 1f : 0f); - } - } - - @Override - protected void onSetFocusAnimatedValue(float animatedValue) { - super.onSetFocusAnimatedValue(animatedValue); - if (mExtendViewOnFocus) { - ViewGroup.LayoutParams params = mMetaViewUnfocused.getLayoutParams(); - params.height = Math.round(mProgramNameViewHeight - + (mExtendedTextViewCardHeight - mProgramNameViewHeight) * animatedValue); - setMetaViewLayoutParams(params); - setMetaViewFocusedAlpha(animatedValue); - } - } - - @Override - protected float getCardHeight() { - return (mExtendViewOnFocus && isFocused()) ? mExtendedCardHeight : mCardHeight; - } - private void updateProgramInformation() { if (mChannel == null) { return; } mProgram = mMainActivity.getProgramDataManager().getCurrentProgram(mChannel.getId()); if (mProgram == null || TextUtils.isEmpty(mProgram.getTitle())) { - setMetaViewEnabled(false); - setMetaViewText(R.string.program_title_for_no_information); + setTextViewEnabled(false); + setText(R.string.program_title_for_no_information); } else { - setMetaViewText(mProgram.getTitle()); + setText(mProgram.getTitle()); } if (mProgram == null) { @@ -222,29 +166,4 @@ public class ChannelCardView extends BaseCardView<Channel> { createProgramPosterArtCallback(this, mProgram)); } } - - private void setMetaViewLayoutParams(ViewGroup.LayoutParams params) { - mMetaViewFocused.setLayoutParams(params); - mMetaViewUnfocused.setLayoutParams(params); - } - - private void setMetaViewText(String text) { - mMetaViewFocused.setText(text); - mMetaViewUnfocused.setText(text); - } - - private void setMetaViewText(int resId) { - mMetaViewFocused.setText(resId); - mMetaViewUnfocused.setText(resId); - } - - private void setMetaViewEnabled(boolean enabled) { - mMetaViewFocused.setEnabled(enabled); - mMetaViewUnfocused.setEnabled(enabled); - } - - private void setMetaViewFocusedAlpha(float focusedAlpha) { - mMetaViewFocused.setAlpha(focusedAlpha); - mMetaViewUnfocused.setAlpha(1f - focusedAlpha); - } } diff --git a/src/com/android/tv/menu/ChannelsRowAdapter.java b/src/com/android/tv/menu/ChannelsRowAdapter.java index 200f4ac0..c8e1bd05 100644 --- a/src/com/android/tv/menu/ChannelsRowAdapter.java +++ b/src/com/android/tv/menu/ChannelsRowAdapter.java @@ -19,16 +19,15 @@ package com.android.tv.menu; import android.content.Context; import android.content.Intent; import android.media.tv.TvInputInfo; -import android.os.Build; -import android.support.v4.os.BuildCompat; import android.view.View; -import com.android.tv.MainActivity; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.analytics.Tracker; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; +import com.android.tv.dvr.DvrDataManager; import com.android.tv.recommendation.Recommender; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; @@ -41,15 +40,17 @@ import java.util.List; * An adapter of the Channels row. */ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> { - // There are four special cards: guide, setup, dvr, record, applink. + private static final String TAG = "ChannelsRowAdapter"; + + // There are four special cards: guide, setup, dvr, applink. private static final int SIZE_OF_VIEW_TYPE = 5; private final Context mContext; private final Tracker mTracker; private final Recommender mRecommender; + private final DvrDataManager mDvrDataManager; private final int mMaxCount; private final int mMinCount; - private final boolean mDvrFeatureEnabled; private final int[] mViewType = new int[SIZE_OF_VIEW_TYPE]; private final View.OnClickListener mGuideOnClickListener = new View.OnClickListener() { @@ -71,28 +72,11 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> private final View.OnClickListener mDvrOnClickListener = new View.OnClickListener() { @Override public void onClick(View view) { - Utils.showToastMessageForDeveloperFeature(view.getContext()); mTracker.sendMenuClicked(R.string.channels_item_dvr); getMainActivity().getOverlayManager().showDvrManager(); } }; - private final View.OnClickListener mRecordOnClickListener = new View.OnClickListener() { - @Override - public void onClick(View view) { - Utils.showToastMessageForDeveloperFeature(view.getContext()); - RecordCardView v = ((RecordCardView) view); - boolean isRecording = v.isRecording(); - mTracker.sendMenuClicked(isRecording ? R.string.channels_item_record_start - : R.string.channels_item_record_stop); - if (!isRecording) { - v.startRecording(); - } else { - v.stopRecording(); - } - } - }; - private final View.OnClickListener mAppLinkOnClickListener = new View.OnClickListener() { @Override public void onClick(View view) { @@ -118,12 +102,17 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> public ChannelsRowAdapter(Context context, Recommender recommender, int minCount, int maxCount) { super(context); - mTracker = TvApplication.getSingletons(context).getTracker(); mContext = context; + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + mTracker = appSingletons.getTracker(); + if (CommonFeatures.DVR.isEnabled(context)) { + mDvrDataManager = appSingletons.getDvrDataManager(); + } else { + mDvrDataManager = null; + } mRecommender = recommender; mMinCount = minCount; mMaxCount = maxCount; - mDvrFeatureEnabled = CommonFeatures.DVR.isEnabled(mContext) && BuildCompat.isAtLeastN(); } @Override @@ -152,8 +141,8 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> viewHolder.itemView.setOnClickListener(mAppLinkOnClickListener); } else if (viewType == R.layout.menu_card_dvr) { viewHolder.itemView.setOnClickListener(mDvrOnClickListener); - } else if (viewType == R.layout.menu_card_record) { - viewHolder.itemView.setOnClickListener(mRecordOnClickListener); + SimpleCardView view = (SimpleCardView) viewHolder.itemView; + view.setText(R.string.channels_item_dvr); } else { viewHolder.itemView.setTag(getItemList().get(position)); viewHolder.itemView.setOnClickListener(mChannelOnClickListener); @@ -170,25 +159,19 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> TvInputManagerHelper inputManager = TvApplication.getSingletons(mContext) .getTvInputManagerHelper(); boolean showSetupCard = SetupUtils.getInstance(mContext).hasNewInput(inputManager); - Channel currentChannel = ((MainActivity) mContext).getCurrentChannel(); - boolean showAppLinkCard = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && currentChannel != null - && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE; + Channel currentChannel = getMainActivity().getCurrentChannel(); + boolean showAppLinkCard = currentChannel != null + && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE + // Sometimes applicationInfo can be null. b/28932537 + && inputManager.getTvInputAppInfo(currentChannel.getInputId()) != null; boolean showDvrCard = false; - boolean showRecordCard = false; - if (mDvrFeatureEnabled) { + if (mDvrDataManager != null) { for (TvInputInfo info : inputManager.getTvInputInfos(true, true)) { if (info.canRecord()) { showDvrCard = true; break; } } - if (currentChannel != null && currentChannel.getInputId() != null) { - TvInputInfo inputInfo = inputManager.getTvInputInfo(currentChannel.getInputId()); - if ((inputInfo.canRecord() && inputInfo.getTunerCount() > 1)) { - showRecordCard = true; - } - } } mViewType[0] = R.layout.menu_card_guide; @@ -201,10 +184,6 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> channelList.add(dummyChannel); mViewType[index++] = R.layout.menu_card_dvr; } - if (showRecordCard) { - channelList.add(currentChannel); - mViewType[index++] = R.layout.menu_card_record; - } if (showAppLinkCard) { channelList.add(currentChannel); mViewType[index++] = R.layout.menu_card_app_link; @@ -226,8 +205,8 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> int count = channelList.size(); // If the number of recommended channels is not enough, add more from the recent channel // list. - if (count < mMinCount && mContext instanceof MainActivity) { - for (long channelId : ((MainActivity) mContext).getRecentChannels()) { + if (count < mMinCount) { + for (long channelId : getMainActivity().getRecentChannels()) { Channel channel = mRecommender.getChannel(channelId); if (channel == null || channelList.contains(channel) || !channel.isBrowsable()) { diff --git a/src/com/android/tv/menu/IMenuView.java b/src/com/android/tv/menu/IMenuView.java index 99fb4126..87c5d9f6 100644 --- a/src/com/android/tv/menu/IMenuView.java +++ b/src/com/android/tv/menu/IMenuView.java @@ -54,6 +54,13 @@ public interface IMenuView { boolean update(boolean menuActive); /** + * Updates the menu row. + * + * <p>Returns <@code true> if the contents have been changed, otherwise {@code false}. + */ + boolean update(String rowId, boolean menuActive); + + /** * Checks if the menu view is visible or not. */ boolean isVisible(); diff --git a/src/com/android/tv/menu/Menu.java b/src/com/android/tv/menu/Menu.java index 7bb0787e..1160a5b5 100644 --- a/src/com/android/tv/menu/Menu.java +++ b/src/com/android/tv/menu/Menu.java @@ -35,10 +35,10 @@ import com.android.tv.analytics.DurationTimer; import com.android.tv.analytics.Tracker; import com.android.tv.common.TvCommonUtils; import com.android.tv.common.WeakHandler; -import com.android.tv.data.Channel; import com.android.tv.menu.MenuRowFactory.PartnerRow; import com.android.tv.menu.MenuRowFactory.PipOptionsRow; import com.android.tv.menu.MenuRowFactory.TvOptionsRow; +import com.android.tv.ui.TunableTvView; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -56,7 +56,7 @@ public class Menu { @IntDef({REASON_NONE, REASON_GUIDE, REASON_PLAY_CONTROLS_PLAY, REASON_PLAY_CONTROLS_PAUSE, REASON_PLAY_CONTROLS_PLAY_PAUSE, REASON_PLAY_CONTROLS_REWIND, REASON_PLAY_CONTROLS_FAST_FORWARD, REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS, - REASON_PLAY_CONTROLS_JUMP_TO_NEXT, REASON_RECORDING_PLAYBACK}) + REASON_PLAY_CONTROLS_JUMP_TO_NEXT}) public @interface MenuShowReason {} public static final int REASON_NONE = 0; public static final int REASON_GUIDE = 1; @@ -67,20 +67,18 @@ public class Menu { public static final int REASON_PLAY_CONTROLS_FAST_FORWARD = 6; public static final int REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS = 7; public static final int REASON_PLAY_CONTROLS_JUMP_TO_NEXT = 8; - public static final int REASON_RECORDING_PLAYBACK = 9; private static final List<String> sRowIdListForReason = new ArrayList<>(); static { - sRowIdListForReason.add(null); // REASON_NONE - sRowIdListForReason.add(ChannelsRow.ID); // REASON_GUIDE - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PAUSE - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY_PAUSE - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_REWIND - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_FAST_FORWARD - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_NEXT - sRowIdListForReason.add(PlayControlsRow.ID); // REASON_RECORDING_PLAYBACK + sRowIdListForReason.add(null); // REASON_NONE + sRowIdListForReason.add(ChannelsRow.ID); // REASON_GUIDE + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PAUSE + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY_PAUSE + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_REWIND + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_FAST_FORWARD + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS + sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_NEXT } private static final String SCREEN_NAME = "Menu"; @@ -94,37 +92,26 @@ public class Menu { private final OnMenuVisibilityChangeListener mOnMenuVisibilityChangeListener; private final WeakHandler<Menu> mHandler = new MenuWeakHandler(this, Looper.getMainLooper()); - private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() { - @Override - public void onLoadFinished() {} - - @Override - public void onBrowsableChannelListChanged() { - mMenuView.update(isActive()); - } - - @Override - public void onCurrentChannelUnavailable(Channel channel) {} - - @Override - public void onChannelChanged(Channel previousChannel, Channel currentChannel) {} - }; - + private final MenuUpdater mMenuUpdater; private final List<MenuRow> mMenuRows = new ArrayList<>(); private final Animator mShowAnimator; private final Animator mHideAnimator; - private ChannelTuner mChannelTuner; private boolean mKeepVisible; private boolean mAnimationDisabledForTest; - /** - * A constructor. - */ - public Menu(Context context, IMenuView menuView, MenuRowFactory menuRowFactory, - OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { + @VisibleForTesting + Menu(Context context, IMenuView menuView, MenuRowFactory menuRowFactory, + OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { + this(context, null, menuView, menuRowFactory, onMenuVisibilityChangeListener); + } + + public Menu(Context context, TunableTvView tvView, IMenuView menuView, + MenuRowFactory menuRowFactory, + OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { mMenuView = menuView; mTracker = TvApplication.getSingletons(context).getTracker(); + mMenuUpdater = new MenuUpdater(context, tvView, this); Resources res = context.getResources(); mShowDurationMillis = res.getInteger(R.integer.menu_show_duration); mOnMenuVisibilityChangeListener = onMenuVisibilityChangeListener; @@ -152,14 +139,7 @@ public class Menu { * or not available any more. */ public void setChannelTuner(ChannelTuner channelTuner) { - if (mChannelTuner != null) { - mChannelTuner.removeListener(mChannelTunerListener); - } - mChannelTuner = channelTuner; - if (mChannelTuner != null) { - mChannelTuner.addListener(mChannelTunerListener); - } - mMenuView.update(isActive()); + mMenuUpdater.setChannelTuner(channelTuner); } private void addMenuRow(MenuRow row) { @@ -172,7 +152,7 @@ public class Menu { * Call this method to end the lifetime of the menu. */ public void release() { - setChannelTuner(null); + mMenuUpdater.release(); for (MenuRow row : mMenuRows) { row.release(); } @@ -199,7 +179,9 @@ public class Menu { mMenuView.onShow(reason, rowIdToSelect, mAnimationDisabledForTest ? null : new Runnable() { @Override public void run() { - mShowAnimator.start(); + if (isActive()) { + mShowAnimator.start(); + } } }); scheduleHide(); @@ -209,6 +191,9 @@ public class Menu { * Closes the menu. */ public void hide(boolean withAnimation) { + if (mShowAnimator.isStarted()) { + mShowAnimator.cancel(); + } if (!isActive()) { return; } @@ -284,6 +269,16 @@ public class Menu { } /** + * Updates the menu row. + * + * <p>Returns <@code true> if the contents have been changed, otherwise {@code false}. + */ + public boolean update(String rowId) { + if (DEBUG) Log.d(TAG, "update main menu"); + return mMenuView.update(rowId, isActive()); + } + + /** * This method is called when channels are changed. */ public void onRecentChannelsChanged() { diff --git a/src/com/android/tv/menu/MenuAction.java b/src/com/android/tv/menu/MenuAction.java index 86153084..0d59552a 100644 --- a/src/com/android/tv/menu/MenuAction.java +++ b/src/com/android/tv/menu/MenuAction.java @@ -41,13 +41,16 @@ public class MenuAction { R.drawable.ic_tvoption_pip); public static final MenuAction SYSTEMWIDE_PIP_ACTION = new MenuAction(R.string.options_item_pip, TvOptionsManager.OPTION_SYSTEMWIDE_PIP, - R.drawable.ic_tvoption_pip); + R.drawable.ic_pip_option_layout2); public static final MenuAction SELECT_AUDIO_LANGUAGE_ACTION = new MenuAction(R.string.options_item_multi_audio, TvOptionsManager.OPTION_MULTI_AUDIO, R.drawable.ic_tvoption_multi_track); public static final MenuAction MORE_CHANNELS_ACTION = new MenuAction(R.string.options_item_more_channels, TvOptionsManager.OPTION_MORE_CHANNELS, R.drawable.ic_store); + public static final MenuAction DEV_ACTION = + new MenuAction(R.string.options_item_developer, + TvOptionsManager.OPTION_DEVELOPER, R.drawable.ic_developer_mode_tv_white_48dp); // TODO: Change the icon. public static final MenuAction SETTINGS_ACTION = new MenuAction(R.string.options_item_settings, TvOptionsManager.OPTION_SETTINGS, diff --git a/src/com/android/tv/menu/MenuLayoutManager.java b/src/com/android/tv/menu/MenuLayoutManager.java index 1f377f54..6c767247 100644 --- a/src/com/android/tv/menu/MenuLayoutManager.java +++ b/src/com/android/tv/menu/MenuLayoutManager.java @@ -219,8 +219,8 @@ public class MenuLayoutManager { * @param bottom The bottom coordinate of the menu view. */ private List<Rect> getViewLayouts(int left, int top, int right, int bottom) { - return getViewLayouts(left, top, right, bottom, Collections.<Integer>emptyList(), - Collections.<Integer>emptyList()); + return getViewLayouts(left, top, right, bottom, Collections.emptyList(), + Collections.emptyList()); } /** diff --git a/src/com/android/tv/menu/MenuRow.java b/src/com/android/tv/menu/MenuRow.java index fe73edd2..6f98e615 100644 --- a/src/com/android/tv/menu/MenuRow.java +++ b/src/com/android/tv/menu/MenuRow.java @@ -17,6 +17,7 @@ package com.android.tv.menu; import android.content.Context; +import android.view.View; /** * A base class of the item which will be displayed in the main menu. @@ -30,6 +31,8 @@ public abstract class MenuRow { private final int mHeight; private final Menu mMenu; + private MenuRowView mMenuRowView; + // TODO: Check if the heightResId is really necessary. public MenuRow(Context context, Menu menu, int titleResId, int heightResId) { this(context, menu, context.getString(titleResId), heightResId); @@ -71,6 +74,20 @@ public abstract class MenuRow { } /** + * Sets the menu row view. + */ + public void setMenuRowView(MenuRowView menuRowView) { + mMenuRowView = menuRowView; + } + + /** + * Returns the menu row view. + */ + protected MenuRowView getMenuRowView() { + return mMenuRowView; + } + + /** * Updates the contents in this row. * This method is called only by the menu when necessary. */ diff --git a/src/com/android/tv/menu/MenuRowFactory.java b/src/com/android/tv/menu/MenuRowFactory.java index b0b000f1..c67a0e04 100644 --- a/src/com/android/tv/menu/MenuRowFactory.java +++ b/src/com/android/tv/menu/MenuRowFactory.java @@ -24,6 +24,7 @@ import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.customization.CustomAction; import com.android.tv.customization.TvCustomizationManager; +import com.android.tv.ui.TunableTvView; import java.util.List; @@ -32,13 +33,15 @@ import java.util.List; */ public class MenuRowFactory { private final MainActivity mMainActivity; + private final TunableTvView mTvView; private final TvCustomizationManager mTvCustomizationManager; /** * A constructor. */ - public MenuRowFactory(MainActivity mainActivity) { + public MenuRowFactory(MainActivity mainActivity, TunableTvView tvView) { mMainActivity = mainActivity; + mTvView = tvView; mTvCustomizationManager = new TvCustomizationManager(mainActivity); mTvCustomizationManager.initialize(); } @@ -49,7 +52,8 @@ public class MenuRowFactory { @Nullable public MenuRow createMenuRow(Menu menu, Class<?> key) { if (PlayControlsRow.class.equals(key)) { - return new PlayControlsRow(mMainActivity, menu, mMainActivity.getTimeShiftManager()); + return new PlayControlsRow(mMainActivity, mTvView, menu, + mMainActivity.getTimeShiftManager()); } else if (ChannelsRow.class.equals(key)) { return new ChannelsRow(mMainActivity, menu, mMainActivity.getProgramDataManager()); } else if (PartnerRow.class.equals(key)) { diff --git a/src/com/android/tv/menu/MenuRowView.java b/src/com/android/tv/menu/MenuRowView.java index 7cdbfe9e..97dea29a 100644 --- a/src/com/android/tv/menu/MenuRowView.java +++ b/src/com/android/tv/menu/MenuRowView.java @@ -35,25 +35,6 @@ public abstract class MenuRowView extends LinearLayout { private static final String TAG = "MenuRowView"; private static final boolean DEBUG = false; - /** - * For setting ListView visible, and TitleView visible with the selected text size and color - * without animation. - */ - public static final int ANIM_NONE_SELECTED = 1; - /** - * For setting ListView gone, and TitleView visible with the deselected text size and color - * without animation. - */ - public static final int ANIM_NONE_DESELECTED = 2; - /** - * An animation for the selected item list view. - */ - public static final int ANIM_SELECTED = 3; - /** - * An animation for the deselected item list view. - */ - public static final int ANIM_DESELECTED = 4; - private TextView mTitleView; private View mContentsView; diff --git a/src/com/android/tv/menu/MenuUpdater.java b/src/com/android/tv/menu/MenuUpdater.java new file mode 100644 index 00000000..075b299e --- /dev/null +++ b/src/com/android/tv/menu/MenuUpdater.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.menu; + +import android.content.Context; +import android.support.annotation.Nullable; + +import com.android.tv.ChannelTuner; +import com.android.tv.data.Channel; +import com.android.tv.ui.TunableTvView; +import com.android.tv.ui.TunableTvView.OnScreenBlockingChangedListener; + +/** + * Update menu items when needed. + * + * <p>As the menu is updated when it shows up, this class handles only the dynamic updates. + */ +public class MenuUpdater { + // Can be null for testing. + @Nullable + private final TunableTvView mTvView; + private final Menu mMenu; + private ChannelTuner mChannelTuner; + + private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() { + @Override + public void onLoadFinished() {} + + @Override + public void onBrowsableChannelListChanged() { + mMenu.update(); + } + + @Override + public void onCurrentChannelUnavailable(Channel channel) {} + + @Override + public void onChannelChanged(Channel previousChannel, Channel currentChannel) { + mMenu.update(ChannelsRow.ID); + } + }; + + public MenuUpdater(Context context, TunableTvView tvView, Menu menu) { + mTvView = tvView; + mMenu = menu; + if (mTvView != null) { + mTvView.setOnScreenBlockedListener(new OnScreenBlockingChangedListener() { + @Override + public void onScreenBlockingChanged(boolean blocked) { + mMenu.update(PlayControlsRow.ID); + } + }); + } + } + + /** + * Sets the instance of {@link ChannelTuner}. Call this method when the channel tuner is ready + * or not available any more. + */ + public void setChannelTuner(ChannelTuner channelTuner) { + if (mChannelTuner != null) { + mChannelTuner.removeListener(mChannelTunerListener); + } + mChannelTuner = channelTuner; + if (mChannelTuner != null) { + mChannelTuner.addListener(mChannelTunerListener); + } + mMenu.update(); + } + + /** + * Called at the end of the menu's lifetime. + */ + public void release() { + if (mChannelTuner != null) { + mChannelTuner.removeListener(mChannelTunerListener); + } + if (mTvView != null) { + mTvView.setOnScreenBlockedListener(null); + } + } +} diff --git a/src/com/android/tv/menu/MenuView.java b/src/com/android/tv/menu/MenuView.java index e012dfca..ee0b036e 100644 --- a/src/com/android/tv/menu/MenuView.java +++ b/src/com/android/tv/menu/MenuView.java @@ -24,6 +24,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewParent; import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; import android.widget.FrameLayout; import com.android.tv.menu.Menu.MenuShowReason; @@ -57,6 +58,8 @@ public class MenuView extends FrameLayout implements IMenuView { public MenuView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mLayoutInflater = LayoutInflater.from(context); + // Set hardware layer type for smooth animation of lots of views. + setLayerType(LAYER_TYPE_HARDWARE, null); getViewTreeObserver().addOnGlobalFocusChangeListener(new OnGlobalFocusChangeListener() { @Override public void onGlobalFocusChanged(View oldFocus, View newFocus) { @@ -89,6 +92,7 @@ public class MenuView extends FrameLayout implements IMenuView { private MenuRowView createMenuRowView(MenuRow row) { MenuRowView view = (MenuRowView) mLayoutInflater.inflate(row.getLayoutResId(), this, false); view.onBind(row); + row.setMenuRowView(view); return view; } @@ -128,7 +132,15 @@ public class MenuView extends FrameLayout implements IMenuView { // Make the selected row have the focus. requestFocus(); if (runnableAfterShow != null) { - runnableAfterShow.run(); + getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getViewTreeObserver().removeOnGlobalLayoutListener(this); + // Start show animation after layout finishes for smooth animation because the + // layout can take long time. + runnableAfterShow.run(); + } + }); } mLayoutManager.onMenuShow(); } @@ -160,6 +172,19 @@ public class MenuView extends FrameLayout implements IMenuView { } @Override + public boolean update(String rowId, boolean menuActive) { + if (menuActive) { + MenuRow row = getMenuRow(rowId); + if (row != null) { + row.update(); + mLayoutManager.onMenuRowUpdated(); + return true; + } + } + return false; + } + + @Override protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { int selectedPosition = mLayoutManager.getSelectedPosition(); // When the menu shows up, the selected row should have focus. @@ -169,6 +194,16 @@ public class MenuView extends FrameLayout implements IMenuView { return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); } + @Override + public void focusableViewAvailable(View v) { + // Workaround of b/30788222 and b/32074688. + // The re-layout of RecyclerView gives the focus to the card view even when the menu is not + // visible. Don't report focusable view when the menu is not visible. + if (getVisibility() == VISIBLE) { + super.focusableViewAvailable(v); + } + } + private void setSelectedPosition(int position) { mLayoutManager.setSelectedPosition(position); } @@ -183,6 +218,15 @@ public class MenuView extends FrameLayout implements IMenuView { } } + private MenuRow getMenuRow(String rowId) { + for (MenuRow item : mMenuRows) { + if (rowId.equals(item.getId())) { + return item; + } + } + return null; + } + private int getItemPosition(String rowIdToSelect) { if (rowIdToSelect == null) { return -1; diff --git a/src/com/android/tv/menu/PlayControlsButton.java b/src/com/android/tv/menu/PlayControlsButton.java index 957f2e94..aff39db3 100644 --- a/src/com/android/tv/menu/PlayControlsButton.java +++ b/src/com/android/tv/menu/PlayControlsButton.java @@ -16,6 +16,8 @@ package com.android.tv.menu; +import android.R.integer; +import android.animation.ValueAnimator; import android.content.Context; import android.text.TextUtils; import android.util.AttributeSet; @@ -33,6 +35,9 @@ public class PlayControlsButton extends FrameLayout { private final ImageView mButton; private final ImageView mIcon; private final TextView mLabel; + private final long mFocusAnimationTimeMs; + private final int mIconColor; + private int mIconFocusedColor; public PlayControlsButton(Context context) { this(context, null); @@ -53,6 +58,9 @@ public class PlayControlsButton extends FrameLayout { mButton = (ImageView) findViewById(R.id.button); mIcon = (ImageView) findViewById(R.id.icon); mLabel = (TextView) findViewById(R.id.label); + mFocusAnimationTimeMs = context.getResources().getInteger(integer.config_shortAnimTime); + mIconColor = context.getResources().getColor(R.color.play_controls_icon_color); + mIconFocusedColor = mIconColor; } /** @@ -60,6 +68,9 @@ public class PlayControlsButton extends FrameLayout { */ public void setImageResId(int imageResId) { mIcon.setImageResource(imageResId); + // Since on foucus changing, icons' color should be switched with animation, + // as a result, selectors cannot be used to switch colors in this case. + mIcon.getDrawable().setTint(hasFocus() ? mIconFocusedColor : mIconColor); } /** @@ -74,6 +85,31 @@ public class PlayControlsButton extends FrameLayout { }); } + /** + * Sets the icon's color should change to when the button is on focus. + */ + public void setFocusedIconColor(int color) { + final ValueAnimator valueAnimator = ValueAnimator.ofArgb(mIconColor, color); + valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(final ValueAnimator animator) { + mIcon.getDrawable().setTint((int) animator.getAnimatedValue()); + } + }); + valueAnimator.setDuration(mFocusAnimationTimeMs); + mButton.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + valueAnimator.start(); + } else { + valueAnimator.reverse(); + } + } + }); + mIconFocusedColor = color; + } + public void setLabel(String label) { if (TextUtils.isEmpty(label)) { mIcon.setVisibility(View.VISIBLE); @@ -93,6 +129,7 @@ public class PlayControlsButton extends FrameLayout { public void setEnabled(boolean enabled) { super.setEnabled(enabled); mButton.setEnabled(enabled); + mButton.setFocusable(enabled); mIcon.setEnabled(enabled); mIcon.setAlpha(enabled ? ALPHA_ENABLED : ALPHA_DISABLED); mLabel.setEnabled(enabled); diff --git a/src/com/android/tv/menu/PlayControlsRow.java b/src/com/android/tv/menu/PlayControlsRow.java index 588ecf6a..a60ff153 100644 --- a/src/com/android/tv/menu/PlayControlsRow.java +++ b/src/com/android/tv/menu/PlayControlsRow.java @@ -20,19 +20,24 @@ import android.content.Context; import com.android.tv.R; import com.android.tv.TimeShiftManager; +import com.android.tv.ui.TunableTvView; public class PlayControlsRow extends MenuRow { public static final String ID = PlayControlsRow.class.getName(); + private final TunableTvView mTvView; private final TimeShiftManager mTimeShiftManager; - public PlayControlsRow(Context context, Menu menu, TimeShiftManager timeShiftManager) { + public PlayControlsRow(Context context, TunableTvView tvView, Menu menu, + TimeShiftManager timeShiftManager) { super(context, menu, R.string.menu_title_play_controls, R.dimen.play_controls_height); + mTvView = tvView; mTimeShiftManager = timeShiftManager; } @Override public void update() { + ((PlayControlsRowView) getMenuRowView()).update(); } @Override @@ -41,6 +46,13 @@ public class PlayControlsRow extends MenuRow { } /** + * Returns TV view. + */ + public TunableTvView getTvView() { + return mTvView; + } + + /** * Returns an instance of {@link TimeShiftManager}. */ public TimeShiftManager getTimeShiftManager() { diff --git a/src/com/android/tv/menu/PlayControlsRowView.java b/src/com/android/tv/menu/PlayControlsRowView.java index 058d5108..a620d4dd 100644 --- a/src/com/android/tv/menu/PlayControlsRowView.java +++ b/src/com/android/tv/menu/PlayControlsRowView.java @@ -19,20 +19,35 @@ package com.android.tv.menu; import android.content.Context; import android.content.res.Resources; import android.text.format.DateFormat; -import android.text.format.DateUtils; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import android.widget.Toast; +import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TimeShiftManager; import com.android.tv.TimeShiftManager.TimeShiftActionId; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.data.Channel; import com.android.tv.data.Program; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener; +import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrUiHelper; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.ui.DvrStopRecordingFragment; +import com.android.tv.dvr.ui.HalfSizedDialogFragment; import com.android.tv.menu.Menu.MenuShowReason; +import com.android.tv.ui.TunableTvView; +import com.android.tv.util.Utils; public class PlayControlsRowView extends MenuRowView { + private static final int NORMAL_WIDTH_MAX_BUTTON_COUNT = 5; // Dimensions private final int mTimeIndicatorLeftMargin; private final int mTimeTextLeftMargin; @@ -51,14 +66,44 @@ public class PlayControlsRowView extends MenuRowView { private PlayControlsButton mPlayPauseButton; private PlayControlsButton mFastForwardButton; private PlayControlsButton mJumpNextButton; + private PlayControlsButton mRecordButton; private TextView mProgramStartTimeText; private TextView mProgramEndTimeText; private View mUnavailableMessageText; + private TunableTvView mTvView; private TimeShiftManager mTimeShiftManager; + private final DvrDataManager mDvrDataManager; + private final DvrManager mDvrManager; + private final MainActivity mMainActivity; private final java.text.DateFormat mTimeFormat; private long mProgramStartTimeMs; private long mProgramEndTimeMs; + private boolean mUseCompactLayout; + private final int mNormalButtonMargin; + private final int mCompactButtonMargin; + + private final ScheduledRecordingListener mScheduledRecordingListener + = new ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) { + Channel currentChannel = mMainActivity.getCurrentChannel(); + if (currentChannel != null && isShown()) { + for (ScheduledRecording schedule : scheduledRecordings) { + if (schedule.getChannelId() == currentChannel.getId()) { + updateRecordButton(); + break; + } + } + } + } + }; public PlayControlsRowView(Context context) { this(context, null); @@ -82,6 +127,38 @@ public class PlayControlsRowView extends MenuRowView { - res.getDimensionPixelOffset(R.dimen.play_controls_time_width) / 2; mTimelineWidth = res.getDimensionPixelSize(R.dimen.play_controls_width); mTimeFormat = DateFormat.getTimeFormat(context); + mNormalButtonMargin = res.getDimensionPixelSize(R.dimen.play_controls_button_normal_margin); + mCompactButtonMargin = + res.getDimensionPixelSize(R.dimen.play_controls_button_compact_margin); + if (CommonFeatures.DVR.isEnabled(context)) { + mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); + mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + } else { + mDvrDataManager = null; + mDvrManager = null; + } + mMainActivity = (MainActivity) context; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + if (mDvrDataManager != null) { + mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener); + if (!mDvrDataManager.isDvrScheduleLoadFinished()) { + mDvrDataManager.addDvrScheduleLoadFinishedListener( + new OnDvrScheduleLoadFinishedListener() { + @Override + public void onDvrScheduleLoadFinished() { + mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); + if (isShown()) { + updateRecordButton(); + } + } + }); + } + + } } @Override @@ -107,22 +184,23 @@ public class PlayControlsRowView extends MenuRowView { mPlayPauseButton = (PlayControlsButton) findViewById(R.id.play_pause); mFastForwardButton = (PlayControlsButton) findViewById(R.id.fast_forward); mJumpNextButton = (PlayControlsButton) findViewById(R.id.jump_next); + mRecordButton = (PlayControlsButton) findViewById(R.id.record); mProgramStartTimeText = (TextView) findViewById(R.id.program_start_time); mProgramEndTimeText = (TextView) findViewById(R.id.program_end_time); mUnavailableMessageText = findViewById(R.id.unavailable_text); initializeButton(mJumpPreviousButton, R.drawable.lb_ic_skip_previous, - R.string.play_controls_description_skip_previous, new Runnable() { + R.string.play_controls_description_skip_previous, null, new Runnable() { @Override public void run() { if (mTimeShiftManager.isAvailable()) { mTimeShiftManager.jumpToPrevious(); - updateAll(); + updateControls(); } } }); initializeButton(mRewindButton, R.drawable.lb_ic_fast_rewind, - R.string.play_controls_description_fast_rewind, new Runnable() { + R.string.play_controls_description_fast_rewind, null, new Runnable() { @Override public void run() { if (mTimeShiftManager.isAvailable()) { @@ -132,7 +210,7 @@ public class PlayControlsRowView extends MenuRowView { } }); initializeButton(mPlayPauseButton, R.drawable.lb_ic_play, - R.string.play_controls_description_play_pause, new Runnable() { + R.string.play_controls_description_play_pause, null, new Runnable() { @Override public void run() { if (mTimeShiftManager.isAvailable()) { @@ -142,7 +220,7 @@ public class PlayControlsRowView extends MenuRowView { } }); initializeButton(mFastForwardButton, R.drawable.lb_ic_fast_forward, - R.string.play_controls_description_fast_forward, new Runnable() { + R.string.play_controls_description_fast_forward, null, new Runnable() { @Override public void run() { if (mTimeShiftManager.isAvailable()) { @@ -152,21 +230,80 @@ public class PlayControlsRowView extends MenuRowView { } }); initializeButton(mJumpNextButton, R.drawable.lb_ic_skip_next, - R.string.play_controls_description_skip_next, new Runnable() { + R.string.play_controls_description_skip_next, null, new Runnable() { @Override public void run() { if (mTimeShiftManager.isAvailable()) { mTimeShiftManager.jumpToNext(); - updateAll(); + updateControls(); } } }); + int color = getResources().getColor(R.color.play_controls_recording_icon_color_on_focus, + null); + initializeButton(mRecordButton, R.drawable.ic_record_start, R.string + .channels_item_record_start, color, new Runnable() { + @Override + public void run() { + onRecordButtonClicked(); + } + }); + } + + private boolean isCurrentChannelRecording() { + Channel currentChannel = mMainActivity.getCurrentChannel(); + return currentChannel != null && mDvrManager != null + && mDvrManager.getCurrentRecording(currentChannel.getId()) != null; + } + + private void onRecordButtonClicked() { + boolean isRecording = isCurrentChannelRecording(); + Channel currentChannel = mMainActivity.getCurrentChannel(); + TvApplication.getSingletons(getContext()).getTracker().sendMenuClicked(isRecording ? + R.string.channels_item_record_start : R.string.channels_item_record_stop); + if (!isRecording) { + if (!(mDvrManager != null && mDvrManager.isChannelRecordable(currentChannel))) { + Toast.makeText(mMainActivity, R.string.dvr_msg_cannot_record_channel, + Toast.LENGTH_SHORT).show(); + } else if (DvrUiHelper.checkStorageStatusAndShowErrorMessage(mMainActivity, + currentChannel.getInputId())) { + Program program = TvApplication.getSingletons(mMainActivity).getProgramDataManager() + .getCurrentProgram(currentChannel.getId()); + if (program == null) { + DvrUiHelper.showChannelRecordDurationOptions(mMainActivity, currentChannel); + } else if (DvrUiHelper.handleCreateSchedule(mMainActivity, program)) { + String msg = mMainActivity.getString(R.string.dvr_msg_current_program_scheduled, + program.getTitle(), + Utils.toTimeString(program.getEndTimeUtcMillis(), false)); + Toast.makeText(mMainActivity, msg, Toast.LENGTH_SHORT).show(); + } + } + } else if (currentChannel != null) { + DvrUiHelper.showStopRecordingDialog(mMainActivity, currentChannel.getId(), + DvrStopRecordingFragment.REASON_USER_STOP, + new HalfSizedDialogFragment.OnActionClickListener() { + @Override + public void onActionClick(long actionId) { + if (actionId == DvrStopRecordingFragment.ACTION_STOP) { + ScheduledRecording currentRecording = + mDvrManager.getCurrentRecording( + currentChannel.getId()); + if (currentRecording != null) { + mDvrManager.stopRecording(currentRecording); + } + } + } + }); + } } private void initializeButton(PlayControlsButton button, int imageResId, - int descriptionId, Runnable clickAction) { + int descriptionId, Integer focusedIconColor, Runnable clickAction) { button.setImageResId(imageResId); button.setAction(clickAction); + if (focusedIconColor != null) { + button.setFocusedIconColor(focusedIconColor); + } button.findViewById(R.id.button) .setContentDescription(getResources().getString(descriptionId)); } @@ -175,46 +312,46 @@ public class PlayControlsRowView extends MenuRowView { public void onBind(MenuRow row) { super.onBind(row); PlayControlsRow playControlsRow = (PlayControlsRow) row; + mTvView = playControlsRow.getTvView(); mTimeShiftManager = playControlsRow.getTimeShiftManager(); mTimeShiftManager.setListener(new TimeShiftManager.Listener() { @Override public void onAvailabilityChanged() { updateMenuVisibility(); - PlayControlsRowView.this.onAvailabilityChanged(); + if (isShown()) { + PlayControlsRowView.this.updateAll(); + } } @Override public void onPlayStatusChanged(int status) { updateMenuVisibility(); - if (mTimeShiftManager.isAvailable()) { - updateAll(); + if (mTimeShiftManager.isAvailable() && isShown()) { + updateControls(); } } @Override public void onRecordTimeRangeChanged() { - if (!mTimeShiftManager.isAvailable()) { - return; + if (mTimeShiftManager.isAvailable() && isShown()) { + updateControls(); } - updateAll(); } @Override public void onCurrentPositionChanged() { - if (!mTimeShiftManager.isAvailable()) { - return; + if (mTimeShiftManager.isAvailable() && isShown()) { + initializeTimeline(); + updateControls(); } - initializeTimeline(); - updateAll(); } @Override public void onProgramInfoChanged() { - if (!mTimeShiftManager.isAvailable()) { - return; + if (mTimeShiftManager.isAvailable() && isShown()) { + initializeTimeline(); + updateControls(); } - initializeTimeline(); - updateAll(); } @Override @@ -235,31 +372,14 @@ public class PlayControlsRowView extends MenuRowView { } } }); - onAvailabilityChanged(); - } - - private void onAvailabilityChanged() { - if (mTimeShiftManager.isAvailable()) { - setEnabled(true); - initializeTimeline(); - mBackgroundView.setEnabled(true); - } else { - setEnabled(false); - mBackgroundView.setEnabled(false); - } updateAll(); } private void initializeTimeline() { - if (mTimeShiftManager.isRecordingPlayback()) { - mProgramStartTimeMs = mTimeShiftManager.getRecordStartTimeMs(); - mProgramEndTimeMs = mTimeShiftManager.getRecordEndTimeMs(); - } else { - Program program = mTimeShiftManager.getProgramAt( - mTimeShiftManager.getCurrentPositionMs()); - mProgramStartTimeMs = program.getStartTimeUtcMillis(); - mProgramEndTimeMs = program.getEndTimeUtcMillis(); - } + Program program = mTimeShiftManager.getProgramAt( + mTimeShiftManager.getCurrentPositionMs()); + mProgramStartTimeMs = program.getStartTimeUtcMillis(); + mProgramEndTimeMs = program.getEndTimeUtcMillis(); SoftPreconditions.checkArgument(mProgramStartTimeMs <= mProgramEndTimeMs); } @@ -272,7 +392,7 @@ public class PlayControlsRowView extends MenuRowView { @Override public void onSelected(boolean showTitle) { super.onSelected(showTitle); - updateAll(); + updateControls(); postHideRippleAnimation(); } @@ -350,11 +470,32 @@ public class PlayControlsRowView extends MenuRowView { } } + /** + * Updates the view contents. It is called from the PlayControlsRow. + */ + public void update() { + updateAll(); + } + private void updateAll() { + if (mTimeShiftManager.isAvailable() && !mTvView.isScreenBlocked()) { + setEnabled(true); + initializeTimeline(); + mBackgroundView.setEnabled(true); + } else { + setEnabled(false); + mBackgroundView.setEnabled(false); + } + updateControls(); + } + + private void updateControls() { updateTime(); updateProgress(); updateRecTimeText(); updateButtons(); + updateRecordButton(); + updateButtonMargin(); } private void updateTime() { @@ -423,12 +564,8 @@ public class PlayControlsRowView extends MenuRowView { private void updateRecTimeText() { if (isEnabled()) { - if (mTimeShiftManager.isRecordingPlayback()) { - mProgramStartTimeText.setVisibility(View.GONE); - } else { - mProgramStartTimeText.setVisibility(View.VISIBLE); - mProgramStartTimeText.setText(getTimeString(mProgramStartTimeMs)); - } + mProgramStartTimeText.setVisibility(View.VISIBLE); + mProgramStartTimeText.setText(getTimeString(mProgramStartTimeMs)); mProgramEndTimeText.setVisibility(View.VISIBLE); mProgramEndTimeText.setText(getTimeString(mProgramEndTimeMs)); } else { @@ -464,6 +601,9 @@ public class PlayControlsRowView extends MenuRowView { TimeShiftManager.TIME_SHIFT_ACTION_ID_FAST_FORWARD)); mJumpNextButton.setEnabled(mTimeShiftManager.isActionEnabled( TimeShiftManager.TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT)); + mJumpPreviousButton.setVisibility(VISIBLE); + mJumpNextButton.setVisibility(VISIBLE); + updateButtonMargin(); PlayControlsButton button; if (mTimeShiftManager.getPlayDirection() == TimeShiftManager.PLAY_DIRECTION_FORWARD) { @@ -481,10 +621,51 @@ public class PlayControlsRowView extends MenuRowView { } } + private void updateRecordButton() { + if (!(mDvrManager != null + && mDvrManager.isChannelRecordable(mMainActivity.getCurrentChannel()))) { + mRecordButton.setVisibility(View.GONE); + updateButtonMargin(); + return; + } + mRecordButton.setVisibility(View.VISIBLE); + updateButtonMargin(); + if (isCurrentChannelRecording()) { + mRecordButton.setImageResId(R.drawable.ic_record_stop); + } else { + mRecordButton.setImageResId(R.drawable.ic_record_start); + } + } + + private void updateButtonMargin() { + int numOfVisibleButtons = (mJumpPreviousButton.getVisibility() == View.VISIBLE ? 1 : 0) + + (mRewindButton.getVisibility() == View.VISIBLE ? 1 : 0) + + (mPlayPauseButton.getVisibility() == View.VISIBLE ? 1 : 0) + + (mFastForwardButton.getVisibility() == View.VISIBLE ? 1 : 0) + + (mJumpNextButton.getVisibility() == View.VISIBLE ? 1 : 0) + + (mRecordButton.getVisibility() == View.VISIBLE ? 1 : 0); + boolean useCompactLayout = numOfVisibleButtons > NORMAL_WIDTH_MAX_BUTTON_COUNT; + if (mUseCompactLayout == useCompactLayout) { + return; + } + mUseCompactLayout = useCompactLayout; + int margin = mUseCompactLayout ? mCompactButtonMargin : mNormalButtonMargin; + updateButtonMargin(mJumpPreviousButton, margin); + updateButtonMargin(mRewindButton, margin); + updateButtonMargin(mPlayPauseButton, margin); + updateButtonMargin(mFastForwardButton, margin); + updateButtonMargin(mJumpNextButton, margin); + updateButtonMargin(mRecordButton, margin); + } + + private void updateButtonMargin(PlayControlsButton button, int margin) { + MarginLayoutParams params = (MarginLayoutParams) button.getLayoutParams(); + params.setMargins(margin, 0, margin, 0); + button.setLayoutParams(params); + } + private String getTimeString(long timeMs) { - return mTimeShiftManager.isRecordingPlayback() - ? DateUtils.formatElapsedTime(timeMs / 1000) - : mTimeFormat.format(timeMs); + return mTimeFormat.format(timeMs); } private int convertDurationToPixel(long duration) { @@ -493,4 +674,12 @@ public class PlayControlsRowView extends MenuRowView { } return (int) (duration * mTimelineWidth / (mProgramEndTimeMs - mProgramStartTimeMs)); } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mDvrDataManager != null) { + mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); + } + } } diff --git a/src/com/android/tv/menu/RecordCardView.java b/src/com/android/tv/menu/RecordCardView.java deleted file mode 100644 index de30894e..00000000 --- a/src/com/android/tv/menu/RecordCardView.java +++ /dev/null @@ -1,189 +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.menu; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.res.Resources; -import android.util.AttributeSet; -import android.widget.ImageView; -import android.widget.TextView; - -import com.android.tv.MainActivity; -import com.android.tv.R; -import com.android.tv.TvApplication; -import com.android.tv.data.Channel; -import com.android.tv.data.Program; -import com.android.tv.dvr.DvrDataManager; -import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.ScheduledRecording; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * A view to render an item of TV options. - */ -public class RecordCardView extends SimpleCardView implements - DvrDataManager.ScheduledRecordingListener { - private static final String TAG = MenuView.TAG; - private static final boolean DEBUG = MenuView.DEBUG; - private static final long MIN_PROGRAM_RECORD_DURATION = TimeUnit.MINUTES.toMillis(5); - - private ImageView mIconView; - private TextView mLabelView; - private Channel mCurrentChannel; - private final DvrManager mDvrManager; - private final DvrDataManager mDvrDataManager; - private boolean mIsRecording; - private ScheduledRecording mCurrentRecording; - - public RecordCardView(Context context) { - this(context, null); - } - - public RecordCardView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public RecordCardView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - mDvrManager = TvApplication.getSingletons(context).getDvrManager(); - mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager(); - } - - @Override - public void onBind(Channel channel, boolean selected) { - super.onBind(channel, selected); - mIconView = (ImageView) findViewById(R.id.record_icon); - mLabelView = (TextView) findViewById(R.id.record_label); - mCurrentChannel = channel; - mCurrentRecording = null; - for (ScheduledRecording recording : mDvrDataManager.getStartedRecordings()) { - if (recording.getChannelId() == channel.getId()) { - mIsRecording = true; - mCurrentRecording = recording; - } - } - mDvrDataManager.addScheduledRecordingListener(this); - updateCardView(); - } - - @Override - public void onRecycled() { - super.onRecycled(); - mDvrDataManager.removeScheduledRecordingListener(this); - } - - public boolean isRecording() { - return mIsRecording; - } - - public void startRecording() { - showStartRecordingDialog(); - } - - public void stopRecording() { - mDvrManager.stopRecording(mCurrentRecording); - } - - private void updateCardView() { - if (mIsRecording) { - mIconView.setImageResource(R.drawable.ic_record_stop); - mLabelView.setText(R.string.channels_item_record_stop); - } else { - mIconView.setImageResource(R.drawable.ic_record_start); - mLabelView.setText(R.string.channels_item_record_start); - } - } - - private void showStartRecordingDialog() { - final long endOfProgram = -1; - - final List<CharSequence> items = new ArrayList<>(); - final List<Long> durations = new ArrayList<>(); - Resources res = getResources(); - items.add(res.getString(R.string.recording_start_dialog_10_min_duration)); - durations.add(TimeUnit.MINUTES.toMillis(10)); - items.add(res.getString(R.string.recording_start_dialog_30_min_duration)); - durations.add(TimeUnit.MINUTES.toMillis(30)); - items.add(res.getString(R.string.recording_start_dialog_1_hour_duration)); - durations.add(TimeUnit.HOURS.toMillis(1)); - items.add(res.getString(R.string.recording_start_dialog_3_hours_duration)); - durations.add(TimeUnit.HOURS.toMillis(3)); - - Program currenProgram = ((MainActivity) getContext()).getCurrentProgram(false); - if (currenProgram != null) { - long duration = currenProgram.getEndTimeUtcMillis() - System.currentTimeMillis(); - if (duration > MIN_PROGRAM_RECORD_DURATION) { - items.add(res.getString(R.string.recording_start_dialog_till_end_of_program)); - durations.add(duration); - } - } - - DialogInterface.OnClickListener onClickListener - = new DialogInterface.OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, int which) { - long startTime = System.currentTimeMillis(); - long endTime = System.currentTimeMillis() + durations.get(which); - mDvrManager.addSchedule(mCurrentChannel, startTime, endTime); - dialog.dismiss(); - } - }; - new AlertDialog.Builder(getContext()) - .setItems(items.toArray(new CharSequence[items.size()]), onClickListener) - .create() - .show(); - } - - @Override - public void onScheduledRecordingAdded(ScheduledRecording recording) { - } - - @Override - public void onScheduledRecordingRemoved(ScheduledRecording recording) { - if (recording.getChannelId() != mCurrentChannel.getId()) { - return; - } - if (mIsRecording) { - mIsRecording = false; - mCurrentRecording = null; - updateCardView(); - } - } - - @Override - public void onScheduledRecordingStatusChanged(ScheduledRecording recording) { - if (recording.getChannelId() != mCurrentChannel.getId()) { - return; - } - int state = recording.getState(); - if (state == ScheduledRecording.STATE_RECORDING_FAILED - || state == ScheduledRecording.STATE_RECORDING_FINISHED) { - mIsRecording = false; - mCurrentRecording = null; - updateCardView(); - } else if (state == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { - mIsRecording = true; - mCurrentRecording = recording; - updateCardView(); - } - } -} diff --git a/src/com/android/tv/menu/SetupCardView.java b/src/com/android/tv/menu/SetupCardView.java deleted file mode 100644 index 7ad5e9d0..00000000 --- a/src/com/android/tv/menu/SetupCardView.java +++ /dev/null @@ -1,53 +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.menu; - -import android.content.Context; -import android.util.AttributeSet; - -import com.android.tv.R; -import com.android.tv.data.Channel; - -/** - * A view to render a guide card. - */ -public class SetupCardView extends BaseCardView<Channel> { - private static final String TAG = "GuideCardView"; - private static final boolean DEBUG = false; - - private static final int INVALID_COUNT = -1; - - private final float mCardHeight; - - public SetupCardView(Context context) { - this(context, null, 0); - } - - public SetupCardView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public SetupCardView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - mCardHeight = getResources().getDimension(R.dimen.card_layout_height); - } - - @Override - protected float getCardHeight() { - return mCardHeight; - } -} diff --git a/src/com/android/tv/menu/SimpleCardView.java b/src/com/android/tv/menu/SimpleCardView.java index 24a44244..c99834be 100644 --- a/src/com/android/tv/menu/SimpleCardView.java +++ b/src/com/android/tv/menu/SimpleCardView.java @@ -19,16 +19,12 @@ package com.android.tv.menu; import android.content.Context; import android.util.AttributeSet; -import com.android.tv.R; import com.android.tv.data.Channel; /** * A view to render a guide card. */ public class SimpleCardView extends BaseCardView<Channel> { - private static final String TAG = "GuideCardView"; - private static final boolean DEBUG = false; - private final float mCardHeight; public SimpleCardView(Context context) { this(context, null, 0); @@ -40,11 +36,5 @@ public class SimpleCardView extends BaseCardView<Channel> { public SimpleCardView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); - mCardHeight = getResources().getDimension(R.dimen.card_layout_height); - } - - @Override - protected float getCardHeight() { - return mCardHeight; } } diff --git a/src/com/android/tv/menu/TvOptionsRowAdapter.java b/src/com/android/tv/menu/TvOptionsRowAdapter.java index ba84247b..fb062246 100644 --- a/src/com/android/tv/menu/TvOptionsRowAdapter.java +++ b/src/com/android/tv/menu/TvOptionsRowAdapter.java @@ -19,7 +19,6 @@ package com.android.tv.menu; import android.content.Context; import android.media.tv.TvTrackInfo; import android.support.annotation.VisibleForTesting; -import android.support.v4.os.BuildCompat; import com.android.tv.Features; import com.android.tv.R; @@ -28,6 +27,7 @@ import com.android.tv.customization.CustomAction; import com.android.tv.data.DisplayMode; import com.android.tv.ui.TvViewUiManager; import com.android.tv.ui.sidepanel.ClosedCaptionFragment; +import com.android.tv.ui.sidepanel.DeveloperOptionFragment; import com.android.tv.ui.sidepanel.DisplayModeFragment; import com.android.tv.ui.sidepanel.MultiAudioFragment; import com.android.tv.util.PipInputManager; @@ -39,14 +39,14 @@ import java.util.List; * An adapter of options. */ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { + private static final boolean ENABLE_IN_APP_PIP = false; + private int mPositionPipAction; // If mInAppPipAction is false, system-wide PIP is used. private boolean mInAppPipAction = true; - private final Context mContext; public TvOptionsRowAdapter(Context context, List<CustomAction> customActions) { super(context, customActions); - mContext = context; } @Override @@ -61,8 +61,9 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { mPositionPipAction = actionList.size() - 1; actionList.add(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); setOptionChangedListener(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); - if (Features.ONBOARDING_PLAY_STORE.isEnabled(getMainActivity())) { - actionList.add(MenuAction.MORE_CHANNELS_ACTION); + actionList.add(MenuAction.MORE_CHANNELS_ACTION); + if (DeveloperOptionFragment.shouldShow()) { + actionList.add(MenuAction.DEV_ACTION); } actionList.add(MenuAction.SETTINGS_ACTION); @@ -109,23 +110,23 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { // Case 1 PipInputManager pipInputManager = getMainActivity().getPipInputManager(); - if (pipInputManager.getPipInputSize(false) < 2) { + if (ENABLE_IN_APP_PIP && pipInputManager.getPipInputSize(false) > 1) { + if (!mInAppPipAction) { + removeAction(mPositionPipAction); + addAction(mPositionPipAction, MenuAction.PIP_IN_APP_ACTION); + mInAppPipAction = true; + changed = true; + } + } else { if (mInAppPipAction) { removeAction(mPositionPipAction); mInAppPipAction = false; - if (BuildCompat.isAtLeastN()) { + if (Features.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) { addAction(mPositionPipAction, MenuAction.SYSTEMWIDE_PIP_ACTION); } return true; } return false; - } else { - if (!mInAppPipAction) { - removeAction(mPositionPipAction); - addAction(mPositionPipAction, MenuAction.PIP_IN_APP_ACTION); - mInAppPipAction = true; - changed = true; - } } // Case 2 @@ -175,12 +176,12 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { protected void executeBaseAction(int type) { switch (type) { case TvOptionsManager.OPTION_CLOSED_CAPTIONS: - getMainActivity().getOverlayManager().getSideFragmentManager().show( - new ClosedCaptionFragment()); + getMainActivity().getOverlayManager().getSideFragmentManager() + .show(new ClosedCaptionFragment()); break; case TvOptionsManager.OPTION_DISPLAY_MODE: - getMainActivity().getOverlayManager().getSideFragmentManager().show( - new DisplayModeFragment()); + getMainActivity().getOverlayManager().getSideFragmentManager() + .show(new DisplayModeFragment()); break; case TvOptionsManager.OPTION_IN_APP_PIP: getMainActivity().togglePipView(); @@ -189,12 +190,16 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { getMainActivity().enterPictureInPictureMode(); break; case TvOptionsManager.OPTION_MULTI_AUDIO: - getMainActivity().getOverlayManager().getSideFragmentManager().show( - new MultiAudioFragment()); + getMainActivity().getOverlayManager().getSideFragmentManager() + .show(new MultiAudioFragment()); break; case TvOptionsManager.OPTION_MORE_CHANNELS: getMainActivity().showMerchantCollection(); break; + case TvOptionsManager.OPTION_DEVELOPER: + getMainActivity().getOverlayManager().getSideFragmentManager() + .show(new DeveloperOptionFragment()); + break; case TvOptionsManager.OPTION_SETTINGS: getMainActivity().showSettingsFragment(); break; diff --git a/src/com/android/tv/onboarding/NewSourcesFragment.java b/src/com/android/tv/onboarding/NewSourcesFragment.java index ebaf0b6c..8509b50c 100644 --- a/src/com/android/tv/onboarding/NewSourcesFragment.java +++ b/src/com/android/tv/onboarding/NewSourcesFragment.java @@ -17,7 +17,6 @@ package com.android.tv.onboarding; import android.app.Fragment; -import android.os.Build; import android.os.Bundle; import android.transition.Slide; import android.view.Gravity; @@ -27,7 +26,6 @@ import android.view.ViewGroup; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.common.ui.setup.OnActionClickListener; import com.android.tv.common.ui.setup.SetupActionHelper; import com.android.tv.util.SetupUtils; @@ -35,12 +33,20 @@ import com.android.tv.util.SetupUtils; * A fragment for new channel source info/setup. */ public class NewSourcesFragment extends Fragment { - public static final String ACTION_CATEOGRY = NewSourcesFragment.class.getCanonicalName(); + /** + * The action category. + */ + public static final String ACTION_CATEOGRY = + "com.android.tv.onboarding.NewSourcesFragment"; + /** + * An action to show the setup screen. + */ public static final int ACTION_SETUP = 1; + /** + * An action to close this fragment. + */ public static final int ACTION_SKIP = 2; - private OnActionClickListener mOnActionClickListener; - public NewSourcesFragment() { setAllowEnterTransitionOverlap(false); setAllowReturnTransitionOverlap(false); @@ -62,21 +68,8 @@ public class NewSourcesFragment extends Fragment { return view; } - /** - * Sets the {@link OnActionClickListener}. This method should be called before the views are - * created. - */ - public void setOnActionClickListener(OnActionClickListener onActionClickListener) { - mOnActionClickListener = onActionClickListener; - } - private void initializeButton(View view, int actionId) { - view.setOnClickListener(SetupActionHelper.createOnClickListenerForAction( - mOnActionClickListener, ACTION_CATEOGRY, actionId)); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Prior to M, foreground ripple animation is not supported. - // Use background ripple drawable instead of drawing in the foreground manually. - view.setBackground(getActivity().getDrawable(R.drawable.setup_selector_background)); - } + view.setOnClickListener(SetupActionHelper.createOnClickListenerForAction(this, + ACTION_CATEOGRY, actionId, null)); } } diff --git a/src/com/android/tv/onboarding/OnboardingActivity.java b/src/com/android/tv/onboarding/OnboardingActivity.java index 0685d14b..45205c4c 100644 --- a/src/com/android/tv/onboarding/OnboardingActivity.java +++ b/src/com/android/tv/onboarding/OnboardingActivity.java @@ -17,32 +17,40 @@ package com.android.tv.onboarding; import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.os.Build; +import android.media.tv.TvInputInfo; import android.os.Bundle; import android.support.annotation.NonNull; import android.widget.Toast; +import com.android.tv.ApplicationSingletons; import com.android.tv.R; +import com.android.tv.SetupPassthroughActivity; import com.android.tv.TvApplication; +import com.android.tv.common.TvCommonUtils; import com.android.tv.common.ui.setup.SetupActivity; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; import com.android.tv.data.ChannelDataManager; import com.android.tv.util.OnboardingUtils; import com.android.tv.util.PermissionUtils; import com.android.tv.util.SetupUtils; +import com.android.tv.util.TvInputManagerHelper; public class OnboardingActivity extends SetupActivity { private static final String KEY_INTENT_AFTER_COMPLETION = "key_intent_after_completion"; private static final int PERMISSIONS_REQUEST_READ_TV_LISTINGS = 1; - private static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS"; private static final int SHOW_RIPPLE_DURATION_MS = 266; + private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; + private ChannelDataManager mChannelDataManager; + private TvInputManagerHelper mInputManager; private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() { @Override public void onLoadFinished() { @@ -73,16 +81,20 @@ public class OnboardingActivity extends SetupActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!PermissionUtils.hasAccessAllEpg(this)) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Toast.makeText(this, R.string.msg_not_supported_device, Toast.LENGTH_LONG).show(); - finish(); - return; - } else if (checkSelfPermission(PERMISSION_READ_TV_LISTINGS) - != PackageManager.PERMISSION_GRANTED) { - requestPermissions(new String[]{PERMISSION_READ_TV_LISTINGS}, - PERMISSIONS_REQUEST_READ_TV_LISTINGS); + ApplicationSingletons singletons = TvApplication.getSingletons(this); + mInputManager = singletons.getTvInputManagerHelper(); + if (PermissionUtils.hasAccessAllEpg(this) || PermissionUtils.hasReadTvListings(this)) { + mChannelDataManager = singletons.getChannelDataManager(); + // Make the channels of the new inputs which have been setup outside Live TV + // browsable. + if (mChannelDataManager.isDbLoadFinished()) { + SetupUtils.getInstance(this).markNewChannelsBrowsable(); + } else { + mChannelDataManager.addListener(mChannelListener); } + } else { + requestPermissions(new String[] {PermissionUtils.PERMISSION_READ_TV_LISTINGS}, + PERMISSIONS_REQUEST_READ_TV_LISTINGS); } } @@ -97,14 +109,6 @@ public class OnboardingActivity extends SetupActivity { @Override protected Fragment onCreateInitialFragment() { if (PermissionUtils.hasAccessAllEpg(this) || PermissionUtils.hasReadTvListings(this)) { - // Make the channels of the new inputs which have been setup outside Live TV - // browsable. - mChannelDataManager = TvApplication.getSingletons(this).getChannelDataManager(); - if (mChannelDataManager.isDbLoadFinished()) { - SetupUtils.getInstance(this).markNewChannelsBrowsable(); - } else { - mChannelDataManager.addListener(mChannelListener); - } return OnboardingUtils.isFirstRunWithCurrentVersion(this) ? new WelcomeFragment() : new SetupSourcesFragment(); } @@ -115,8 +119,7 @@ public class OnboardingActivity extends SetupActivity { public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (requestCode == PERMISSIONS_REQUEST_READ_TV_LISTINGS) { - if (grantResults != null && grantResults.length > 0 - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { finish(); Intent intentForNextActivity = getIntent().getParcelableExtra( KEY_INTENT_AFTER_COMPLETION); @@ -129,7 +132,7 @@ public class OnboardingActivity extends SetupActivity { } } - void finishActivity() { + private void finishActivity() { Intent intentForNextActivity = getIntent().getParcelableExtra( KEY_INTENT_AFTER_COMPLETION); if (intentForNextActivity != null) { @@ -138,17 +141,17 @@ public class OnboardingActivity extends SetupActivity { finish(); } - void showMerchantCollection() { + private void showMerchantCollection() { executeActionWithDelay(new Runnable() { @Override public void run() { - startActivity(OnboardingUtils.PLAY_STORE_INTENT); + startActivity(OnboardingUtils.ONLINE_STORE_INTENT); } }, SHOW_RIPPLE_DURATION_MS); } @Override - protected void executeAction(String category, int actionId) { + protected boolean executeAction(String category, int actionId, Bundle params) { switch (category) { case WelcomeFragment.ACTION_CATEGORY: switch (actionId) { @@ -156,14 +159,40 @@ public class OnboardingActivity extends SetupActivity { OnboardingUtils.setFirstRunWithCurrentVersionCompleted( OnboardingActivity.this); showFragment(new SetupSourcesFragment(), false); - break; + return true; } break; case SetupSourcesFragment.ACTION_CATEGORY: switch (actionId) { - case SetupSourcesFragment.ACTION_PLAY_STORE: + case SetupSourcesFragment.ACTION_ONLINE_STORE: showMerchantCollection(); - break; + return true; + case SetupSourcesFragment.ACTION_SETUP_INPUT: { + String inputId = params.getString( + SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID); + TvInputInfo input = mInputManager.getTvInputInfo(inputId); + Intent intent = TvCommonUtils.createSetupIntent(input); + if (intent == null) { + Toast.makeText(this, R.string.msg_no_setup_activity, Toast.LENGTH_SHORT) + .show(); + return true; + } + // Even though other app can handle the intent, the setup launched by Live + // channels should go through Live channels SetupPassthroughActivity. + intent.setComponent(new ComponentName(this, + SetupPassthroughActivity.class)); + try { + // Now we know that the user intends to set up this input. Grant + // permission for writing EPG data. + SetupUtils.grantEpgPermission(this, input.getServiceInfo().packageName); + startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, + getString(R.string.msg_unable_to_start_setup_activity, + input.loadLabel(this)), Toast.LENGTH_SHORT).show(); + } + return true; + } case SetupMultiPaneFragment.ACTION_DONE: { ChannelDataManager manager = TvApplication.getSingletons( OnboardingActivity.this).getChannelDataManager(); @@ -172,10 +201,11 @@ public class OnboardingActivity extends SetupActivity { } else { finishActivity(); } - break; + return true; } } break; } + return false; } } diff --git a/src/com/android/tv/onboarding/SetupSourcesFragment.java b/src/com/android/tv/onboarding/SetupSourcesFragment.java index 23145503..7607822c 100644 --- a/src/com/android/tv/onboarding/SetupSourcesFragment.java +++ b/src/com/android/tv/onboarding/SetupSourcesFragment.java @@ -16,12 +16,8 @@ package com.android.tv.onboarding; -import android.content.ActivityNotFoundException; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; import android.graphics.Typeface; -import android.graphics.drawable.Drawable; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager.TvInputCallback; import android.os.Bundle; @@ -33,23 +29,18 @@ import android.support.v17.leanback.widget.VerticalGridView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageView; import android.widget.TextView; -import android.widget.Toast; import com.android.tv.ApplicationSingletons; -import com.android.tv.Features; import com.android.tv.R; -import com.android.tv.SetupPassthroughActivity; import com.android.tv.TvApplication; -import com.android.tv.common.TvCommonUtils; import com.android.tv.common.ui.setup.SetupGuidedStepFragment; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.TvInputNewComparator; +import com.android.tv.ui.GuidedActionsStylistWithDivider; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; -import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; @@ -59,17 +50,30 @@ import java.util.List; * A fragment for channel source info/setup. */ public class SetupSourcesFragment extends SetupMultiPaneFragment { - private static final String TAG = "SetupSourcesFragment"; - + /** + * The action category for the actions which is fired from this fragment. + */ public static final String ACTION_CATEGORY = "com.android.tv.onboarding.SetupSourcesFragment"; - public static final int ACTION_PLAY_STORE = 1; - - private static final String SETUP_TRACKER_LABEL = "Setup fragment"; + /** + * An action to open the merchant collection. + */ + public static final int ACTION_ONLINE_STORE = 1; + /** + * An action to show the setup activity of TV input. + * <p> + * This action is not added to the action list. This is sent outside of the fragment. + * Use {@link #ACTION_PARAM_KEY_INPUT_ID} to get the input ID from the parameter. + */ + public static final int ACTION_SETUP_INPUT = 2; - private InputSetupRunnable mInputSetupRunnable; + /** + * The key for the action parameter which contains the TV input ID. It's used for the action + * {@link #ACTION_SETUP_INPUT}. + */ + public static final String ACTION_PARAM_KEY_INPUT_ID = "input_id"; - private ContentFragment mContentFragment; + private static final String SETUP_TRACKER_LABEL = "Setup fragment"; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, @@ -81,19 +85,21 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { @Override protected void onEnterTransitionEnd() { - if (mContentFragment != null) { - mContentFragment.executePendingAction(); + SetupGuidedStepFragment f = getContentFragment(); + if (f instanceof ContentFragment) { + // If the enter transition is canceled quickly, the child fragment can be null because + // the fragment is added asynchronously. + ((ContentFragment) f).executePendingAction(); } } @Override protected SetupGuidedStepFragment onCreateContentFragment() { - mContentFragment = new ContentFragment(); - mContentFragment.setParentFragment(this); + SetupGuidedStepFragment f = new ContentFragment(); Bundle arguments = new Bundle(); arguments.putBoolean(SetupGuidedStepFragment.KEY_THREE_PANE, true); - mContentFragment.setArguments(arguments); - return mContentFragment; + f.setArguments(arguments); + return f; } @Override @@ -101,32 +107,8 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { return ACTION_CATEGORY; } - /** - * Call this method to run customized input setup. - * - * @param runnable runnable to be called when the input setup is necessary. - */ - public void setInputSetupRunnable(InputSetupRunnable runnable) { - mInputSetupRunnable = runnable; - } - - /** - * Interface for the customized input setup. - */ - public interface InputSetupRunnable { - /** - * Called for the input setup. - * - * @param input TV input for setup. - */ - void runInputSetup(TvInputInfo input); - } - public static class ContentFragment extends SetupGuidedStepFragment { - private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; - - // ACTION_PLAY_STORE is defined in the outer class. - private static final int ACTION_DIVIDER = 2; + // ACTION_ONLINE_STORE is defined in the outer class. private static final int ACTION_HEADER = 3; private static final int ACTION_INPUT_START = 4; @@ -163,6 +145,11 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { handleInputChanged(); } + @Override + public void onTvInputInfoUpdated(TvInputInfo inputInfo) { + handleInputChanged(); + } + private void handleInputChanged() { // The actions created while enter transition is running will not be included in the // fragment transition. @@ -175,10 +162,6 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { } }; - void setParentFragment(SetupSourcesFragment parentFragment) { - mParentFragment = parentFragment; - } - private final ChannelDataManager.Listener mChannelDataManagerListener = new ChannelDataManager.Listener() { @Override @@ -211,7 +194,6 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { @Override public void onCreate(Bundle savedInstanceState) { - // TODO: Handle USB TV tuner differently. Context context = getActivity(); ApplicationSingletons app = TvApplication.getSingletons(context); mInputManager = app.getTvInputManagerHelper(); @@ -221,6 +203,7 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { mInputManager.addCallback(mInputCallback); mChannelDataManager.addListener(mChannelDataManagerListener); super.onCreate(savedInstanceState); + mParentFragment = (SetupSourcesFragment) getParentFragment(); } @Override @@ -289,16 +272,24 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { int position = 0; if (mDoneInputStartIndex > 0) { // Need a "New" category - actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_HEADER) - .title(null).description(getString(R.string.setup_category_new)) - .focusable(false).build()); + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_HEADER) + .title(null) + .description(getString(R.string.setup_category_new)) + .focusable(false) + .infoOnly(true) + .build()); } for (int i = 0; i < mInputs.size(); ++i) { if (i == mDoneInputStartIndex) { ++position; - actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_HEADER) - .title(null).description(getString(R.string.setup_category_done)) - .focusable(false).build()); + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_HEADER) + .title(null) + .description(getString(R.string.setup_category_done)) + .focusable(false) + .infoOnly(true) + .build()); } TvInputInfo input = mInputs.get(i); String inputId = input.getId(); @@ -320,24 +311,26 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { if (input.getId().equals(mNewlyAddedInputId)) { newPosition = position; } - actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_INPUT_START + i) - .title(input.loadLabel(getActivity()).toString()).description(description) + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_INPUT_START + i) + .title(input.loadLabel(getActivity()).toString()) + .description(description) .build()); } - if (Features.ONBOARDING_PLAY_STORE.isEnabled(getActivity())) { - if (mInputs.size() > 0) { - // Divider - ++position; - actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DIVIDER) - .title(null).description(null).focusable(false).build()); - } - // Play store action + if (mInputs.size() > 0) { + // Divider ++position; - actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_PLAY_STORE) - .title(getString(R.string.setup_play_store_action_title)) - .description(getString(R.string.setup_play_store_action_description)) - .icon(R.drawable.ic_playstore).build()); + actions.add(GuidedActionsStylistWithDivider.createDividerAction(getContext())); } + // online store action + ++position; + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ONLINE_STORE) + .title(getString(R.string.setup_store_action_title)) + .description(getString(R.string.setup_store_action_description)) + .icon(R.drawable.ic_store) + .build()); + if (newPosition != -1) { VerticalGridView gridView = getGuidedActionsStylist().getActionsGridView(); gridView.setSelectedPosition(newPosition); @@ -351,38 +344,17 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { @Override public void onGuidedActionClicked(GuidedAction action) { - if (action.getId() == ACTION_PLAY_STORE) { + if (action.getId() == ACTION_ONLINE_STORE) { mParentFragment.onActionClick(ACTION_CATEGORY, (int) action.getId()); return; } - TvInputInfo input = mInputs.get((int) action.getId() - ACTION_INPUT_START); - if (mParentFragment.mInputSetupRunnable != null) { - mParentFragment.mInputSetupRunnable.runInputSetup(input); - return; + int index = (int) action.getId() - ACTION_INPUT_START; + if (index >= 0) { + TvInputInfo input = mInputs.get(index); + Bundle params = new Bundle(); + params.putString(ACTION_PARAM_KEY_INPUT_ID, input.getId()); + mParentFragment.onActionClick(ACTION_CATEGORY, ACTION_SETUP_INPUT, params); } - Intent intent = TvCommonUtils.createSetupIntent(input); - if (intent == null) { - Toast.makeText(getActivity(), R.string.msg_no_setup_activity, Toast.LENGTH_SHORT) - .show(); - return; - } - // Even though other app can handle the intent, the setup launched by Live channels - // should go through Live channels SetupPassthroughActivity. - intent.setComponent(new ComponentName(getActivity(), SetupPassthroughActivity.class)); - try { - // Now we know that the user intends to set up this input. Grant permission for - // writing EPG data. - SetupUtils.grantEpgPermission(getActivity(), input.getServiceInfo().packageName); - startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY); - } catch (ActivityNotFoundException e) { - Toast.makeText(getActivity(), getString(R.string.msg_unable_to_start_setup_activity, - input.loadLabel(getActivity())), Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - updateActions(); } void executePendingAction() { @@ -397,60 +369,28 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { mPendingAction = PENDING_ACTION_NONE; } - private class SetupSourceGuidedActionsStylist extends GuidedActionsStylist { - private static final int VIEW_TYPE_DIVIDER = 1; - + private class SetupSourceGuidedActionsStylist extends GuidedActionsStylistWithDivider { private static final float ALPHA_CATEGORY = 1.0f; private static final float ALPHA_INPUT_DESCRIPTION = 0.5f; @Override - public int getItemViewType(GuidedAction action) { - if (action.getId() == ACTION_DIVIDER) { - return VIEW_TYPE_DIVIDER; - } - return super.getItemViewType(action); - } - - @Override - public int onProvideItemLayoutId(int viewType) { - if (viewType == VIEW_TYPE_DIVIDER) { - return R.layout.onboarding_item_divider; - } - return super.onProvideItemLayoutId(viewType); - } - - @Override public void onBindViewHolder(ViewHolder vh, GuidedAction action) { super.onBindViewHolder(vh, action); TextView descriptionView = vh.getDescriptionView(); if (descriptionView != null) { if (action.getId() == ACTION_HEADER) { descriptionView.setAlpha(ALPHA_CATEGORY); - descriptionView.setTextColor(Utils.getColor(getResources(), - R.color.setup_category)); + descriptionView.setTextColor(getResources().getColor(R.color.setup_category, + null)); descriptionView.setTypeface(Typeface.create( getString(R.string.condensed_font), 0)); } else { descriptionView.setAlpha(ALPHA_INPUT_DESCRIPTION); - descriptionView.setTextColor(Utils.getColor(getResources(), - R.color.common_setup_input_description)); + descriptionView.setTextColor(getResources().getColor( + R.color.common_setup_input_description, null)); descriptionView.setTypeface(Typeface.create(getString(R.string.font), 0)); } } - // Workaround for b/26473407. - ImageView iconView = vh.getIconView(); - if (iconView != null) { - Drawable icon = action.getIcon(); - if (icon != null) { - // setImageDrawable resets the drawable's level unless we set the view level - // first. - iconView.setImageLevel(icon.getLevel()); - iconView.setImageDrawable(icon); - iconView.setVisibility(View.VISIBLE); - } else { - iconView.setVisibility(View.GONE); - } - } } } } diff --git a/src/com/android/tv/onboarding/WelcomeFragment.java b/src/com/android/tv/onboarding/WelcomeFragment.java index 00f7fe8d..f12233e9 100644 --- a/src/com/android/tv/onboarding/WelcomeFragment.java +++ b/src/com/android/tv/onboarding/WelcomeFragment.java @@ -591,12 +591,6 @@ public class WelcomeFragment extends OnboardingFragment { } @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - initialize(); - } - - @Override public void onAttach(Context context) { super.onAttach(context); initialize(); diff --git a/src/com/android/tv/receiver/BootCompletedReceiver.java b/src/com/android/tv/receiver/BootCompletedReceiver.java index da88f70d..8d6c5a14 100644 --- a/src/com/android/tv/receiver/BootCompletedReceiver.java +++ b/src/com/android/tv/receiver/BootCompletedReceiver.java @@ -21,11 +21,11 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.support.v4.os.BuildCompat; import android.util.Log; import com.android.tv.Features; import com.android.tv.TvActivity; +import com.android.tv.TvApplication; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.dvr.DvrRecordingService; import com.android.tv.recommendation.NotificationService; @@ -50,6 +50,7 @@ public class BootCompletedReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (DEBUG) Log.d(TAG, "boot completed " + intent); + TvApplication.setCurrentRunningProcess(context, true); // Start {@link NotificationService}. Intent notificationIntent = new Intent(context, NotificationService.class); notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION); @@ -73,7 +74,7 @@ public class BootCompletedReceiver extends BroadcastReceiver { } } - if (CommonFeatures.DVR.isEnabled(context) && BuildCompat.isAtLeastN()) { + if (CommonFeatures.DVR.isEnabled(context)) { DvrRecordingService.startService(context); } } diff --git a/src/com/android/tv/receiver/GlobalKeyReceiver.java b/src/com/android/tv/receiver/GlobalKeyReceiver.java index 2e19c089..8cd4fdf1 100644 --- a/src/com/android/tv/receiver/GlobalKeyReceiver.java +++ b/src/com/android/tv/receiver/GlobalKeyReceiver.java @@ -35,6 +35,7 @@ public class GlobalKeyReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + TvApplication.setCurrentRunningProcess(context, true); if (ACTION_GLOBAL_BUTTON.equals(intent.getAction())) { KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); if (DEBUG) Log.d(TAG, "onReceive: " + event); diff --git a/src/com/android/tv/receiver/PackageIntentsReceiver.java b/src/com/android/tv/receiver/PackageIntentsReceiver.java index 4c850402..26d000e7 100644 --- a/src/com/android/tv/receiver/PackageIntentsReceiver.java +++ b/src/com/android/tv/receiver/PackageIntentsReceiver.java @@ -29,6 +29,7 @@ public class PackageIntentsReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + TvApplication.setCurrentRunningProcess(context, true); ((TvApplication) context.getApplicationContext()).handleInputCountChanged(); } } diff --git a/src/com/android/tv/recommendation/NotificationService.java b/src/com/android/tv/recommendation/NotificationService.java index 0095482d..30ec73e3 100644 --- a/src/com/android/tv/recommendation/NotificationService.java +++ b/src/com/android/tv/recommendation/NotificationService.java @@ -28,7 +28,6 @@ import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Rect; import android.media.tv.TvInputInfo; -import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; @@ -52,7 +51,6 @@ import com.android.tv.data.Program; import com.android.tv.util.BitmapUtils; import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; import com.android.tv.util.ImageLoader; -import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -128,14 +126,8 @@ public class NotificationService extends Service implements Recommender.Listener @Override public void onCreate() { if (DEBUG) Log.d(TAG, "onCreate"); + TvApplication.setCurrentRunningProcess(this, true); super.onCreate(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M - && !PermissionUtils.hasAccessAllEpg(this)) { - Log.w(TAG, "Live TV requires the system permission on this platform."); - stopSelf(); - return; - } - mCurrentNotificationCount = 0; mNotificationChannels = new long[NOTIFICATION_COUNT]; for (int i = 0; i < NOTIFICATION_COUNT; ++i) { @@ -426,8 +418,7 @@ public class NotificationService extends Service implements Recommender.Listener } private void sendNotification(int notificationId, Bitmap channelLogo, Channel channel, - Bitmap posterArtBitmap, Program program, String inputDisplayName1) { - + Bitmap posterArtBitmap, Program program, String inputDisplayName) { final long programDurationMs = program.getEndTimeUtcMillis() - program .getStartTimeUtcMillis(); long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis(); @@ -442,16 +433,18 @@ public class NotificationService extends Service implements Recommender.Listener : overlayChannelLogo(channelLogo, posterArtBitmap); String channelDisplayName = channel.getDisplayName(); Notification notification = new Notification.Builder(this) - .setContentIntent(notificationIntent).setContentTitle(program.getTitle()) - .setContentText(inputDisplayName1 + " " + - (TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber() - : channelDisplayName)).setContentInfo(channelDisplayName) + .setContentIntent(notificationIntent) + .setContentTitle(program.getTitle()) + .setContentText(TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber() + : channelDisplayName) + .setContentInfo(channelDisplayName) .setAutoCancel(true).setLargeIcon(largeIconBitmap) .setSmallIcon(R.drawable.ic_launcher_s) .setCategory(Notification.CATEGORY_RECOMMENDATION) .setProgress((programProgress > 0) ? 100 : 0, programProgress, false) - .setSortKey(mRecommender.getChannelSortKey(channel.getId())).build(); - notification.color = Utils.getColor(getResources(), R.color.recommendation_card_background); + .setSortKey(mRecommender.getChannelSortKey(channel.getId())) + .build(); + notification.color = getResources().getColor(R.color.recommendation_card_background, null); if (!TextUtils.isEmpty(program.getThumbnailUri())) { notification.extras .putString(Notification.EXTRA_BACKGROUND_IMAGE_URI, program.getThumbnailUri()); diff --git a/src/com/android/tv/recommendation/RecommendationDataManager.java b/src/com/android/tv/recommendation/RecommendationDataManager.java index a7d4c46d..62ccd578 100644 --- a/src/com/android/tv/recommendation/RecommendationDataManager.java +++ b/src/com/android/tv/recommendation/RecommendationDataManager.java @@ -16,8 +16,8 @@ package com.android.tv.recommendation; +import android.annotation.SuppressLint; import android.content.Context; -import android.content.UriMatcher; import android.database.ContentObserver; import android.database.Cursor; import android.media.tv.TvContract; @@ -41,6 +41,7 @@ import com.android.tv.data.ChannelDataManager; import com.android.tv.data.Program; import com.android.tv.data.WatchedHistoryManager; import com.android.tv.util.PermissionUtils; +import com.android.tv.util.TvProviderUriMatcher; import java.util.ArrayList; import java.util.Collection; @@ -52,17 +53,6 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class RecommendationDataManager implements WatchedHistoryManager.Listener { - private static final UriMatcher sUriMatcher; - private static final int MATCH_CHANNEL = 1; - private static final int MATCH_CHANNEL_ID = 2; - private static final int MATCH_WATCHED_PROGRAM_ID = 3; - static { - sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL); - sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID); - sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID); - } - private static final int MSG_START = 1000; private static final int MSG_STOP = 1001; private static final int MSG_UPDATE_CHANNELS = 1002; @@ -130,8 +120,9 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener public synchronized static RecommendationDataManager acquireManager( Context context, @NonNull Listener listener) { if (sManager == null) { - sManager = new RecommendationDataManager(context, listener); + sManager = new RecommendationDataManager(context); } + sManager.addListener(listener); return sManager; } @@ -191,7 +182,7 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener public void onInputUpdated(String inputId) { } }; - private RecommendationDataManager(Context context, final Listener listener) { + private RecommendationDataManager(Context context) { mContext = context.getApplicationContext(); mHandlerThread = new HandlerThread("RecommendationDataManager"); mHandlerThread.start(); @@ -202,7 +193,6 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener runOnMainThread(new Runnable() { @Override public void run() { - addListener(listener); start(); } }); @@ -273,9 +263,13 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener .sendToTarget(); } - @MainThread private void addListener(Listener listener) { - mListeners.add(listener); + runOnMainThread(new Runnable() { + @Override + public void run() { + mListeners.add(listener); + } + }); } @MainThread @@ -493,7 +487,7 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener private ChannelRecord updateChannelRecordFromWatchedProgram(WatchedProgram program) { ChannelRecord channelRecord = null; - if (program != null && program.getWatchEndTimeMs() != 0l) { + if (program != null && program.getWatchEndTimeMs() != 0L) { channelRecord = mChannelRecordMap.get(program.getProgram().getChannelId()); if (channelRecord != null && channelRecord.getLastWatchEndTimeMs() < program.getWatchEndTimeMs()) { @@ -508,10 +502,11 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener super(handler); } + @SuppressLint("SwitchIntDef") @Override public void onChange(final boolean selfChange, final Uri uri) { - switch (sUriMatcher.match(uri)) { - case MATCH_WATCHED_PROGRAM_ID: + switch (TvProviderUriMatcher.match(uri)) { + case TvProviderUriMatcher.MATCH_WATCHED_PROGRAM_ID: if (!mHandler.hasMessages(MSG_UPDATE_WATCH_HISTORY, TvContract.WatchedPrograms.CONTENT_URI)) { mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY, uri).sendToTarget(); diff --git a/src/com/android/tv/search/DataManagerSearch.java b/src/com/android/tv/search/DataManagerSearch.java index d26ae334..5f89a21a 100644 --- a/src/com/android/tv/search/DataManagerSearch.java +++ b/src/com/android/tv/search/DataManagerSearch.java @@ -22,6 +22,7 @@ import android.media.tv.TvContentRating; import android.media.tv.TvContract; import android.media.tv.TvContract.Programs; import android.media.tv.TvInputManager; +import android.os.SystemClock; import android.support.annotation.MainThread; import android.text.TextUtils; import android.util.Log; @@ -50,8 +51,8 @@ import java.util.concurrent.Future; * and {@link ProgramDataManager}. */ public class DataManagerSearch implements SearchInterface { - private static final boolean DEBUG = false; private static final String TAG = "TvProviderSearch"; + private static final boolean DEBUG = false; private final Context mContext; private final TvInputManager mTvInputManager; @@ -98,6 +99,8 @@ public class DataManagerSearch implements SearchInterface { // Voice search query should be handled by the a system TV app. return results; } + if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'"); + long time = SystemClock.elapsedRealtime(); Set<Long> channelsFound = new HashSet<>(); List<Channel> channelList = mChannelDataManager.getBrowsableChannelList(); query = query.toLowerCase(); @@ -110,6 +113,11 @@ public class DataManagerSearch implements SearchInterface { addResult(results, channelsFound, channel, null); } if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" + + " searching channels: " + (SystemClock.elapsedRealtime() - time) + + "(msec)"); + } return results; } } @@ -124,9 +132,21 @@ public class DataManagerSearch implements SearchInterface { addResult(results, channelsFound, channel, null); } if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" + + " searching channels: " + (SystemClock.elapsedRealtime() - time) + + "(msec)"); + } return results; } } + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" + + " searching channels: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } + int channelResult = results.size(); + if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'"); + time = SystemClock.elapsedRealtime(); for (Channel channel : channelList) { if (channelsFound.contains(channel.getId())) { continue; @@ -140,6 +160,11 @@ public class DataManagerSearch implements SearchInterface { addResult(results, channelsFound, channel, program); } if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed" + + " time for searching programs: " + + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return results; } } @@ -156,9 +181,18 @@ public class DataManagerSearch implements SearchInterface { addResult(results, channelsFound, channel, program); } if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed" + + " time for searching programs: " + + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return results; } } + if (DEBUG) { + Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed time for" + + " searching programs: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return results; } diff --git a/src/com/android/tv/search/LocalSearchProvider.java b/src/com/android/tv/search/LocalSearchProvider.java index 7edb07dc..9255a43d 100644 --- a/src/com/android/tv/search/LocalSearchProvider.java +++ b/src/com/android/tv/search/LocalSearchProvider.java @@ -22,6 +22,7 @@ import android.content.ContentValues; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; +import android.os.SystemClock; import android.text.TextUtils; import android.util.Log; @@ -32,8 +33,8 @@ import java.util.Arrays; import java.util.List; public class LocalSearchProvider extends ContentProvider { - private static final boolean DEBUG = false; private static final String TAG = "LocalSearchProvider"; + private static final boolean DEBUG = false; public static final int PROGRESS_PERCENTAGE_HIDE = -1; @@ -76,10 +77,13 @@ public class LocalSearchProvider extends ContentProvider { Log.d(TAG, "query(" + uri + ", " + Arrays.toString(projection) + ", " + selection + ", " + Arrays.toString(selectionArgs) + ", " + sortOrder + ")"); } + long time = SystemClock.elapsedRealtime(); SearchInterface search; if (PermissionUtils.hasAccessAllEpg(getContext())) { + if (DEBUG) Log.d(TAG, "Performing TV Provider search."); search = new TvProviderSearch(getContext()); } else { + if (DEBUG) Log.d(TAG, "Performing Data Manager search."); search = new DataManagerSearch(getContext()); } String query = uri.getLastPathSegment(); @@ -95,7 +99,9 @@ public class LocalSearchProvider extends ContentProvider { if (!TextUtils.isEmpty(query)) { results.addAll(search.search(query, limit, action)); } - return createSuggestionsCursor(results); + Cursor c = createSuggestionsCursor(results); + if (DEBUG) Log.d(TAG, "Elapsed time: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + return c; } private Cursor createSuggestionsCursor(List<SearchResult> results) { diff --git a/src/com/android/tv/search/TvProviderSearch.java b/src/com/android/tv/search/TvProviderSearch.java index bd4ae5e5..3804f2de 100644 --- a/src/com/android/tv/search/TvProviderSearch.java +++ b/src/com/android/tv/search/TvProviderSearch.java @@ -28,6 +28,7 @@ import android.media.tv.TvContract.WatchedPrograms; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.net.Uri; +import android.os.SystemClock; import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; @@ -54,8 +55,8 @@ import java.util.Set; * An implementation of {@link SearchInterface} to search query from TvProvider directly. */ public class TvProviderSearch implements SearchInterface { - private static final boolean DEBUG = false; private static final String TAG = "TvProviderSearch"; + private static final boolean DEBUG = false; private static final int NO_LIMIT = 0; @@ -159,6 +160,8 @@ public class TvProviderSearch implements SearchInterface { @WorkerThread private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) { + if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'"); + long time = SystemClock.elapsedRealtime(); List<SearchResult> results = new ArrayList<>(); if (TextUtils.isDigitsOnly(query)) { results.addAll(searchChannels(query, new String[] { Channels.COLUMN_DISPLAY_NUMBER }, @@ -178,6 +181,10 @@ public class TvProviderSearch implements SearchInterface { for (SearchResult result : results) { fillProgramInfo(result); } + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for searching" + + " channels: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return results; } @@ -305,6 +312,8 @@ public class TvProviderSearch implements SearchInterface { @WorkerThread private List<SearchResult> searchPrograms(String query, String[] columnForExactMatching, String[] columnForPartialMatching, Set<Long> channelsFound, int limit) { + if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'"); + long time = SystemClock.elapsedRealtime(); Assert.assertTrue( (columnForExactMatching != null && columnForExactMatching.length > 0) || (columnForPartialMatching != null && columnForPartialMatching.length > 0)); @@ -395,6 +404,10 @@ public class TvProviderSearch implements SearchInterface { } } } + if (DEBUG) { + Log.d(TAG, "Found " + searchResults.size() + " programs. Elapsed time for searching" + + " programs: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return searchResults; } @@ -420,9 +433,8 @@ public class TvProviderSearch implements SearchInterface { } private List<SearchResult> searchInputs(String query, int limit) { - if (DEBUG) { - Log.d(TAG, "searchInputs(" + query + ", limit=" + limit + ")"); - } + if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'"); + long time = SystemClock.elapsedRealtime(); query = canonicalizeLabel(query); List<TvInputInfo> inputList = mTvInputManager.getTvInputList(); @@ -435,6 +447,11 @@ public class TvProviderSearch implements SearchInterface { if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) { results.add(buildSearchResultForInput(input.getId())); if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" + + " searching inputs: " + (SystemClock.elapsedRealtime() - time) + + "(msec)"); + } return results; } } @@ -448,10 +465,19 @@ public class TvProviderSearch implements SearchInterface { (customLabel != null && customLabel.contains(query))) { results.add(buildSearchResultForInput(input.getId())); if (results.size() >= limit) { + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" + + " searching inputs: " + (SystemClock.elapsedRealtime() - time) + + "(msec)"); + } return results; } } } + if (DEBUG) { + Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for searching" + + " inputs: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } return results; } diff --git a/src/com/android/tv/setup/SystemSetupActivity.java b/src/com/android/tv/setup/SystemSetupActivity.java new file mode 100644 index 00000000..7e627410 --- /dev/null +++ b/src/com/android/tv/setup/SystemSetupActivity.java @@ -0,0 +1,124 @@ +/* + * 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.setup; + +import android.app.Activity; +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.media.tv.TvInputInfo; +import android.os.Bundle; +import android.widget.Toast; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.R; +import com.android.tv.SetupPassthroughActivity; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.common.ui.setup.SetupActivity; +import com.android.tv.common.ui.setup.SetupFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.TvApplication; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.onboarding.SetupSourcesFragment; +import com.android.tv.util.OnboardingUtils; +import com.android.tv.util.SetupUtils; +import com.android.tv.util.TvInputManagerHelper; + +/** + * A activity to start input sources setup fragment for initial setup flow. + */ +public class SystemSetupActivity extends SetupActivity { + private static final String SYSTEM_SETUP = + "com.android.tv.action.LAUNCH_SYSTEM_SETUP"; + private static final int SHOW_RIPPLE_DURATION_MS = 266; + private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; + + private TvInputManagerHelper mInputManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = getIntent(); + if (!SYSTEM_SETUP.equals(intent.getAction())) { + finish(); + return; + } + ApplicationSingletons singletons = TvApplication.getSingletons(this); + mInputManager = singletons.getTvInputManagerHelper(); + } + + @Override + protected Fragment onCreateInitialFragment() { + return new SetupSourcesFragment(); + } + + private void showMerchantCollection() { + executeActionWithDelay(new Runnable() { + @Override + public void run() { + startActivity(OnboardingUtils.ONLINE_STORE_INTENT); + } + }, SHOW_RIPPLE_DURATION_MS); + } + + @Override + public boolean executeAction(String category, int actionId, Bundle params) { + switch (category) { + case SetupSourcesFragment.ACTION_CATEGORY: + switch (actionId) { + case SetupSourcesFragment.ACTION_ONLINE_STORE: + showMerchantCollection(); + return true; + case SetupSourcesFragment.ACTION_SETUP_INPUT: { + String inputId = params.getString( + SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID); + TvInputInfo input = mInputManager.getTvInputInfo(inputId); + Intent intent = TvCommonUtils.createSetupIntent(input); + if (intent == null) { + Toast.makeText(this, R.string.msg_no_setup_activity, Toast.LENGTH_SHORT) + .show(); + return true; + } + // Even though other app can handle the intent, the setup launched by Live + // channels should go through Live channels SetupPassthroughActivity. + intent.setComponent(new ComponentName(this, + SetupPassthroughActivity.class)); + try { + // Now we know that the user intends to set up this input. Grant + // permission for writing EPG data. + SetupUtils.grantEpgPermission(this, input.getServiceInfo().packageName); + startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, + getString(R.string.msg_unable_to_start_setup_activity, + input.loadLabel(this)), Toast.LENGTH_SHORT).show(); + } + return true; + } + case SetupMultiPaneFragment.ACTION_DONE: { + // To make sure user can finish setup flow, set result as RESULT_OK. + setResult(Activity.RESULT_OK); + finish(); + return true; + } + } + break; + } + return false; + } +} diff --git a/src/com/android/tv/tuner/ChannelScanFileParser.java b/src/com/android/tv/tuner/ChannelScanFileParser.java new file mode 100644 index 00000000..8b06aaa9 --- /dev/null +++ b/src/com/android/tv/tuner/ChannelScanFileParser.java @@ -0,0 +1,105 @@ +/* + * 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.tuner; + +import android.util.Log; + +import com.android.tv.tuner.data.Channel; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; + +/** + * Parses plain text formatted scan files, which contain the list of channels. + */ +public class ChannelScanFileParser { + private static final String TAG = "ChannelScanFileParser"; + + public static final class ScanChannel { + public final int type; + public final int frequency; + public final String modulation; + public final String filename; + /** + * Radio frequency (channel) number specified at + * https://en.wikipedia.org/wiki/North_American_television_frequencies + * This can be {@code null} for cases like cable signal. + */ + public final Integer radioFrequencyNumber; + + public static ScanChannel forTuner(int frequency, String modulation, + Integer radioFrequencyNumber) { + return new ScanChannel(Channel.TYPE_TUNER, frequency, modulation, null, + radioFrequencyNumber); + } + + public static ScanChannel forFile(int frequency, String filename) { + return new ScanChannel(Channel.TYPE_FILE, frequency, "file:", filename, null); + } + + private ScanChannel(int type, int frequency, String modulation, String filename, + Integer radioFrequencyNumber) { + this.type = type; + this.frequency = frequency; + this.modulation = modulation; + this.filename = filename; + this.radioFrequencyNumber = radioFrequencyNumber; + } + } + + /** + * Parses a given scan file and returns the list of {@link ScanChannel} objects. + * + * @param is {@link InputStream} of a scan file. Each line matches one channel. + * The line format of the scan file is as follows:<br> + * "A <frequency> <modulation>". + * @return a list of {@link ScanChannel} objects parsed + */ + public static List<ScanChannel> parseScanFile(InputStream is) { + BufferedReader in = new BufferedReader(new InputStreamReader(is)); + String line; + List<ScanChannel> scanChannelList = new ArrayList<>(); + try { + while ((line = in.readLine()) != null) { + if (line.isEmpty()) { + continue; + } + if (line.charAt(0) == '#') { + // Skip comment line + continue; + } + String[] tokens = line.split("\\s+"); + if (tokens.length != 3 && tokens.length != 4) { + continue; + } + if (!tokens[0].equals("A")) { + // Only support ATSC + continue; + } + scanChannelList.add(ScanChannel.forTuner(Integer.parseInt(tokens[1]), tokens[2], + tokens.length == 4 ? Integer.parseInt(tokens[3]) : null)); + } + } catch (IOException e) { + Log.e(TAG, "error on parseScanFile()", e); + } + return scanChannelList; + } +} diff --git a/src/com/android/tv/tuner/DvbDeviceAccessor.java b/src/com/android/tv/tuner/DvbDeviceAccessor.java new file mode 100644 index 00000000..4f5d8ee4 --- /dev/null +++ b/src/com/android/tv/tuner/DvbDeviceAccessor.java @@ -0,0 +1,223 @@ +/* + * 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.tuner; + +import android.content.Context; +import android.media.tv.TvInputManager; +import android.os.ParcelFileDescriptor; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.android.tv.common.recording.RecordingCapability; +import com.android.tv.tuner.tvinput.TunerTvInputService; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +/** + * Provides with the file descriptors to access DVB device. + */ +public class DvbDeviceAccessor { + private static final String TAG = "DvbDeviceAccessor"; + + @IntDef({DVB_DEVICE_DEMUX, DVB_DEVICE_DVR, DVB_DEVICE_FRONTEND}) + @Retention(RetentionPolicy.SOURCE) + public @interface DvbDevice {} + public static final int DVB_DEVICE_DEMUX = 0; // TvInputManager.DVB_DEVICE_DEMUX; + public static final int DVB_DEVICE_DVR = 1; // TvInputManager.DVB_DEVICE_DVR; + public static final int DVB_DEVICE_FRONTEND = 2; // TvInputManager.DVB_DEVICE_FRONTEND; + + private static Method sGetDvbDeviceListMethod; + private static Method sOpenDvbDeviceMethod; + + private final TvInputManager mTvInputManager; + + static { + try { + Class tvInputManagerClass = Class.forName("android.media.tv.TvInputManager"); + Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo"); + sGetDvbDeviceListMethod = tvInputManagerClass.getDeclaredMethod("getDvbDeviceList"); + sGetDvbDeviceListMethod.setAccessible(true); + sOpenDvbDeviceMethod = tvInputManagerClass.getDeclaredMethod( + "openDvbDevice", dvbDeviceInfoClass, Integer.TYPE); + sOpenDvbDeviceMethod.setAccessible(true); + } catch (ClassNotFoundException e) { + Log.e(TAG, "Couldn't find class", e); + } catch (NoSuchMethodException e) { + Log.e(TAG, "Couldn't find method", e); + } + } + + public DvbDeviceAccessor(Context context) { + mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); + } + + public List<DvbDeviceInfoWrapper> getDvbDeviceList() { + try { + List<DvbDeviceInfoWrapper> wrapperList = new ArrayList<>(); + List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager); + for (Object dvbDeviceInfo : dvbDeviceInfoList) { + wrapperList.add(new DvbDeviceInfoWrapper(dvbDeviceInfo)); + } + Collections.sort(wrapperList); + return wrapperList; + } catch (IllegalAccessException e) { + Log.e(TAG, "Couldn't access", e); + } catch (InvocationTargetException e) { + Log.e(TAG, "Couldn't invoke", e); + } + return null; + } + + /** + * Returns the number of currently connected DVB devices. + */ + public int getNumOfDvbDevices() { + List<DvbDeviceInfoWrapper> dvbDeviceList = getDvbDeviceList(); + return dvbDeviceList == null ? 0 : dvbDeviceList.size(); + } + + public boolean isDvbDeviceAvailable() { + try { + List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager); + return (!dvbDeviceInfoList.isEmpty()); + } catch (IllegalAccessException e) { + Log.e(TAG, "Couldn't access", e); + } catch (InvocationTargetException e) { + Log.e(TAG, "Couldn't invoke", e); + } + return false; + } + + public ParcelFileDescriptor openDvbDevice(DvbDeviceInfoWrapper deviceInfo, + @DvbDevice int device) { + try { + return (ParcelFileDescriptor) sOpenDvbDeviceMethod.invoke( + mTvInputManager, deviceInfo.getDvbDeviceInfo(), device); + } catch (IllegalAccessException e) { + Log.e(TAG, "Couldn't access", e); + } catch (InvocationTargetException e) { + Log.e(TAG, "Couldn't invoke", e); + } + return null; + } + + /** + * Returns the current recording capability for USB tuner. + * @param inputId the input id to use. + */ + public RecordingCapability getRecordingCapability(String inputId) { + List<DvbDeviceInfoWrapper> deviceList = getDvbDeviceList(); + // TODO(DVR) implement accurate capabilities and updating values when needed. + return RecordingCapability.builder() + .setInputId(inputId) + .setMaxConcurrentPlayingSessions(1) + .setMaxConcurrentTunedSessions(deviceList.size()) + .setMaxConcurrentSessionsOfAllTypes(deviceList.size() + 1) + .build(); + } + + public static class DvbDeviceInfoWrapper implements Comparable<DvbDeviceInfoWrapper> { + private static Method sGetAdapterIdMethod; + private static Method sGetDeviceIdMethod; + private final Object mDvbDeviceInfo; + private final int mAdapterId; + private final int mDeviceId; + private final long mId; + + static { + try { + Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo"); + sGetAdapterIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getAdapterId"); + sGetAdapterIdMethod.setAccessible(true); + sGetDeviceIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getDeviceId"); + sGetDeviceIdMethod.setAccessible(true); + } catch (ClassNotFoundException e) { + Log.e(TAG, "Couldn't find class", e); + } catch (NoSuchMethodException e) { + Log.e(TAG, "Couldn't find method", e); + } + } + + public DvbDeviceInfoWrapper(Object dvbDeviceInfo) { + mDvbDeviceInfo = dvbDeviceInfo; + mAdapterId = initAdapterId(); + mDeviceId = initDeviceId(); + mId = (((long) getAdapterId()) << 32) | (getDeviceId() & 0xffffffffL); + } + + public long getId() { + return mId; + } + + public int getAdapterId() { + return mAdapterId; + } + + private int initAdapterId() { + try { + return (int) sGetAdapterIdMethod.invoke(mDvbDeviceInfo); + } catch (InvocationTargetException e) { + Log.e(TAG, "Couldn't invoke", e); + } catch (IllegalAccessException e) { + Log.e(TAG, "Couldn't access", e); + } + return -1; + } + + public int getDeviceId() { + return mDeviceId; + } + + private int initDeviceId() { + try { + return (int) sGetDeviceIdMethod.invoke(mDvbDeviceInfo); + } catch (InvocationTargetException e) { + Log.e(TAG, "Couldn't invoke", e); + } catch (IllegalAccessException e) { + Log.e(TAG, "Couldn't access", e); + } + return -1; + } + + public Object getDvbDeviceInfo() { + return mDvbDeviceInfo; + } + + @Override + public int compareTo(@NonNull DvbDeviceInfoWrapper another) { + if (getAdapterId() != another.getAdapterId()) { + return getAdapterId() - another.getAdapterId(); + } + return getDeviceId() - another.getDeviceId(); + } + + @Override + public String toString() { + return String.format(Locale.US, "DvbDeviceInfo {adapterId: %d, deviceId: %d}", + getAdapterId(), + getDeviceId()); + } + } +} diff --git a/src/com/android/tv/tuner/TunerHal.java b/src/com/android/tv/tuner/TunerHal.java new file mode 100644 index 00000000..de19766e --- /dev/null +++ b/src/com/android/tv/tuner/TunerHal.java @@ -0,0 +1,259 @@ +/* + * 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.tuner; + +import android.content.Context; +import android.support.annotation.IntDef; +import android.support.annotation.StringDef; +import android.util.Log; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Objects; + +/** + * A base class to handle a hardware tuner device. + */ +public abstract class TunerHal implements AutoCloseable { + protected static final String TAG = "TunerHal"; + protected static final boolean DEBUG = false; + + @IntDef({ FILTER_TYPE_OTHER, FILTER_TYPE_AUDIO, FILTER_TYPE_VIDEO, FILTER_TYPE_PCR }) + @Retention(RetentionPolicy.SOURCE) + public @interface FilterType {} + public static final int FILTER_TYPE_OTHER = 0; + public static final int FILTER_TYPE_AUDIO = 1; + public static final int FILTER_TYPE_VIDEO = 2; + public static final int FILTER_TYPE_PCR = 3; + + @StringDef({ MODULATION_8VSB, MODULATION_QAM256 }) + @Retention(RetentionPolicy.SOURCE) + public @interface ModulationType {} + public static final String MODULATION_8VSB = "8VSB"; + public static final String MODULATION_QAM256 = "QAM256"; + + public static final int TUNER_TYPE_BUILT_IN = 1; + public static final int TUNER_TYPE_USB = 2; + + protected static final int PID_PAT = 0; + protected static final int PID_ATSC_SI_BASE = 0x1ffb; + protected static final int DEFAULT_VSB_TUNE_TIMEOUT_MS = 2000; + protected static final int DEFAULT_QAM_TUNE_TIMEOUT_MS = 4000; // Some device takes time for + // QAM256 tuning. + private boolean mIsStreaming; + private int mFrequency; + private String mModulation; + + static { + System.loadLibrary("tunertvinput_jni"); + } + + /** + * Creates a TunerHal instance. + * @param context context for creating the TunerHal instance + * @return the TunerHal instance + */ + public synchronized static TunerHal createInstance(Context context) { + TunerHal tunerHal = null; + if (getTunerType(context) == TUNER_TYPE_BUILT_IN) { + } + if (tunerHal == null) { + tunerHal = new UsbTunerHal(context); + } + if (tunerHal.openFirstAvailable()) { + return tunerHal; + } + return null; + } + + /** + * Gets the number of tuner devices currently present. + */ + public static int getTunerCount(Context context) { + if (getTunerType(context) == TUNER_TYPE_BUILT_IN) { + } + return UsbTunerHal.getNumberOfDevices(context); + } + + /** + * Gets the type of tuner devices currently used. + */ + public static int getTunerType(Context context) { + return TUNER_TYPE_USB; + } + + protected TunerHal(Context context) { + mIsStreaming = false; + mFrequency = -1; + mModulation = null; + } + + protected boolean isStreaming() { + return mIsStreaming; + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + close(); + } + + protected native void nativeFinalize(long deviceId); + + /** + * Acquires the first available tuner device. If there is a tuner device that is available, the + * tuner device will be locked to the current instance. + * + * @return {@code true} if the operation was successful, {@code false} otherwise + */ + protected abstract boolean openFirstAvailable(); + + protected abstract boolean isDeviceOpen(); + + protected abstract long getDeviceId(); + + /** + * Sets the tuner channel. This should be called after acquiring a tuner device. + * + * @param frequency a frequency of the channel to tune to + * @param modulation a modulation method of the channel to tune to + * @return {@code true} if the operation was successful, {@code false} otherwise + */ + public synchronized boolean tune(int frequency, @ModulationType String modulation) { + if (!isDeviceOpen()) { + Log.e(TAG, "There's no available device"); + return false; + } + if (mIsStreaming) { + nativeCloseAllPidFilters(getDeviceId()); + mIsStreaming = false; + } + + // When tuning to a new channel in the same frequency, there's no need to stop current tuner + // device completely and the only thing necessary for tuning is reopening pid filters. + if (mFrequency == frequency && Objects.equals(mModulation, modulation)) { + addPidFilter(PID_PAT, FILTER_TYPE_OTHER); + addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER); + mIsStreaming = true; + return true; + } + int timeout_ms = modulation.equals(MODULATION_8VSB) ? DEFAULT_VSB_TUNE_TIMEOUT_MS + : DEFAULT_QAM_TUNE_TIMEOUT_MS; + if (nativeTune(getDeviceId(), frequency, modulation, timeout_ms)) { + addPidFilter(PID_PAT, FILTER_TYPE_OTHER); + addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER); + mFrequency = frequency; + mModulation = modulation; + mIsStreaming = true; + return true; + } + return false; + } + + protected native boolean nativeTune(long deviceId, int frequency, + @ModulationType String modulation, int timeout_ms); + + /** + * Sets a pid filter. This should be set after setting a channel. + * + * @param pid a pid number to be added to filter list + * @param filterType a type of pid. Must be one of (FILTER_TYPE_XXX) + * @return {@code true} if the operation was successful, {@code false} otherwise + */ + public synchronized boolean addPidFilter(int pid, @FilterType int filterType) { + if (!isDeviceOpen()) { + Log.e(TAG, "There's no available device"); + return false; + } + if (pid >= 0 && pid <= 0x1fff) { + nativeAddPidFilter(getDeviceId(), pid, filterType); + return true; + } + return false; + } + + protected native void nativeAddPidFilter(long deviceId, int pid, @FilterType int filterType); + protected native void nativeCloseAllPidFilters(long deviceId); + protected native void nativeSetHasPendingTune(long deviceId, boolean hasPendingTune); + + /** + * Stops current tuning. The tuner device and pid filters will be reset by this call and make + * the tuner ready to accept another tune request. + */ + public synchronized void stopTune() { + if (isDeviceOpen()) { + if (mIsStreaming) { + nativeCloseAllPidFilters(getDeviceId()); + } + nativeStopTune(getDeviceId()); + } + mIsStreaming = false; + mFrequency = -1; + mModulation = null; + } + + public void setHasPendingTune(boolean hasPendingTune) { + nativeSetHasPendingTune(getDeviceId(), hasPendingTune); + } + + protected native void nativeStopTune(long deviceId); + + /** + * This method must be called after {@link TunerHal#tune} and before + * {@link TunerHal#stopTune}. Writes at most maxSize TS frames in a buffer + * provided by the user. The frames employ MPEG encoding. + * + * @param javaBuffer a buffer to write the video data in + * @param javaBufferSize the max amount of bytes to write in this buffer. Usually this number + * should be equal to the length of the buffer. + * @return the amount of bytes written in the buffer. Note that this value could be 0 if no new + * frames have been obtained since the last call. + */ + public synchronized int readTsStream(byte[] javaBuffer, int javaBufferSize) { + if (isDeviceOpen()) { + return nativeWriteInBuffer(getDeviceId(), javaBuffer, javaBufferSize); + } else { + return 0; + } + } + + protected native int nativeWriteInBuffer(long deviceId, byte[] javaBuffer, int javaBufferSize); + + /** + * Opens Linux DVB frontend device. This method is called from native JNI and used only for + * UsbTunerHal. + */ + protected int openDvbFrontEndFd() { + return -1; + } + + /** + * Opens Linux DVB demux device. This method is called from native JNI and used only for + * UsbTunerHal. + */ + protected int openDvbDemuxFd() { + return -1; + } + + /** + * Opens Linux DVB dvr device. This method is called from native JNI and used only for + * UsbTunerHal. + */ + protected int openDvbDvrFd() { + return -1; + } +} diff --git a/src/com/android/tv/tuner/TunerInputController.java b/src/com/android/tv/tuner/TunerInputController.java new file mode 100644 index 00000000..d89b6a0c --- /dev/null +++ b/src/com/android/tv/tuner/TunerInputController.java @@ -0,0 +1,192 @@ +/* + * 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.tuner; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.media.tv.TvInputInfo; +import android.media.tv.TvInputManager; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.support.v4.os.BuildCompat; +import android.util.Log; +import android.widget.Toast; + +import com.android.tv.Features; +import com.android.tv.TvApplication; +import com.android.tv.tuner.R; +import com.android.tv.tuner.setup.TunerSetupActivity; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.TunerInputInfoUtils; + +import java.util.Map; + +/** + * Controls the package visibility of {@link TunerTvInputService}. + * <p> + * Listens to broadcast intent for {@link Intent#ACTION_BOOT_COMPLETED}, + * {@code UsbManager.ACTION_USB_DEVICE_ATTACHED}, and {@code UsbManager.ACTION_USB_DEVICE_ATTACHED} + * to update the connection status of the supported USB TV tuners. + */ +public class TunerInputController extends BroadcastReceiver { + private static final boolean DEBUG = true; + private static final String TAG = "TunerInputController"; + + private static final TunerDevice[] TUNER_DEVICES = { + new TunerDevice(0x2040, 0xb123), // WinTV-HVR-955Q + new TunerDevice(0x07ca, 0x0837) // AverTV Volar Hybrid Q + }; + + private static final int MSG_ENABLE_INPUT_SERVICE = 1000; + private static final long DVB_DRIVER_CHECK_DELAY_MS = 300; + + private DvbDeviceAccessor mDvbDeviceAccessor; + private final Handler mHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_ENABLE_INPUT_SERVICE: + Context context = (Context) msg.obj; + if (mDvbDeviceAccessor == null) { + mDvbDeviceAccessor = new DvbDeviceAccessor(context); + } + enableTunerTvInputService(context, mDvbDeviceAccessor.isDvbDeviceAvailable()); + break; + } + } + }; + + /** + * Simple data holder for a USB device. Used to represent a tuner model, and compare + * against {@link UsbDevice}. + */ + private static class TunerDevice { + private final int vendorId; + private final int productId; + + private TunerDevice(int vendorId, int productId) { + this.vendorId = vendorId; + this.productId = productId; + } + + private boolean equals(UsbDevice device) { + return device.getVendorId() == vendorId && device.getProductId() == productId; + } + } + + @Override + public void onReceive(Context context, Intent intent) { + if (DEBUG) Log.d(TAG, "Broadcast intent received:" + intent); + TvApplication.setCurrentRunningProcess(context, true); + if (!Features.TUNER.isEnabled(context)) { + enableTunerTvInputService(context, false); + return; + } + + switch (intent.getAction()) { + case Intent.ACTION_BOOT_COMPLETED: + case TvApplication.ACTION_APPLICATION_FIRST_LAUNCHED: + case UsbManager.ACTION_USB_DEVICE_ATTACHED: + case UsbManager.ACTION_USB_DEVICE_DETACHED: + if (TunerInputInfoUtils.isBuiltInTuner(context)) { + enableTunerTvInputService(context, true); + break; + } + // Falls back to the below to check USB tuner devices. + boolean enabled = isUsbTunerConnected(context); + mHandler.removeMessages(MSG_ENABLE_INPUT_SERVICE); + if (enabled) { + // Need to check if DVB driver is accessible. Since the driver creation + // could be happen after the USB event, delay the checking by + // DVB_DRIVER_CHECK_DELAY_MS. + mHandler.sendMessageDelayed( + mHandler.obtainMessage(MSG_ENABLE_INPUT_SERVICE, context), + DVB_DRIVER_CHECK_DELAY_MS); + } else { + enableTunerTvInputService(context, false); + } + break; + } + } + + /** + * See if any USB tuner hardware is attached in the system. + * + * @param context {@link Context} instance + * @return {@code true} if any tuner device we support is plugged in + */ + private boolean isUsbTunerConnected(Context context) { + UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + Map<String, UsbDevice> deviceList = manager.getDeviceList(); + for (UsbDevice device : deviceList.values()) { + if (DEBUG) { + Log.d(TAG, "Device: " + device); + } + for (TunerDevice tuner : TUNER_DEVICES) { + if (tuner.equals(device)) { + Log.i(TAG, "Tuner found"); + return true; + } + } + } + return false; + } + + /** + * Enable/disable the component {@link TunerTvInputService}. + * + * @param context {@link Context} instance + * @param enabled {@code true} to enable the service; otherwise {@code false} + */ + private void enableTunerTvInputService(Context context, boolean enabled) { + if (DEBUG) Log.d(TAG, "enableTunerTvInputService: " + enabled); + PackageManager pm = context.getPackageManager(); + ComponentName componentName = new ComponentName(context, TunerTvInputService.class); + + // Don't kill app by enabling/disabling TvActivity. If LC is killed by enabling/disabling + // TvActivity, the following pm.setComponentEnabledSetting doesn't work. + ((TvApplication) context.getApplicationContext()).handleInputCountChanged( + true, enabled, true); + // Since PackageManager.DONT_KILL_APP delays the operation by 10 seconds + // (PackageManagerService.BROADCAST_DELAY), we'd better avoid using it. It is used only + // when the LiveChannels app is active since we don't want to kill the running app. + int flags = TvApplication.getSingletons(context).getMainActivityWrapper().isCreated() + ? PackageManager.DONT_KILL_APP : 0; + int newState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED + : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; + if (newState != pm.getComponentEnabledSetting(componentName)) { + // Send/cancel the USB tuner TV input setup recommendation card. + TunerSetupActivity.onTvInputEnabled(context, enabled); + // Enable/disable the USB tuner TV input. + pm.setComponentEnabledSetting(componentName, newState, flags); + if (!enabled) { + Toast.makeText( + context, R.string.msg_usb_device_detached, Toast.LENGTH_SHORT).show(); + } + if (DEBUG) Log.d(TAG, "Status updated:" + enabled); + } else if (enabled) { + // When # of USB tuners is changed or the device just boots. + TunerInputInfoUtils.updateTunerInputInfo(context); + } + } +} diff --git a/src/com/android/tv/tuner/TunerPreferenceProvider.java b/src/com/android/tv/tuner/TunerPreferenceProvider.java new file mode 100644 index 00000000..3a3561b6 --- /dev/null +++ b/src/com/android/tv/tuner/TunerPreferenceProvider.java @@ -0,0 +1,203 @@ +/* + * 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.tuner; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +/** + * A content provider for storing the preferences. It's used across TV app and USB tuner TV input. + */ +public class TunerPreferenceProvider extends ContentProvider { + /** The authority of the provider */ + public static final String AUTHORITY = "com.android.tv.tuner.preferences"; + + private static final String PATH_PREFERENCES = "preferences"; + + private static final int DATABASE_VERSION = 1; + private static final String DATABASE_NAME = "usbtuner_preferences.db"; + private static final String PREFERENCES_TABLE = "preferences"; + + private static final int MATCH_PREFERENCE = 1; + private static final int MATCH_PREFERENCE_KEY = 2; + + private static final UriMatcher sUriMatcher; + + private DatabaseOpenHelper mDatabaseOpenHelper; + + static { + sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + sUriMatcher.addURI(AUTHORITY, "preferences", MATCH_PREFERENCE); + sUriMatcher.addURI(AUTHORITY, "preferences/*", MATCH_PREFERENCE_KEY); + } + + /** + * Builds a Uri that points to a specific preference. + + * @param key a key of the preference to point to + */ + public static Uri buildPreferenceUri(String key) { + return Preferences.CONTENT_URI.buildUpon().appendPath(key).build(); + } + + /** + * Columns definitions for the preferences table. + */ + public interface Preferences { + + /** + * The content:// style for the preferences table. + */ + Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + PATH_PREFERENCES); + + /** + * The MIME type of a directory of preferences. + */ + String CONTENT_TYPE = "vnd.android.cursor.dir/preferences"; + + /** + * The MIME type of a single preference. + */ + String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/preferences"; + + /** + * The ID of this preference. + * + * <p>This is auto-incremented. + * + * <p>Type: INTEGER + */ + String _ID = "_id"; + + /** + * The key of this preference. + * + * <p>Should be unique. + * + * <p>Type: TEXT + */ + String COLUMN_KEY = "key"; + + /** + * The value of this preference. + * + * <p>Type: TEXT + */ + String COLUMN_VALUE = "value"; + } + + private static class DatabaseOpenHelper extends SQLiteOpenHelper { + public DatabaseOpenHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + PREFERENCES_TABLE + " (" + + Preferences._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Preferences.COLUMN_KEY + " TEXT NOT NULL," + + Preferences.COLUMN_VALUE + " TEXT);"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // No-op + } + } + + @Override + public boolean onCreate() { + mDatabaseOpenHelper = new DatabaseOpenHelper(getContext()); + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + int match = sUriMatcher.match(uri); + if (match != MATCH_PREFERENCE && match != MATCH_PREFERENCE_KEY) { + throw new UnsupportedOperationException(); + } + SQLiteDatabase db = mDatabaseOpenHelper.getReadableDatabase(); + Cursor cursor = db.query(PREFERENCES_TABLE, projection, selection, selectionArgs, + null, null, sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + @Override + public String getType(Uri uri) { + switch (sUriMatcher.match(uri)) { + case MATCH_PREFERENCE: + return Preferences.CONTENT_TYPE; + case MATCH_PREFERENCE_KEY: + return Preferences.CONTENT_ITEM_TYPE; + } + throw new IllegalArgumentException("Unknown URI " + uri); + } + + /** + * Inserts a preference row into the preference table. + * + * If a key is already exists in the table, it removes the old row and inserts a new row. + * + * @param uri the URL of the table to insert into + * @param values the initial values for the newly inserted row + * @return the URL of the newly created row + */ + @Override + public Uri insert(Uri uri, ContentValues values) { + if (sUriMatcher.match(uri) != MATCH_PREFERENCE) { + throw new UnsupportedOperationException(); + } + return insertRow(uri, values); + } + + private Uri insertRow(Uri uri, ContentValues values) { + SQLiteDatabase db = mDatabaseOpenHelper.getWritableDatabase(); + + // Remove the old row. + db.delete(PREFERENCES_TABLE, Preferences.COLUMN_KEY + " like ?", + new String[]{values.getAsString(Preferences.COLUMN_KEY)}); + + long rowId = db.insert(PREFERENCES_TABLE, null, values); + if (rowId > 0) { + Uri rowUri = buildPreferenceUri(values.getAsString(Preferences.COLUMN_KEY)); + getContext().getContentResolver().notifyChange(rowUri, null); + return rowUri; + } + + throw new SQLiteException("Failed to insert row into " + uri); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/com/android/tv/tuner/TunerPreferences.java b/src/com/android/tv/tuner/TunerPreferences.java new file mode 100644 index 00000000..1547e3ae --- /dev/null +++ b/src/com/android/tv/tuner/TunerPreferences.java @@ -0,0 +1,310 @@ +/* + * 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.tuner; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.support.annotation.MainThread; + +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.TunerPreferenceProvider.Preferences; +import com.android.tv.tuner.util.TisConfiguration; + +/** + * A helper class for the USB tuner preferences. + */ +public class TunerPreferences { + private static final String TAG = "TunerPreferences"; + + private static final String PREFS_KEY_CHANNEL_DATA_VERSION = "channel_data_version"; + private static final String PREFS_KEY_SCANNED_CHANNEL_COUNT = "scanned_channel_count"; + private static final String PREFS_KEY_SCAN_DONE = "scan_done"; + private static final String PREFS_KEY_LAUNCH_SETUP = "launch_setup"; + private static final String PREFS_KEY_STORE_TS_STREAM = "store_ts_stream"; + + private static final String SHARED_PREFS_NAME = "com.android.tv.tuner.preferences"; + + public static final int CHANNEL_DATA_VERSION_NOT_SET = -1; + + private static final Bundle sPreferenceValues = new Bundle(); + private static LoadPreferencesTask sLoadPreferencesTask; + private static ContentObserver sContentObserver; + + private static boolean sInitialized; + + /** + * Initializes the USB tuner preferences. + */ + @MainThread + public static void initialize(final Context context) { + if (sInitialized) { + return; + } + sInitialized = true; + if (useContentProvider(context)) { + loadPreferences(context); + sContentObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + loadPreferences(context); + } + }; + context.getContentResolver().registerContentObserver( + TunerPreferenceProvider.Preferences.CONTENT_URI, true, sContentObserver); + } else { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + getSharedPreferences(context); + return null; + } + }.execute(); + } + } + + /** + * Releases the resources. + */ + @MainThread + public static void release(Context context) { + if (useContentProvider(context) && sContentObserver != null) { + context.getContentResolver().unregisterContentObserver(sContentObserver); + } + } + + /** + * Loads the preferences from database. + * <p> + * This preferences is used across processes, so the preferences should be loaded again when the + * databases changes. + */ + public static synchronized void loadPreferences(Context context) { + if (sLoadPreferencesTask != null + && sLoadPreferencesTask.getStatus() != AsyncTask.Status.FINISHED) { + sLoadPreferencesTask.cancel(true); + } + sLoadPreferencesTask = new LoadPreferencesTask(context); + sLoadPreferencesTask.execute(); + } + + private static boolean useContentProvider(Context context) { + // If TIS is a part of LC, it should use ContentProvider to resolve multiple process access. + return TisConfiguration.isPackagedWithLiveChannels(context); + } + + @MainThread + public static int getChannelDataVersion(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt(PREFS_KEY_CHANNEL_DATA_VERSION, + CHANNEL_DATA_VERSION_NOT_SET); + } else { + return getSharedPreferences(context) + .getInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, + CHANNEL_DATA_VERSION_NOT_SET); + } + } + + @MainThread + public static void setChannelDataVersion(Context context, int version) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_CHANNEL_DATA_VERSION, version); + } else { + getSharedPreferences(context).edit() + .putInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, version) + .apply(); + } + } + + @MainThread + public static int getScannedChannelCount(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt(PREFS_KEY_SCANNED_CHANNEL_COUNT); + } else { + return getSharedPreferences(context) + .getInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, 0); + } + } + + @MainThread + public static void setScannedChannelCount(Context context, int channelCount) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount); + } else { + getSharedPreferences(context).edit() + .putInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount) + .apply(); + } + } + + @MainThread + public static boolean isScanDone(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_SCAN_DONE); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, false); + } + } + + @MainThread + public static void setScanDone(Context context) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_SCAN_DONE, true); + } else { + getSharedPreferences(context).edit() + .putBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, true) + .apply(); + } + } + + @MainThread + public static boolean shouldShowSetupActivity(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_LAUNCH_SETUP); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, false); + } + } + + @MainThread + public static void setShouldShowSetupActivity(Context context, boolean need) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_LAUNCH_SETUP, need); + } else { + getSharedPreferences(context).edit() + .putBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, need) + .apply(); + } + } + + @MainThread + public static boolean getStoreTsStream(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getBoolean(PREFS_KEY_STORE_TS_STREAM, false); + } else { + return getSharedPreferences(context) + .getBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, false); + } + } + + @MainThread + public static void setStoreTsStream(Context context, boolean shouldStore) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_STORE_TS_STREAM, shouldStore); + } else { + getSharedPreferences(context).edit() + .putBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, shouldStore) + .apply(); + } + } + + private static SharedPreferences getSharedPreferences(Context context) { + return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); + } + + @MainThread + private static void setPreference(final Context context, final String key, final String value) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContentResolver resolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(Preferences.COLUMN_KEY, key); + values.put(Preferences.COLUMN_VALUE, value); + try { + resolver.insert(Preferences.CONTENT_URI, values); + } catch (Exception e) { + SoftPreconditions.warn(TAG, "setPreference", "Error writing preference values", + e); + } + return null; + } + }.execute(); + } + + @MainThread + private static void setPreference(Context context, String key, int value) { + sPreferenceValues.putInt(key, value); + setPreference(context, key, Integer.toString(value)); + } + + @MainThread + private static void setPreference(Context context, String key, boolean value) { + sPreferenceValues.putBoolean(key, value); + setPreference(context, key, Boolean.toString(value)); + } + + private static class LoadPreferencesTask extends AsyncTask<Void, Void, Bundle> { + private final Context mContext; + private LoadPreferencesTask(Context context) { + mContext = context; + } + + @Override + protected Bundle doInBackground(Void... params) { + Bundle bundle = new Bundle(); + ContentResolver resolver = mContext.getContentResolver(); + String[] projection = new String[] { Preferences.COLUMN_KEY, Preferences.COLUMN_VALUE }; + try (Cursor cursor = resolver.query(Preferences.CONTENT_URI, projection, null, null, + null)) { + if (cursor != null) { + while (!isCancelled() && cursor.moveToNext()) { + String key = cursor.getString(0); + String value = cursor.getString(1); + switch (key) { + case PREFS_KEY_CHANNEL_DATA_VERSION: + case PREFS_KEY_SCANNED_CHANNEL_COUNT: + try { + bundle.putInt(key, Integer.parseInt(value)); + } catch (NumberFormatException e) { + // Does nothing. + } + break; + case PREFS_KEY_SCAN_DONE: + case PREFS_KEY_LAUNCH_SETUP: + case PREFS_KEY_STORE_TS_STREAM: + bundle.putBoolean(key, Boolean.parseBoolean(value)); + break; + } + } + } + } catch (Exception e) { + SoftPreconditions.warn(TAG, "getPreference", "Error querying preference values", e); + return null; + } + return bundle; + } + + @Override + protected void onPostExecute(Bundle bundle) { + sPreferenceValues.putAll(bundle); + } + } +} diff --git a/src/com/android/tv/tuner/UsbTunerHal.java b/src/com/android/tv/tuner/UsbTunerHal.java new file mode 100644 index 00000000..22e35ea1 --- /dev/null +++ b/src/com/android/tv/tuner/UsbTunerHal.java @@ -0,0 +1,174 @@ +/* + * 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.tuner; + +import android.content.Context; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import com.android.tv.tuner.DvbDeviceAccessor.DvbDeviceInfoWrapper; + +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * A class to handle a hardware USB tuner device. + */ +public class UsbTunerHal extends TunerHal { + + private static final Object sLock = new Object(); + // @GuardedBy("sLock") + private static final SortedSet<DvbDeviceInfoWrapper> sUsedDvbDevices = new TreeSet<>(); + + private final DvbDeviceAccessor mDvbDeviceAccessor; + private DvbDeviceInfoWrapper mDvbDeviceInfo; + + protected UsbTunerHal(Context context) { + super(context); + mDvbDeviceAccessor = new DvbDeviceAccessor(context); + } + + @Override + protected boolean openFirstAvailable() { + List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList(); + if (deviceInfoList == null || deviceInfoList.isEmpty()) { + Log.e(TAG, "There's no dvb device attached"); + return false; + } + synchronized (sLock) { + for (DvbDeviceInfoWrapper deviceInfo : deviceInfoList) { + if (!sUsedDvbDevices.contains(deviceInfo)) { + if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo); + mDvbDeviceInfo = deviceInfo; + sUsedDvbDevices.add(deviceInfo); + return true; + } + } + } + Log.e(TAG, "There's no available dvb devices"); + return false; + } + + /** + * Acquires the tuner device. The requested device will be locked to the current instance if + * it's not acquired by others. + * + * @param deviceInfo a tuner device to open + * @return {@code true} if the operation was successful, {@code false} otherwise + */ + protected boolean open(DvbDeviceInfoWrapper deviceInfo) { + if (deviceInfo == null) { + Log.e(TAG, "Device info should not be null"); + return false; + } + if (mDvbDeviceInfo != null) { + Log.e(TAG, "Already acquired"); + return false; + } + List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList(); + if (deviceInfoList == null || deviceInfoList.isEmpty()) { + Log.e(TAG, "There's no dvb device attached"); + return false; + } + for (DvbDeviceInfoWrapper deviceInfoWrapper : deviceInfoList) { + if (deviceInfoWrapper.compareTo(deviceInfo) == 0) { + synchronized (sLock) { + if (sUsedDvbDevices.contains(deviceInfo)) { + Log.e(TAG, deviceInfo + " is already taken"); + return false; + } + sUsedDvbDevices.add(deviceInfo); + } + if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo); + mDvbDeviceInfo = deviceInfo; + return true; + } + } + Log.e(TAG, "There's no such dvb device attached"); + return false; + } + + @Override + public void close() { + if (mDvbDeviceInfo != null) { + if (isStreaming()) { + stopTune(); + } + nativeFinalize(mDvbDeviceInfo.getId()); + synchronized (sLock) { + sUsedDvbDevices.remove(mDvbDeviceInfo); + } + mDvbDeviceInfo = null; + } + } + + @Override + protected boolean isDeviceOpen() { + return (mDvbDeviceInfo != null); + } + + @Override + protected long getDeviceId() { + if (mDvbDeviceInfo != null) { + return mDvbDeviceInfo.getId(); + } + return -1; + } + + @Override + protected int openDvbFrontEndFd() { + if (mDvbDeviceInfo != null) { + ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice( + mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_FRONTEND); + if (descriptor != null) { + return descriptor.detachFd(); + } + } + return -1; + } + + @Override + protected int openDvbDemuxFd() { + if (mDvbDeviceInfo != null) { + ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice( + mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DEMUX); + if (descriptor != null) { + return descriptor.detachFd(); + } + } + return -1; + } + + @Override + protected int openDvbDvrFd() { + if (mDvbDeviceInfo != null) { + ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice( + mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DVR); + if (descriptor != null) { + return descriptor.detachFd(); + } + } + return -1; + } + + /** + * Gets the number of USB tuner devices currently present. + */ + public static int getNumberOfDevices(Context context) { + return (new DvbDeviceAccessor(context)).getNumOfDvbDevices(); + } +} diff --git a/src/com/android/tv/tuner/cc/CaptionLayout.java b/src/com/android/tv/tuner/cc/CaptionLayout.java new file mode 100644 index 00000000..c41f1014 --- /dev/null +++ b/src/com/android/tv/tuner/cc/CaptionLayout.java @@ -0,0 +1,76 @@ +/* + * 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.tuner.cc; + +import android.content.Context; +import android.util.AttributeSet; + +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.layout.ScaledLayout; + +/** + * Layout containing the safe title area that helps the closed captions look more prominent. + * This is required by CEA-708B. + */ +public class CaptionLayout extends ScaledLayout { + // The safe title area has 10% margins of the screen. + private static final float SAFE_TITLE_AREA_SCALE_START_X = 0.1f; + private static final float SAFE_TITLE_AREA_SCALE_END_X = 0.9f; + private static final float SAFE_TITLE_AREA_SCALE_START_Y = 0.1f; + private static final float SAFE_TITLE_AREA_SCALE_END_Y = 0.9f; + + private final ScaledLayout mSafeTitleAreaLayout; + private AtscCaptionTrack mCaptionTrack; + + public CaptionLayout(Context context) { + this(context, null); + } + + public CaptionLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CaptionLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + mSafeTitleAreaLayout = new ScaledLayout(context); + addView(mSafeTitleAreaLayout, new ScaledLayoutParams( + SAFE_TITLE_AREA_SCALE_START_X, SAFE_TITLE_AREA_SCALE_END_X, + SAFE_TITLE_AREA_SCALE_START_Y, SAFE_TITLE_AREA_SCALE_END_Y)); + } + + public void addOrUpdateViewToSafeTitleArea(CaptionWindowLayout captionWindowLayout, + ScaledLayoutParams scaledLayoutParams) { + int index = mSafeTitleAreaLayout.indexOfChild(captionWindowLayout); + if (index < 0) { + mSafeTitleAreaLayout.addView(captionWindowLayout, scaledLayoutParams); + return; + } + mSafeTitleAreaLayout.updateViewLayout(captionWindowLayout, scaledLayoutParams); + } + + public void removeViewFromSafeTitleArea(CaptionWindowLayout captionWindowLayout) { + mSafeTitleAreaLayout.removeView(captionWindowLayout); + } + + public void setCaptionTrack(AtscCaptionTrack captionTrack) { + mCaptionTrack = captionTrack; + } + + public AtscCaptionTrack getCaptionTrack() { + return mCaptionTrack; + } +} diff --git a/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java new file mode 100644 index 00000000..3aa40982 --- /dev/null +++ b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java @@ -0,0 +1,340 @@ +/* + * 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.tuner.cc; + +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.view.View; + +import com.android.tv.tuner.data.Cea708Data.CaptionEvent; +import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr; +import com.android.tv.tuner.data.Cea708Data.CaptionPenColor; +import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation; +import com.android.tv.tuner.data.Cea708Data.CaptionWindow; +import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; + +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * Decodes and renders CEA-708. + */ +public class CaptionTrackRenderer implements Handler.Callback { + // TODO: Remaining works + // CaptionTrackRenderer does not support the full spec of CEA-708. The remaining works are + // described in the follows. + // C0 Table: Backspace, FF, and HCR are not supported. The rule for P16 is not standardized but + // it is handled as EUC-KR charset for korea broadcasting. + // C1 Table: All styles of windows and pens except underline, italic, pen size, and pen offset + // specified in CEA-708 are ignored and this follows system wide cc preferences for + // look and feel. SetPenLocation is not implemented. + // G2 Table: TSP, NBTSP and BLK are not supported. + // Text/commands: Word wrapping, fonts, row and column locking are not supported. + + private static final String TAG = "CaptionTrackRenderer"; + private static final boolean DEBUG = false; + + private static final long DELAY_IN_MILLIS = TimeUnit.MILLISECONDS.toMillis(100); + + // According to CEA-708B, there can exist up to 8 caption windows. + private static final int CAPTION_WINDOWS_MAX = 8; + private static final int CAPTION_ALL_WINDOWS_BITMAP = 255; + + private static final int MSG_DELAY_CANCEL = 1; + private static final int MSG_CAPTION_CLEAR = 2; + + private static final long CAPTION_CLEAR_INTERVAL_MS = 60000; + + private final CaptionLayout mCaptionLayout; + private boolean mIsDelayed = false; + private CaptionWindowLayout mCurrentWindowLayout; + private final CaptionWindowLayout[] mCaptionWindowLayouts = + new CaptionWindowLayout[CAPTION_WINDOWS_MAX]; + private final ArrayList<CaptionEvent> mPendingCaptionEvents = new ArrayList<>(); + private final Handler mHandler; + + public CaptionTrackRenderer(CaptionLayout captionLayout) { + mCaptionLayout = captionLayout; + mHandler = new Handler(this); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_DELAY_CANCEL: + delayCancel(); + return true; + case MSG_CAPTION_CLEAR: + clearWindows(CAPTION_ALL_WINDOWS_BITMAP); + return true; + } + return false; + } + + public void start(AtscCaptionTrack captionTrack) { + if (captionTrack == null) { + stop(); + return; + } + if (DEBUG) { + Log.d(TAG, "Start captionTrack " + captionTrack.language); + } + reset(); + mCaptionLayout.setCaptionTrack(captionTrack); + mCaptionLayout.setVisibility(View.VISIBLE); + } + + public void stop() { + if (DEBUG) { + Log.d(TAG, "Stop captionTrack"); + } + mCaptionLayout.setVisibility(View.INVISIBLE); + mHandler.removeMessages(MSG_CAPTION_CLEAR); + } + + public void processCaptionEvent(CaptionEvent event) { + if (mIsDelayed) { + mPendingCaptionEvents.add(event); + return; + } + switch (event.type) { + case Cea708Parser.CAPTION_EMIT_TYPE_BUFFER: + sendBufferToCurrentWindow((String) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_CONTROL: + sendControlToCurrentWindow((char) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CWX: + setCurrentWindowLayout((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CLW: + clearWindows((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DSW: + displayWindows((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_HDW: + hideWindows((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_TGW: + toggleWindows((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLW: + deleteWindows((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLY: + delay((int) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLC: + delayCancel(); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_RST: + reset(); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPA: + setPenAttr((CaptionPenAttr) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPC: + setPenColor((CaptionPenColor) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPL: + setPenLocation((CaptionPenLocation) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SWA: + setWindowAttr((CaptionWindowAttr) event.obj); + break; + case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DFX: + defineWindow((CaptionWindow) event.obj); + break; + } + } + + // The window related caption commands + private void setCurrentWindowLayout(int windowId) { + if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) { + return; + } + CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId]; + if (windowLayout == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "setCurrentWindowLayout to " + windowId); + } + mCurrentWindowLayout = windowLayout; + } + + // Each bit of windowBitmap indicates a window. + // If a bit is set, the window id is the same as the number of the trailing zeros of the bit. + private ArrayList<CaptionWindowLayout> getWindowsFromBitmap(int windowBitmap) { + ArrayList<CaptionWindowLayout> windows = new ArrayList<>(); + for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) { + if ((windowBitmap & (1 << i)) != 0) { + CaptionWindowLayout windowLayout = mCaptionWindowLayouts[i]; + if (windowLayout != null) { + windows.add(windowLayout); + } + } + } + return windows; + } + + private void clearWindows(int windowBitmap) { + if (windowBitmap == 0) { + return; + } + for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) { + windowLayout.clear(); + } + } + + private void displayWindows(int windowBitmap) { + if (windowBitmap == 0) { + return; + } + for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) { + windowLayout.show(); + } + } + + private void hideWindows(int windowBitmap) { + if (windowBitmap == 0) { + return; + } + for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) { + windowLayout.hide(); + } + } + + private void toggleWindows(int windowBitmap) { + if (windowBitmap == 0) { + return; + } + for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) { + if (windowLayout.isShown()) { + windowLayout.hide(); + } else { + windowLayout.show(); + } + } + } + + private void deleteWindows(int windowBitmap) { + if (windowBitmap == 0) { + return; + } + for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) { + windowLayout.removeFromCaptionView(); + mCaptionWindowLayouts[windowLayout.getCaptionWindowId()] = null; + } + } + + public void reset() { + mCurrentWindowLayout = null; + mIsDelayed = false; + mPendingCaptionEvents.clear(); + for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) { + if (mCaptionWindowLayouts[i] != null) { + mCaptionWindowLayouts[i].removeFromCaptionView(); + } + mCaptionWindowLayouts[i] = null; + } + mCaptionLayout.setVisibility(View.INVISIBLE); + mHandler.removeMessages(MSG_CAPTION_CLEAR); + } + + private void setWindowAttr(CaptionWindowAttr windowAttr) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.setWindowAttr(windowAttr); + } + } + + private void defineWindow(CaptionWindow window) { + if (window == null) { + return; + } + int windowId = window.id; + if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) { + return; + } + CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId]; + if (windowLayout == null) { + windowLayout = new CaptionWindowLayout(mCaptionLayout.getContext()); + } + windowLayout.initWindow(mCaptionLayout, window); + mCurrentWindowLayout = mCaptionWindowLayouts[windowId] = windowLayout; + } + + // The job related caption commands + private void delay(int tenthsOfSeconds) { + if (tenthsOfSeconds < 0 || tenthsOfSeconds > 255) { + return; + } + mIsDelayed = true; + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_DELAY_CANCEL), + tenthsOfSeconds * DELAY_IN_MILLIS); + } + + private void delayCancel() { + mIsDelayed = false; + processPendingBuffer(); + } + + private void processPendingBuffer() { + for (CaptionEvent event : mPendingCaptionEvents) { + processCaptionEvent(event); + } + mPendingCaptionEvents.clear(); + } + + // The implicit write caption commands + private void sendControlToCurrentWindow(char control) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.sendControl(control); + } + } + + private void sendBufferToCurrentWindow(String buffer) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.sendBuffer(buffer); + mHandler.removeMessages(MSG_CAPTION_CLEAR); + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CAPTION_CLEAR), + CAPTION_CLEAR_INTERVAL_MS); + } + } + + // The pen related caption commands + private void setPenAttr(CaptionPenAttr attr) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.setPenAttr(attr); + } + } + + private void setPenColor(CaptionPenColor color) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.setPenColor(color); + } + } + + private void setPenLocation(CaptionPenLocation location) { + if (mCurrentWindowLayout != null) { + mCurrentWindowLayout.setPenLocation(location.row, location.column); + } + } +} diff --git a/src/com/android/tv/tuner/cc/CaptionWindowLayout.java b/src/com/android/tv/tuner/cc/CaptionWindowLayout.java new file mode 100644 index 00000000..6f42b506 --- /dev/null +++ b/src/com/android/tv/tuner/cc/CaptionWindowLayout.java @@ -0,0 +1,650 @@ +/* + * 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.tuner.cc; + +import android.content.Context; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.text.Layout.Alignment; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; +import android.text.style.SubscriptSpan; +import android.text.style.SuperscriptSpan; +import android.text.style.UnderlineSpan; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.CaptioningManager; +import android.view.accessibility.CaptioningManager.CaptionStyle; +import android.view.accessibility.CaptioningManager.CaptioningChangeListener; +import android.widget.RelativeLayout; + +import com.google.android.exoplayer.text.CaptionStyleCompat; +import com.google.android.exoplayer.text.SubtitleView; +import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr; +import com.android.tv.tuner.data.Cea708Data.CaptionPenColor; +import com.android.tv.tuner.data.Cea708Data.CaptionWindow; +import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr; +import com.android.tv.tuner.layout.ScaledLayout; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Layout which renders a caption window of CEA-708B. It contains a {@link SubtitleView} that + * takes care of displaying the actual cc text. + */ +public class CaptionWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener { + private static final String TAG = "CaptionWindowLayout"; + private static final boolean DEBUG = false; + + private static final float PROPORTION_PEN_SIZE_SMALL = .75f; + private static final float PROPORTION_PEN_SIZE_LARGE = 1.25f; + + // The following values indicates the maximum cell number of a window. + private static final int ANCHOR_RELATIVE_POSITIONING_MAX = 99; + private static final int ANCHOR_VERTICAL_MAX = 74; + private static final int ANCHOR_HORIZONTAL_4_3_MAX = 159; + private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209; + + // The following values indicates a gravity of a window. + private static final int ANCHOR_MODE_DIVIDER = 3; + private static final int ANCHOR_HORIZONTAL_MODE_LEFT = 0; + private static final int ANCHOR_HORIZONTAL_MODE_CENTER = 1; + private static final int ANCHOR_HORIZONTAL_MODE_RIGHT = 2; + private static final int ANCHOR_VERTICAL_MODE_TOP = 0; + private static final int ANCHOR_VERTICAL_MODE_CENTER = 1; + private static final int ANCHOR_VERTICAL_MODE_BOTTOM = 2; + + private static final int US_MAX_COLUMN_COUNT_16_9 = 42; + private static final int US_MAX_COLUMN_COUNT_4_3 = 32; + private static final int KR_MAX_COLUMN_COUNT_16_9 = 52; + private static final int KR_MAX_COLUMN_COUNT_4_3 = 40; + private static final int MAX_ROW_COUNT = 15; + + private static final String KOR_ALPHABET = + new String("\uAC00".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + private static final float WIDE_SCREEN_ASPECT_RATIO_THRESHOLD = 1.6f; + + private CaptionLayout mCaptionLayout; + private CaptionStyleCompat mCaptionStyleCompat; + + // TODO: Replace SubtitleView to {@link com.google.android.exoplayer.text.SubtitleLayout}. + private final SubtitleView mSubtitleView; + private int mRowLimit = 0; + private final SpannableStringBuilder mBuilder = new SpannableStringBuilder(); + private final List<CharacterStyle> mCharacterStyles = new ArrayList<>(); + private int mCaptionWindowId; + private int mCurrentTextRow = -1; + private float mFontScale; + private float mTextSize; + private String mWidestChar; + private int mLastCaptionLayoutWidth; + private int mLastCaptionLayoutHeight; + private int mWindowJustify; + private int mPrintDirection; + + private class SystemWideCaptioningChangeListener extends CaptioningChangeListener { + @Override + public void onUserStyleChanged(CaptionStyle userStyle) { + mCaptionStyleCompat = CaptionStyleCompat.createFromCaptionStyle(userStyle); + mSubtitleView.setStyle(mCaptionStyleCompat); + updateWidestChar(); + } + + @Override + public void onFontScaleChanged(float fontScale) { + mFontScale = fontScale; + updateTextSize(); + } + } + + public CaptionWindowLayout(Context context) { + this(context, null); + } + + public CaptionWindowLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + // Add a subtitle view to the layout. + mSubtitleView = new SubtitleView(context); + LayoutParams params = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + addView(mSubtitleView, params); + + // Set the system wide cc preferences to the subtitle view. + CaptioningManager captioningManager = + (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); + mFontScale = captioningManager.getFontScale(); + mCaptionStyleCompat = + CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); + mSubtitleView.setStyle(mCaptionStyleCompat); + mSubtitleView.setText(""); + captioningManager.addCaptioningChangeListener(new SystemWideCaptioningChangeListener()); + updateWidestChar(); + } + + public int getCaptionWindowId() { + return mCaptionWindowId; + } + + public void setCaptionWindowId(int captionWindowId) { + mCaptionWindowId = captionWindowId; + } + + public void clear() { + clearText(); + hide(); + } + + public void show() { + setVisibility(View.VISIBLE); + requestLayout(); + } + + public void hide() { + setVisibility(View.INVISIBLE); + requestLayout(); + } + + public void setPenAttr(CaptionPenAttr penAttr) { + mCharacterStyles.clear(); + if (penAttr.italic) { + mCharacterStyles.add(new StyleSpan(Typeface.ITALIC)); + } + if (penAttr.underline) { + mCharacterStyles.add(new UnderlineSpan()); + } + switch (penAttr.penSize) { + case CaptionPenAttr.PEN_SIZE_SMALL: + mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL)); + break; + case CaptionPenAttr.PEN_SIZE_LARGE: + mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE)); + break; + } + switch (penAttr.penOffset) { + case CaptionPenAttr.OFFSET_SUBSCRIPT: + mCharacterStyles.add(new SubscriptSpan()); + break; + case CaptionPenAttr.OFFSET_SUPERSCRIPT: + mCharacterStyles.add(new SuperscriptSpan()); + break; + } + } + + public void setPenColor(CaptionPenColor penColor) { + // TODO: apply pen colors or skip this and use the style of system wide cc style as is. + } + + public void setPenLocation(int row, int column) { + // TODO: change the location of pen when window's justify isn't left. + // According to the CEA708B spec 8.7, setPenLocation means set the pen cursor within + // window's text buffer. When row > mCurrentTextRow, we add "\n" to make the cursor locate + // at row. Adding white space to make cursor locate at column. + if (mWindowJustify == CaptionWindowAttr.JUSTIFY_LEFT) { + if (mCurrentTextRow >= 0) { + for (int r = mCurrentTextRow; r < row; ++r) { + appendText("\n"); + } + if (mCurrentTextRow <= row) { + for (int i = 0; i < column; ++i) { + appendText(" "); + } + } + } + } + mCurrentTextRow = row; + } + + public void setWindowAttr(CaptionWindowAttr windowAttr) { + // TODO: apply window attrs or skip this and use the style of system wide cc style as is. + mWindowJustify = windowAttr.justify; + mPrintDirection = windowAttr.printDirection; + } + + public void sendBuffer(String buffer) { + appendText(buffer); + } + + public void sendControl(char control) { + // TODO: there are a bunch of ASCII-style control codes. + } + + /** + * This method places the window on a given CaptionLayout along with the anchor of the window. + * <p> + * According to CEA-708B, the anchor id indicates the gravity of the window as the follows. + * For example, A value 7 of a anchor id says that a window is align with its parent bottom and + * is located at the center horizontally of its parent. + * </p> + * <h4>Anchor id and the gravity of a window</h4> + * <table> + * <tr> + * <th>GRAVITY</th> + * <th>LEFT</th> + * <th>CENTER_HORIZONTAL</th> + * <th>RIGHT</th> + * </tr> + * <tr> + * <th>TOP</th> + * <td>0</td> + * <td>1</td> + * <td>2</td> + * </tr> + * <tr> + * <th>CENTER_VERTICAL</th> + * <td>3</td> + * <td>4</td> + * <td>5</td> + * </tr> + * <tr> + * <th>BOTTOM</th> + * <td>6</td> + * <td>7</td> + * <td>8</td> + * </tr> + * </table> + * <p> + * In order to handle the gravity of a window, there are two steps. First, set the size of the + * window. Since the window will be positioned at {@link ScaledLayout}, the size factors are + * determined in a ratio. Second, set the gravity of the window. {@link CaptionWindowLayout} is + * inherited from {@link RelativeLayout}. Hence, we could set the gravity of its child view, + * {@link SubtitleView}. + * </p> + * <p> + * The gravity of the window is also related to its size. When it should be pushed to a one of + * the end of the window, like LEFT, RIGHT, TOP or BOTTOM, the anchor point should be a boundary + * of the window. When it should be pushed in the horizontal/vertical center of its container, + * the horizontal/vertical center point of the window should be the same as the anchor point. + * </p> + * + * @param captionLayout a given {@link CaptionLayout}, which contains a safe title area + * @param captionWindow a given {@link CaptionWindow}, which stores the construction info of the + * window + */ + public void initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow) { + if (DEBUG) { + Log.d(TAG, "initWindow with " + + (captionLayout != null ? captionLayout.getCaptionTrack() : null)); + } + if (mCaptionLayout != captionLayout) { + if (mCaptionLayout != null) { + mCaptionLayout.removeOnLayoutChangeListener(this); + } + mCaptionLayout = captionLayout; + mCaptionLayout.addOnLayoutChangeListener(this); + updateWidestChar(); + } + + // Both anchor vertical and horizontal indicates the position cell number of the window. + float scaleRow = (float) captionWindow.anchorVertical / (captionWindow.relativePositioning + ? ANCHOR_RELATIVE_POSITIONING_MAX : ANCHOR_VERTICAL_MAX); + float scaleCol = (float) captionWindow.anchorHorizontal / + (captionWindow.relativePositioning ? ANCHOR_RELATIVE_POSITIONING_MAX + : (isWideAspectRatio() + ? ANCHOR_HORIZONTAL_16_9_MAX : ANCHOR_HORIZONTAL_4_3_MAX)); + + // The range of scaleRow/Col need to be verified to be in [0, 1]. + // Otherwise a {@link RuntimeException} will be raised in {@link ScaledLayout}. + if (scaleRow < 0 || scaleRow > 1) { + Log.i(TAG, "The vertical position of the anchor point should be at the range of 0 and 1" + + " but " + scaleRow); + scaleRow = Math.max(0, Math.min(scaleRow, 1)); + } + if (scaleCol < 0 || scaleCol > 1) { + Log.i(TAG, "The horizontal position of the anchor point should be at the range of 0 and" + + " 1 but " + scaleCol); + scaleCol = Math.max(0, Math.min(scaleCol, 1)); + } + int gravity = Gravity.CENTER; + int horizontalMode = captionWindow.anchorId % ANCHOR_MODE_DIVIDER; + int verticalMode = captionWindow.anchorId / ANCHOR_MODE_DIVIDER; + float scaleStartRow = 0; + float scaleEndRow = 1; + float scaleStartCol = 0; + float scaleEndCol = 1; + switch (horizontalMode) { + case ANCHOR_HORIZONTAL_MODE_LEFT: + gravity = Gravity.LEFT; + mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL); + scaleStartCol = scaleCol; + break; + case ANCHOR_HORIZONTAL_MODE_CENTER: + float gap = Math.min(1 - scaleCol, scaleCol); + + // Since all TV sets use left text alignment instead of center text alignment + // for this case, we follow the industry convention if possible. + int columnCount = captionWindow.columnCount + 1; + if (isKoreanLanguageTrack()) { + columnCount /= 2; + } + columnCount = Math.min(getScreenColumnCount(), columnCount); + StringBuilder widestTextBuilder = new StringBuilder(); + for (int i = 0; i < columnCount; ++i) { + widestTextBuilder.append(mWidestChar); + } + Paint paint = new Paint(); + paint.setTypeface(mCaptionStyleCompat.typeface); + paint.setTextSize(mTextSize); + float maxWindowWidth = paint.measureText(widestTextBuilder.toString()); + float halfMaxWidthScale = mCaptionLayout.getWidth() > 0 + ? maxWindowWidth / 2.0f / (mCaptionLayout.getWidth() * 0.8f) : 0.0f; + if (halfMaxWidthScale > 0f && halfMaxWidthScale < scaleCol) { + // Calculate the expected max window size based on the column count of the + // caption window multiplied by average alphabets char width, then align the + // left side of the window with the left side of the expected max window. + gravity = Gravity.LEFT; + mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL); + scaleStartCol = scaleCol - halfMaxWidthScale; + scaleEndCol = 1.0f; + } else { + // The gap will be the minimum distance value of the distances from both + // horizontal end points to the anchor point. + // If scaleCol <= 0.5, the range of scaleCol is [0, the anchor point * 2]. + // If scaleCol > 0.5, the range of scaleCol is [(1 - the anchor point) * 2, 1]. + // The anchor point is located at the horizontal center of the window in both + // cases. + gravity = Gravity.CENTER_HORIZONTAL; + mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER); + scaleStartCol = scaleCol - gap; + scaleEndCol = scaleCol + gap; + } + break; + case ANCHOR_HORIZONTAL_MODE_RIGHT: + gravity = Gravity.RIGHT; + mSubtitleView.setTextAlignment(Alignment.ALIGN_OPPOSITE); + scaleEndCol = scaleCol; + break; + } + switch (verticalMode) { + case ANCHOR_VERTICAL_MODE_TOP: + gravity |= Gravity.TOP; + scaleStartRow = scaleRow; + break; + case ANCHOR_VERTICAL_MODE_CENTER: + gravity |= Gravity.CENTER_VERTICAL; + + // See the above comment. + float gap = Math.min(1 - scaleRow, scaleRow); + scaleStartRow = scaleRow - gap; + scaleEndRow = scaleRow + gap; + break; + case ANCHOR_VERTICAL_MODE_BOTTOM: + gravity |= Gravity.BOTTOM; + scaleEndRow = scaleRow; + break; + } + mCaptionLayout.addOrUpdateViewToSafeTitleArea(this, new ScaledLayout + .ScaledLayoutParams(scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol)); + setCaptionWindowId(captionWindow.id); + setRowLimit(captionWindow.rowCount); + setGravity(gravity); + setWindowStyle(captionWindow.windowStyle); + if (mWindowJustify == CaptionWindowAttr.JUSTIFY_CENTER) { + mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER); + } + if (captionWindow.visible) { + show(); + } else { + hide(); + } + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + int width = right - left; + int height = bottom - top; + if (width != mLastCaptionLayoutWidth || height != mLastCaptionLayoutHeight) { + mLastCaptionLayoutWidth = width; + mLastCaptionLayoutHeight = height; + updateTextSize(); + } + } + + private boolean isKoreanLanguageTrack() { + return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null + && mCaptionLayout.getCaptionTrack().language != null + && "KOR".compareToIgnoreCase(mCaptionLayout.getCaptionTrack().language) == 0; + } + + private boolean isWideAspectRatio() { + return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null + && mCaptionLayout.getCaptionTrack().wideAspectRatio; + } + + private void updateWidestChar() { + if (isKoreanLanguageTrack()) { + mWidestChar = KOR_ALPHABET; + } else { + Paint paint = new Paint(); + paint.setTypeface(mCaptionStyleCompat.typeface); + Charset latin1 = Charset.forName("ISO-8859-1"); + float widestCharWidth = 0f; + for (int i = 0; i < 256; ++i) { + String ch = new String(new byte[]{(byte) i}, latin1); + float charWidth = paint.measureText(ch); + if (widestCharWidth < charWidth) { + widestCharWidth = charWidth; + mWidestChar = ch; + } + } + } + updateTextSize(); + } + + private void updateTextSize() { + if (mCaptionLayout == null) return; + + // Calculate text size based on the max window size. + StringBuilder widestTextBuilder = new StringBuilder(); + int screenColumnCount = getScreenColumnCount(); + for (int i = 0; i < screenColumnCount; ++i) { + widestTextBuilder.append(mWidestChar); + } + String widestText = widestTextBuilder.toString(); + Paint paint = new Paint(); + paint.setTypeface(mCaptionStyleCompat.typeface); + float startFontSize = 0f; + float endFontSize = 255f; + Rect boundRect = new Rect(); + while (startFontSize < endFontSize) { + float testTextSize = (startFontSize + endFontSize) / 2f; + paint.setTextSize(testTextSize); + float width = paint.measureText(widestText); + paint.getTextBounds(widestText, 0, widestText.length(), boundRect); + float height = boundRect.height() + width - boundRect.width(); + // According to CEA-708B Section 9.13, the height of standard font size shouldn't taller + // than 1/15 of the height of the safe-title area, and the width shouldn't wider than + // 1/{@code getScreenColumnCount()} of the width of the safe-title area. + if (mCaptionLayout.getWidth() * 0.8f > width + && mCaptionLayout.getHeight() * 0.8f / MAX_ROW_COUNT > height) { + startFontSize = testTextSize + 0.01f; + } else { + endFontSize = testTextSize - 0.01f; + } + } + mTextSize = endFontSize * mFontScale; + paint.setTextSize(mTextSize); + float whiteSpaceWidth = paint.measureText(" "); + mSubtitleView.setWhiteSpaceWidth(whiteSpaceWidth); + mSubtitleView.setTextSize(mTextSize); + } + + private int getScreenColumnCount() { + float screenAspectRatio = (float) mCaptionLayout.getWidth() / mCaptionLayout.getHeight(); + boolean isWideAspectRationScreen = screenAspectRatio > WIDE_SCREEN_ASPECT_RATIO_THRESHOLD; + if (isKoreanLanguageTrack()) { + // Each korean character consumes two slots. + if (isWideAspectRationScreen || isWideAspectRatio()) { + return KR_MAX_COLUMN_COUNT_16_9 / 2; + } else { + return KR_MAX_COLUMN_COUNT_4_3 / 2; + } + } else { + if (isWideAspectRationScreen || isWideAspectRatio()) { + return US_MAX_COLUMN_COUNT_16_9; + } else { + return US_MAX_COLUMN_COUNT_4_3; + } + } + } + + public void removeFromCaptionView() { + if (mCaptionLayout != null) { + mCaptionLayout.removeViewFromSafeTitleArea(this); + mCaptionLayout.removeOnLayoutChangeListener(this); + mCaptionLayout = null; + } + } + + public void setText(String text) { + updateText(text, false); + } + + public void appendText(String text) { + updateText(text, true); + } + + public void clearText() { + mBuilder.clear(); + mSubtitleView.setText(""); + } + + private void updateText(String text, boolean appended) { + if (!appended) { + mBuilder.clear(); + } + if (text != null && text.length() > 0) { + int length = mBuilder.length(); + mBuilder.append(text); + for (CharacterStyle characterStyle : mCharacterStyles) { + mBuilder.setSpan(characterStyle, length, mBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + String[] lines = TextUtils.split(mBuilder.toString(), "\n"); + + // Truncate text not to exceed the row limit. + // Plus one here since the range of the rows is [0, mRowLimit]. + int startRow = Math.max(0, lines.length - (mRowLimit + 1)); + String truncatedText = TextUtils.join("\n", Arrays.copyOfRange( + lines, startRow, lines.length)); + mBuilder.delete(0, mBuilder.length() - truncatedText.length()); + mCurrentTextRow = lines.length - startRow - 1; + + // Trim the buffer first then set text to {@link SubtitleView}. + int start = 0, last = mBuilder.length() - 1; + int end = last; + while ((start <= end) && (mBuilder.charAt(start) <= ' ')) { + ++start; + } + while (start - 1 >= 0 && start <= end && mBuilder.charAt(start - 1) != '\n') { + --start; + } + while ((end >= start) && (mBuilder.charAt(end) <= ' ')) { + --end; + } + if (start == 0 && end == last) { + mSubtitleView.setPrefixSpaces(getPrefixSpaces(mBuilder)); + mSubtitleView.setText(mBuilder); + } else { + SpannableStringBuilder trim = new SpannableStringBuilder(); + trim.append(mBuilder); + if (end < last) { + trim.delete(end + 1, last + 1); + } + if (start > 0) { + trim.delete(0, start); + } + mSubtitleView.setPrefixSpaces(getPrefixSpaces(trim)); + mSubtitleView.setText(trim); + } + } + + private static ArrayList<Integer> getPrefixSpaces(SpannableStringBuilder builder) { + ArrayList<Integer> prefixSpaces = new ArrayList<>(); + String[] lines = TextUtils.split(builder.toString(), "\n"); + for (String line : lines) { + int start = 0; + while (start < line.length() && line.charAt(start) <= ' ') { + start++; + } + prefixSpaces.add(start); + } + return prefixSpaces; + } + + public void setRowLimit(int rowLimit) { + if (rowLimit < 0) { + throw new IllegalArgumentException("A rowLimit should have a positive number"); + } + mRowLimit = rowLimit; + } + + private void setWindowStyle(int windowStyle) { + // TODO: Set other attributes of window style. Like fill opacity and fill color. + switch (windowStyle) { + case 2: + mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + case 3: + mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + case 4: + mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + case 5: + mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + case 6: + mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + case 7: + mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT; + mPrintDirection = CaptionWindowAttr.PRINT_TOP_TO_BOTTOM; + break; + default: + if (windowStyle != 0 && windowStyle != 1) { + Log.e(TAG, "Error predefined window style:" + windowStyle); + } + mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT; + mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT; + break; + } + } +} diff --git a/src/com/android/tv/tuner/cc/Cea708Parser.java b/src/com/android/tv/tuner/cc/Cea708Parser.java new file mode 100644 index 00000000..92ab0620 --- /dev/null +++ b/src/com/android/tv/tuner/cc/Cea708Parser.java @@ -0,0 +1,808 @@ +/* + * 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.tuner.cc; + +import android.os.SystemClock; +import android.support.annotation.IntDef; +import android.util.Log; +import android.util.SparseIntArray; + +import com.android.tv.tuner.data.Cea708Data; +import com.android.tv.tuner.data.Cea708Data.CaptionColor; +import com.android.tv.tuner.data.Cea708Data.CaptionEvent; +import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr; +import com.android.tv.tuner.data.Cea708Data.CaptionPenColor; +import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation; +import com.android.tv.tuner.data.Cea708Data.CaptionWindow; +import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr; +import com.android.tv.tuner.data.Cea708Data.CcPacket; +import com.android.tv.tuner.util.ByteArrayBuffer; + +import java.io.UnsupportedEncodingException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.TreeSet; + +/** + * A class for parsing CEA-708, which is the standard for closed captioning for ATSC DTV. + * + * <p>ATSC DTV closed caption data are carried on picture user data of video streams. + * This class starts to parse from picture user data payload, so extraction process of user_data + * from video streams is up to outside of this code. + * + * <p>There are 4 steps to decode user_data to provide closed caption services. + * + * <h3>Step 1. user_data -> CcPacket ({@link #parseClosedCaption} method)</h3> + * + * <p>First, user_data consists of cc_data packets, which are 3-byte segments. Here, CcPacket is a + * collection of cc_data packets in a frame along with same presentation timestamp. Because cc_data + * packets must be reassembled in the frame display order, CcPackets are reordered. + * + * <h3>Step 2. CcPacket -> DTVCC packet ({@link #parseCcPacket} method)</h3> + * + * <p>Each cc_data packet has a one byte for declaring a type of itself and data validity, and the + * subsequent two bytes for input data of a DTVCC packet. There are 4 types for cc_data packet. + * We're interested in DTVCC_PACKET_START(type 3) and DTVCC_PACKET_DATA(type 2). Each DTVCC packet + * begins with DTVCC_PACKET_START(type 3) and the following cc_data packets which has + * DTVCC_PACKET_DATA(type 2) are appended into the DTVCC packet being assembled. + * + * <h3>Step 3. DTVCC packet -> Service Blocks ({@link #parseDtvCcPacket} method)</h3> + * + * <p>A DTVCC packet consists of multiple service blocks. Each service block represents a caption + * track and has a service number, which ranges from 1 to 63, that denotes caption track identity. + * In here, we listen at most one chosen caption track by {@link #mListenServiceNumber}. + * Otherwise, just skip the other service blocks. + * + * <h3>Step 4. Interpreting Service Block Data ({@link #parseServiceBlockData}, {@code parseXX}, + * and {@link #parseExt1} methods)</h3> + * + * <p>Service block data is actual caption stream. it looks similar to telnet. It uses most parts of + * ASCII table and consists of specially defined commands and some ASCII control codes which work + * in a behavior slightly different from their original purpose. ASCII control codes and caption + * commands are explicit instructions that control the state of a closed caption service and the + * other ASCII and text codes are implicit instructions that send their characters to buffer. + * + * <p>There are 4 main code groups and 4 extended code groups. Both the range of code groups are the + * same as the range of a byte. + * + * <p>4 main code groups: C0, C1, G0, G1 + * <br>4 extended code groups: C2, C3, G2, G3 + * + * <p>Each code group has its own handle method. For example, {@link #parseC0} handles C0 code group + * and so on. And {@link #parseServiceBlockData} method maps a stream on the main code groups while + * {@link #parseExt1} method maps on the extended code groups. + * + * <p>The main code groups: + * <ul> + * <li>C0 - contains modified ASCII control codes. It is not intended by CEA-708 but Korea TTA + * standard for ATSC CC uses P16 character heavily, which is unclear entity in CEA-708 doc, + * even for the alphanumeric characters instead of ASCII characters.</li> + * <li>C1 - contains the caption commands. There are 3 categories of a caption command.</li> + * <ul> + * <li>Window commands: The window commands control a caption window which is addressable area being + * with in the Safe title area. (CWX, CLW, DSW, HDW, TGW, DLW, SWA, DFX)</li> + * <li>Pen commands: Th pen commands control text style and location. (SPA, SPC, SPL)</li> + * <li>Job commands: The job commands make a delay and recover from the delay. (DLY, DLC, RST)</li> + * </ul> + * <li>G0 - same as printable ASCII character set except music note character.</li> + * <li>G1 - same as ISO 8859-1 Latin 1 character set.</li> + * </ul> + * <p>Most of the extended code groups are being skipped. + * + */ +public class Cea708Parser { + private static final String TAG = "Cea708Parser"; + private static final boolean DEBUG = false; + + // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps. + private static final int MAX_ALLOCATED_SIZE = 9600 / 8; + private static final String MUSIC_NOTE_CHAR = new String( + "\u266B".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + + // The following values are denoting the type of closed caption data. + // See CEA-708B section 4.4.1. + private static final int CC_TYPE_DTVCC_PACKET_START = 3; + private static final int CC_TYPE_DTVCC_PACKET_DATA = 2; + + // The following values are defined in CEA-708B Figure 4 and 6. + private static final int DTVCC_MAX_PACKET_SIZE = 64; + private static final int DTVCC_PACKET_SIZE_SCALE_FACTOR = 2; + private static final int DTVCC_EXTENDED_SERVICE_NUMBER_POINT = 7; + + // The following values are for seeking closed caption tracks. + private static final int DISCOVERY_PERIOD_MS = 10000; // 10 sec + private static final int DISCOVERY_NUM_BYTES_THRESHOLD = 10; // 10 bytes + private static final int DISCOVERY_CC_SERVICE_NUMBER_START = 1; // CC1 + private static final int DISCOVERY_CC_SERVICE_NUMBER_END = 4; // CC4 + + private final ByteArrayBuffer mDtvCcPacket = new ByteArrayBuffer(MAX_ALLOCATED_SIZE); + private final TreeSet<CcPacket> mCcPackets = new TreeSet<>(); + private final StringBuffer mBuffer = new StringBuffer(); + private final SparseIntArray mDiscoveredNumBytes = new SparseIntArray(); // per service number + private long mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime(); + private int mCommand = 0; + private int mListenServiceNumber = 0; + private boolean mDtvCcPacking = false; + + // Assign a dummy listener in order to avoid null checks. + private OnCea708ParserListener mListener = new OnCea708ParserListener() { + @Override + public void emitEvent(CaptionEvent event) { + // do nothing + } + + @Override + public void discoverServiceNumber(int serviceNumber) { + // do nothing + } + }; + + /** + * {@link Cea708Parser} emits caption event of three different types. + * {@link OnCea708ParserListener#emitEvent} is invoked with the parameter + * {@link CaptionEvent} to pass all the results to an observer of the decoding process. + * + * <p>{@link CaptionEvent#type} determines the type of the result and + * {@link CaptionEvent#obj} contains the output value of a caption event. + * The observer must do the casting to the corresponding type. + * + * <ul><li>{@code CAPTION_EMIT_TYPE_BUFFER}: Passes a caption text buffer to a observer. + * {@code obj} must be of {@link String}.</li> + * + * <li>{@code CAPTION_EMIT_TYPE_CONTROL}: Passes a caption character control code to a observer. + * {@code obj} must be of {@link Character}.</li> + * + * <li>{@code CAPTION_EMIT_TYPE_CLEAR_COMMAND}: Passes a clear command to a observer. + * {@code obj} must be {@code NULL}.</li></ul> + */ + @IntDef({CAPTION_EMIT_TYPE_BUFFER, CAPTION_EMIT_TYPE_CONTROL, CAPTION_EMIT_TYPE_COMMAND_CWX, + CAPTION_EMIT_TYPE_COMMAND_CLW, CAPTION_EMIT_TYPE_COMMAND_DSW, CAPTION_EMIT_TYPE_COMMAND_HDW, + CAPTION_EMIT_TYPE_COMMAND_TGW, CAPTION_EMIT_TYPE_COMMAND_DLW, CAPTION_EMIT_TYPE_COMMAND_DLY, + CAPTION_EMIT_TYPE_COMMAND_DLC, CAPTION_EMIT_TYPE_COMMAND_RST, CAPTION_EMIT_TYPE_COMMAND_SPA, + CAPTION_EMIT_TYPE_COMMAND_SPC, CAPTION_EMIT_TYPE_COMMAND_SPL, CAPTION_EMIT_TYPE_COMMAND_SWA, + CAPTION_EMIT_TYPE_COMMAND_DFX}) + @Retention(RetentionPolicy.SOURCE) + public @interface CaptionEmitType {} + public static final int CAPTION_EMIT_TYPE_BUFFER = 1; + public static final int CAPTION_EMIT_TYPE_CONTROL = 2; + public static final int CAPTION_EMIT_TYPE_COMMAND_CWX = 3; + public static final int CAPTION_EMIT_TYPE_COMMAND_CLW = 4; + public static final int CAPTION_EMIT_TYPE_COMMAND_DSW = 5; + public static final int CAPTION_EMIT_TYPE_COMMAND_HDW = 6; + public static final int CAPTION_EMIT_TYPE_COMMAND_TGW = 7; + public static final int CAPTION_EMIT_TYPE_COMMAND_DLW = 8; + public static final int CAPTION_EMIT_TYPE_COMMAND_DLY = 9; + public static final int CAPTION_EMIT_TYPE_COMMAND_DLC = 10; + public static final int CAPTION_EMIT_TYPE_COMMAND_RST = 11; + public static final int CAPTION_EMIT_TYPE_COMMAND_SPA = 12; + public static final int CAPTION_EMIT_TYPE_COMMAND_SPC = 13; + public static final int CAPTION_EMIT_TYPE_COMMAND_SPL = 14; + public static final int CAPTION_EMIT_TYPE_COMMAND_SWA = 15; + public static final int CAPTION_EMIT_TYPE_COMMAND_DFX = 16; + + public interface OnCea708ParserListener { + void emitEvent(CaptionEvent event); + void discoverServiceNumber(int serviceNumber); + } + + public void setListener(OnCea708ParserListener listener) { + if (listener != null) { + mListener = listener; + } + } + + public void setListenServiceNumber(int serviceNumber) { + mListenServiceNumber = serviceNumber; + } + + private void emitCaptionEvent(CaptionEvent captionEvent) { + // Emit the existing string buffer before a new event is arrived. + emitCaptionBuffer(); + mListener.emitEvent(captionEvent); + } + + private void emitCaptionBuffer() { + if (mBuffer.length() > 0) { + mListener.emitEvent(new CaptionEvent(CAPTION_EMIT_TYPE_BUFFER, mBuffer.toString())); + mBuffer.setLength(0); + } + } + + // Step 1. user_data -> CcPacket ({@link #parseClosedCaption} method) + public void parseClosedCaption(ByteBuffer data, long framePtsUs) { + int ccCount = data.limit() / 3; + byte[] ccBytes = new byte[3 * ccCount]; + for (int i = 0; i < 3 * ccCount; i++) { + ccBytes[i] = data.get(i); + } + CcPacket ccPacket = new CcPacket(ccBytes, ccCount, framePtsUs); + mCcPackets.add(ccPacket); + } + + public boolean processClosedCaptions(long framePtsUs) { + // To get the sorted cc packets that have lower frame pts than current frame pts, + // the following offset divides off the lower side of the packets. + CcPacket offsetPacket = new CcPacket(new byte[0], 0, framePtsUs); + offsetPacket = mCcPackets.lower(offsetPacket); + boolean processed = false; + if (offsetPacket != null) { + while (!mCcPackets.isEmpty() && offsetPacket.compareTo(mCcPackets.first()) >= 0) { + CcPacket packet = mCcPackets.pollFirst(); + parseCcPacket(packet); + processed = true; + } + } + return processed; + } + + // Step 2. CcPacket -> DTVCC packet ({@link #parseCcPacket} method) + private void parseCcPacket(CcPacket ccPacket) { + // For the details of cc packet, see ATSC TSG-676 - Table A8. + byte[] bytes = ccPacket.bytes; + int pos = 0; + for (int i = 0; i < ccPacket.ccCount; ++i) { + boolean ccValid = (bytes[pos] & 0x04) != 0; + int ccType = bytes[pos] & 0x03; + + // The dtvcc should be considered complete: + // - if either ccValid is set and ccType is 3 + // - or ccValid is clear and ccType is 2 or 3. + if (ccValid) { + if (ccType == CC_TYPE_DTVCC_PACKET_START) { + if (mDtvCcPacking) { + parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length()); + mDtvCcPacket.clear(); + } + mDtvCcPacking = true; + mDtvCcPacket.append(bytes[pos + 1]); + mDtvCcPacket.append(bytes[pos + 2]); + } else if (mDtvCcPacking && ccType == CC_TYPE_DTVCC_PACKET_DATA) { + mDtvCcPacket.append(bytes[pos + 1]); + mDtvCcPacket.append(bytes[pos + 2]); + } + } else { + if ((ccType == CC_TYPE_DTVCC_PACKET_START || ccType == CC_TYPE_DTVCC_PACKET_DATA) + && mDtvCcPacking) { + mDtvCcPacking = false; + parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length()); + mDtvCcPacket.clear(); + } + } + pos += 3; + } + } + + // Step 3. DTVCC packet -> Service Blocks ({@link #parseDtvCcPacket} method) + private void parseDtvCcPacket(byte[] data, int limit) { + // For the details of DTVCC packet, see CEA-708B Figure 4. + int pos = 0; + int packetSize = data[pos] & 0x3f; + if (packetSize == 0) { + packetSize = DTVCC_MAX_PACKET_SIZE; + } + int calculatedPacketSize = packetSize * DTVCC_PACKET_SIZE_SCALE_FACTOR; + if (limit != calculatedPacketSize) { + return; + } + ++pos; + int len = pos + calculatedPacketSize; + while (pos < len) { + // For the details of Service Block, see CEA-708B Figure 5 and 6. + int serviceNumber = (data[pos] & 0xe0) >> 5; + int blockSize = data[pos] & 0x1f; + ++pos; + if (serviceNumber == DTVCC_EXTENDED_SERVICE_NUMBER_POINT) { + serviceNumber = (data[pos] & 0x3f); + ++pos; + + // Return if invalid service number + if (serviceNumber < DTVCC_EXTENDED_SERVICE_NUMBER_POINT) { + return; + } + } + if (pos + blockSize > limit) { + return; + } + + // Send parsed service number in order to find unveiled closed caption tracks which + // are not specified in any ATSC PSIP sections. Since some broadcasts send empty closed + // caption tracks, it detects the proper closed caption tracks by counting the number of + // bytes sent with the same service number during a discovery period. + // The viewer in most TV sets chooses between CC1, CC2, CC3, CC4 to view different + // language captions. Therefore, only CC1, CC2, CC3, CC4 are allowed to be reported. + if (blockSize > 0 && serviceNumber >= DISCOVERY_CC_SERVICE_NUMBER_START + && serviceNumber <= DISCOVERY_CC_SERVICE_NUMBER_END) { + mDiscoveredNumBytes.put( + serviceNumber, blockSize + mDiscoveredNumBytes.get(serviceNumber, 0)); + } + if (mLastDiscoveryLaunchedMs + DISCOVERY_PERIOD_MS < SystemClock.elapsedRealtime()) { + for (int i = 0; i < mDiscoveredNumBytes.size(); ++i) { + int discoveredNumBytes = mDiscoveredNumBytes.valueAt(i); + if (discoveredNumBytes >= DISCOVERY_NUM_BYTES_THRESHOLD) { + int discoveredServiceNumber = mDiscoveredNumBytes.keyAt(i); + mListener.discoverServiceNumber(discoveredServiceNumber); + } + } + mDiscoveredNumBytes.clear(); + mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime(); + } + + // Skip current service block if either there is no block data or the service number + // is not same as listening service number. + if (blockSize == 0 || serviceNumber != mListenServiceNumber) { + pos += blockSize; + continue; + } + + // From this point, starts to read DTVCC coding layer. + // First, identify code groups, which is defined in CEA-708B Section 7.1. + int blockLimit = pos + blockSize; + while (pos < blockLimit) { + pos = parseServiceBlockData(data, pos); + } + + // Emit the buffer after reading codes. + emitCaptionBuffer(); + pos = blockLimit; + } + } + + // Step 4. Main code groups + private int parseServiceBlockData(byte[] data, int pos) { + // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6. + mCommand = data[pos] & 0xff; + ++pos; + if (mCommand == Cea708Data.CODE_C0_EXT1) { + pos = parseExt1(data, pos); + } else if (mCommand >= Cea708Data.CODE_C0_RANGE_START + && mCommand <= Cea708Data.CODE_C0_RANGE_END) { + pos = parseC0(data, pos); + } else if (mCommand >= Cea708Data.CODE_C1_RANGE_START + && mCommand <= Cea708Data.CODE_C1_RANGE_END) { + pos = parseC1(data, pos); + } else if (mCommand >= Cea708Data.CODE_G0_RANGE_START + && mCommand <= Cea708Data.CODE_G0_RANGE_END) { + pos = parseG0(data, pos); + } else if (mCommand >= Cea708Data.CODE_G1_RANGE_START + && mCommand <= Cea708Data.CODE_G1_RANGE_END) { + pos = parseG1(data, pos); + } + return pos; + } + + private int parseC0(byte[] data, int pos) { + // For the details of C0 code group, see CEA-708B Section 7.4.1. + // CL Group: C0 Subset of ASCII Control codes + if (mCommand >= Cea708Data.CODE_C0_SKIP2_RANGE_START + && mCommand <= Cea708Data.CODE_C0_SKIP2_RANGE_END) { + if (mCommand == Cea708Data.CODE_C0_P16) { + // TODO : P16 escapes next two bytes for the large character maps.(no standard rule) + // TODO : For korea broadcasting, express whole letters by using this. + try { + if (data[pos] == 0) { + mBuffer.append((char) data[pos + 1]); + } else { + String value = new String( + Arrays.copyOfRange(data, pos, pos + 2), + "EUC-KR"); + mBuffer.append(value); + } + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "P16 Code - Could not find supported encoding", e); + } + } + pos += 2; + } else if (mCommand >= Cea708Data.CODE_C0_SKIP1_RANGE_START + && mCommand <= Cea708Data.CODE_C0_SKIP1_RANGE_END) { + ++pos; + } else { + // NUL, BS, FF, CR interpreted as they are in ASCII control codes. + // HCR moves the pen location to th beginning of the current line and deletes contents. + // FF clears the screen and moves the pen location to (0,0). + // ETX is the NULL command which is used to flush text to the current window when no + // other command is pending. + switch (mCommand) { + case Cea708Data.CODE_C0_NUL: + break; + case Cea708Data.CODE_C0_ETX: + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand)); + break; + case Cea708Data.CODE_C0_BS: + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand)); + break; + case Cea708Data.CODE_C0_FF: + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand)); + break; + case Cea708Data.CODE_C0_CR: + mBuffer.append('\n'); + break; + case Cea708Data.CODE_C0_HCR: + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand)); + break; + default: + break; + } + } + return pos; + } + + private int parseC1(byte[] data, int pos) { + // For the details of C1 code group, see CEA-708B Section 8.10. + // CR Group: C1 Caption Control Codes + switch (mCommand) { + case Cea708Data.CODE_C1_CW0: + case Cea708Data.CODE_C1_CW1: + case Cea708Data.CODE_C1_CW2: + case Cea708Data.CODE_C1_CW3: + case Cea708Data.CODE_C1_CW4: + case Cea708Data.CODE_C1_CW5: + case Cea708Data.CODE_C1_CW6: + case Cea708Data.CODE_C1_CW7: { + // SetCurrentWindow0-7 + int windowId = mCommand - Cea708Data.CODE_C1_CW0; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CWX, windowId)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand CWX windowId: %d", windowId)); + } + break; + } + + case Cea708Data.CODE_C1_CLW: { + // ClearWindows + int windowBitmap = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CLW, windowBitmap)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand CLW windowBitmap: %d", windowBitmap)); + } + break; + } + + case Cea708Data.CODE_C1_DSW: { + // DisplayWindows + int windowBitmap = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DSW, windowBitmap)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand DSW windowBitmap: %d", windowBitmap)); + } + break; + } + + case Cea708Data.CODE_C1_HDW: { + // HideWindows + int windowBitmap = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_HDW, windowBitmap)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand HDW windowBitmap: %d", windowBitmap)); + } + break; + } + + case Cea708Data.CODE_C1_TGW: { + // ToggleWindows + int windowBitmap = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_TGW, windowBitmap)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand TGW windowBitmap: %d", windowBitmap)); + } + break; + } + + case Cea708Data.CODE_C1_DLW: { + // DeleteWindows + int windowBitmap = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLW, windowBitmap)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand DLW windowBitmap: %d", windowBitmap)); + } + break; + } + + case Cea708Data.CODE_C1_DLY: { + // Delay + int tenthsOfSeconds = data[pos] & 0xff; + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLY, tenthsOfSeconds)); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand DLY %d tenths of seconds", + tenthsOfSeconds)); + } + break; + } + case Cea708Data.CODE_C1_DLC: { + // DelayCancel + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLC, null)); + if (DEBUG) { + Log.d(TAG, "CaptionCommand DLC"); + } + break; + } + + case Cea708Data.CODE_C1_RST: { + // Reset + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_RST, null)); + if (DEBUG) { + Log.d(TAG, "CaptionCommand RST"); + } + break; + } + + case Cea708Data.CODE_C1_SPA: { + // SetPenAttributes + int textTag = (data[pos] & 0xf0) >> 4; + int penSize = data[pos] & 0x03; + int penOffset = (data[pos] & 0x0c) >> 2; + boolean italic = (data[pos + 1] & 0x80) != 0; + boolean underline = (data[pos + 1] & 0x40) != 0; + int edgeType = (data[pos + 1] & 0x38) >> 3; + int fontTag = data[pos + 1] & 0x7; + pos += 2; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPA, + new CaptionPenAttr(penSize, penOffset, textTag, fontTag, edgeType, + underline, italic))); + if (DEBUG) { + Log.d(TAG, String.format( + "CaptionCommand SPA penSize: %d, penOffset: %d, textTag: %d, " + + "fontTag: %d, edgeType: %d, underline: %s, italic: %s", + penSize, penOffset, textTag, fontTag, edgeType, underline, italic)); + } + break; + } + + case Cea708Data.CODE_C1_SPC: { + // SetPenColor + int opacity = (data[pos] & 0xc0) >> 6; + int red = (data[pos] & 0x30) >> 4; + int green = (data[pos] & 0x0c) >> 2; + int blue = data[pos] & 0x03; + CaptionColor foregroundColor = new CaptionColor(opacity, red, green, blue); + ++pos; + opacity = (data[pos] & 0xc0) >> 6; + red = (data[pos] & 0x30) >> 4; + green = (data[pos] & 0x0c) >> 2; + blue = data[pos] & 0x03; + CaptionColor backgroundColor = new CaptionColor(opacity, red, green, blue); + ++pos; + red = (data[pos] & 0x30) >> 4; + green = (data[pos] & 0x0c) >> 2; + blue = data[pos] & 0x03; + CaptionColor edgeColor = new CaptionColor( + CaptionColor.OPACITY_SOLID, red, green, blue); + ++pos; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPC, + new CaptionPenColor(foregroundColor, backgroundColor, edgeColor))); + if (DEBUG) { + Log.d(TAG, String.format( + "CaptionCommand SPC foregroundColor %s backgroundColor %s edgeColor %s", + foregroundColor, backgroundColor, edgeColor)); + } + break; + } + + case Cea708Data.CODE_C1_SPL: { + // SetPenLocation + // column is normally 0-31 for 4:3 formats, and 0-41 for 16:9 formats + int row = data[pos] & 0x0f; + int column = data[pos + 1] & 0x3f; + pos += 2; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPL, + new CaptionPenLocation(row, column))); + if (DEBUG) { + Log.d(TAG, String.format("CaptionCommand SPL row: %d, column: %d", + row, column)); + } + break; + } + + case Cea708Data.CODE_C1_SWA: { + // SetWindowAttributes + int opacity = (data[pos] & 0xc0) >> 6; + int red = (data[pos] & 0x30) >> 4; + int green = (data[pos] & 0x0c) >> 2; + int blue = data[pos] & 0x03; + CaptionColor fillColor = new CaptionColor(opacity, red, green, blue); + int borderType = (data[pos + 1] & 0xc0) >> 6 | (data[pos + 2] & 0x80) >> 5; + red = (data[pos + 1] & 0x30) >> 4; + green = (data[pos + 1] & 0x0c) >> 2; + blue = data[pos + 1] & 0x03; + CaptionColor borderColor = new CaptionColor( + CaptionColor.OPACITY_SOLID, red, green, blue); + boolean wordWrap = (data[pos + 2] & 0x40) != 0; + int printDirection = (data[pos + 2] & 0x30) >> 4; + int scrollDirection = (data[pos + 2] & 0x0c) >> 2; + int justify = (data[pos + 2] & 0x03); + int effectSpeed = (data[pos + 3] & 0xf0) >> 4; + int effectDirection = (data[pos + 3] & 0x0c) >> 2; + int displayEffect = data[pos + 3] & 0x3; + pos += 4; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SWA, + new CaptionWindowAttr(fillColor, borderColor, borderType, wordWrap, + printDirection, scrollDirection, justify, + effectDirection, effectSpeed, displayEffect))); + if (DEBUG) { + Log.d(TAG, String.format( + "CaptionCommand SWA fillColor: %s, borderColor: %s, borderType: %d" + + "wordWrap: %s, printDirection: %d, scrollDirection: %d, " + + "justify: %s, effectDirection: %d, effectSpeed: %d, " + + "displayEffect: %d", + fillColor, borderColor, borderType, wordWrap, printDirection, + scrollDirection, justify, effectDirection, effectSpeed, displayEffect)); + } + break; + } + + case Cea708Data.CODE_C1_DF0: + case Cea708Data.CODE_C1_DF1: + case Cea708Data.CODE_C1_DF2: + case Cea708Data.CODE_C1_DF3: + case Cea708Data.CODE_C1_DF4: + case Cea708Data.CODE_C1_DF5: + case Cea708Data.CODE_C1_DF6: + case Cea708Data.CODE_C1_DF7: { + // DefineWindow0-7 + int windowId = mCommand - Cea708Data.CODE_C1_DF0; + boolean visible = (data[pos] & 0x20) != 0; + boolean rowLock = (data[pos] & 0x10) != 0; + boolean columnLock = (data[pos] & 0x08) != 0; + int priority = data[pos] & 0x07; + boolean relativePositioning = (data[pos + 1] & 0x80) != 0; + int anchorVertical = data[pos + 1] & 0x7f; + int anchorHorizontal = data[pos + 2] & 0xff; + int anchorId = (data[pos + 3] & 0xf0) >> 4; + int rowCount = data[pos + 3] & 0x0f; + int columnCount = data[pos + 4] & 0x3f; + int windowStyle = (data[pos + 5] & 0x38) >> 3; + int penStyle = data[pos + 5] & 0x07; + pos += 6; + emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DFX, + new CaptionWindow(windowId, visible, rowLock, columnLock, priority, + relativePositioning, anchorVertical, anchorHorizontal, anchorId, + rowCount, columnCount, penStyle, windowStyle))); + if (DEBUG) { + Log.d(TAG, String.format( + "CaptionCommand DFx windowId: %d, priority: %d, columnLock: %s, " + + "rowLock: %s, visible: %s, anchorVertical: %d, " + + "relativePositioning: %s, anchorHorizontal: %d, " + + "rowCount: %d, anchorId: %d, columnCount: %d, penStyle: %d, " + + "windowStyle: %d", + windowId, priority, columnLock, rowLock, visible, anchorVertical, + relativePositioning, anchorHorizontal, rowCount, anchorId, columnCount, + penStyle, windowStyle)); + } + break; + } + + default: + break; + } + return pos; + } + + private int parseG0(byte[] data, int pos) { + // For the details of G0 code group, see CEA-708B Section 7.4.3. + // GL Group: G0 Modified version of ANSI X3.4 Printable Character Set (ASCII) + if (mCommand == Cea708Data.CODE_G0_MUSICNOTE) { + // Music note. + mBuffer.append(MUSIC_NOTE_CHAR); + } else { + // Put ASCII code into buffer. + mBuffer.append((char) mCommand); + } + return pos; + } + + private int parseG1(byte[] data, int pos) { + // For the details of G0 code group, see CEA-708B Section 7.4.4. + // GR Group: G1 ISO 8859-1 Latin 1 Characters + // Put ASCII Extended character set into buffer. + mBuffer.append((char) mCommand); + return pos; + } + + // Step 4. Extended code groups + private int parseExt1(byte[] data, int pos) { + // For the details of EXT1 code group, see CEA-708B Section 7.2. + mCommand = data[pos] & 0xff; + ++pos; + if (mCommand >= Cea708Data.CODE_C2_RANGE_START + && mCommand <= Cea708Data.CODE_C2_RANGE_END) { + pos = parseC2(data, pos); + } else if (mCommand >= Cea708Data.CODE_C3_RANGE_START + && mCommand <= Cea708Data.CODE_C3_RANGE_END) { + pos = parseC3(data, pos); + } else if (mCommand >= Cea708Data.CODE_G2_RANGE_START + && mCommand <= Cea708Data.CODE_G2_RANGE_END) { + pos = parseG2(data, pos); + } else if (mCommand >= Cea708Data.CODE_G3_RANGE_START + && mCommand <= Cea708Data.CODE_G3_RANGE_END) { + pos = parseG3(data ,pos); + } + return pos; + } + + private int parseC2(byte[] data, int pos) { + // For the details of C2 code group, see CEA-708B Section 7.4.7. + // Extended Miscellaneous Control Codes + // C2 Table : No commands as of CEA-708B. A decoder must skip. + if (mCommand >= Cea708Data.CODE_C2_SKIP0_RANGE_START + && mCommand <= Cea708Data.CODE_C2_SKIP0_RANGE_END) { + // Do nothing. + } else if (mCommand >= Cea708Data.CODE_C2_SKIP1_RANGE_START + && mCommand <= Cea708Data.CODE_C2_SKIP1_RANGE_END) { + ++pos; + } else if (mCommand >= Cea708Data.CODE_C2_SKIP2_RANGE_START + && mCommand <= Cea708Data.CODE_C2_SKIP2_RANGE_END) { + pos += 2; + } else if (mCommand >= Cea708Data.CODE_C2_SKIP3_RANGE_START + && mCommand <= Cea708Data.CODE_C2_SKIP3_RANGE_END) { + pos += 3; + } + return pos; + } + + private int parseC3(byte[] data, int pos) { + // For the details of C3 code group, see CEA-708B Section 7.4.8. + // Extended Control Code Set 2 + // C3 Table : No commands as of CEA-708B. A decoder must skip. + if (mCommand >= Cea708Data.CODE_C3_SKIP4_RANGE_START + && mCommand <= Cea708Data.CODE_C3_SKIP4_RANGE_END) { + pos += 4; + } else if (mCommand >= Cea708Data.CODE_C3_SKIP5_RANGE_START + && mCommand <= Cea708Data.CODE_C3_SKIP5_RANGE_END) { + pos += 5; + } + return pos; + } + + private int parseG2(byte[] data, int pos) { + // For the details of C3 code group, see CEA-708B Section 7.4.5. + // Extended Control Code Set 1(G2 Table) + switch (mCommand) { + case Cea708Data.CODE_G2_TSP: + // TODO : TSP is the Transparent space + break; + case Cea708Data.CODE_G2_NBTSP: + // TODO : NBTSP is Non-Breaking Transparent Space. + break; + case Cea708Data.CODE_G2_BLK: + // TODO : BLK indicates a solid block which fills the entire character block + // TODO : with a solid foreground color. + break; + default: + break; + } + return pos; + } + + private int parseG3(byte[] data, int pos) { + // For the details of C3 code group, see CEA-708B Section 7.4.6. + // Future characters and icons(G3 Table) + if (mCommand == Cea708Data.CODE_G3_CC) { + // TODO : [CC] icon with square corners + } + + // Do nothing + return pos; + } +} diff --git a/src/com/android/tv/tuner/data/Cea708Data.java b/src/com/android/tv/tuner/data/Cea708Data.java new file mode 100644 index 00000000..6350d63c --- /dev/null +++ b/src/com/android/tv/tuner/data/Cea708Data.java @@ -0,0 +1,320 @@ +/* + * 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.tuner.data; + +import com.android.tv.tuner.cc.Cea708Parser; + +import android.graphics.Color; +import android.support.annotation.NonNull; + +/** + * Collection of CEA-708 structures. + */ +public class Cea708Data { + + private Cea708Data() { + } + + // According to CEA-708B, the range of valid service number is between 1 and 63. + public static final int EMPTY_SERVICE_NUMBER = 0; + + // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6. + public static final int CODE_C0_RANGE_START = 0x00; + public static final int CODE_C0_RANGE_END = 0x1f; + public static final int CODE_C1_RANGE_START = 0x80; + public static final int CODE_C1_RANGE_END = 0x9f; + public static final int CODE_G0_RANGE_START = 0x20; + public static final int CODE_G0_RANGE_END = 0x7f; + public static final int CODE_G1_RANGE_START = 0xa0; + public static final int CODE_G1_RANGE_END = 0xff; + public static final int CODE_C2_RANGE_START = 0x00; + public static final int CODE_C2_RANGE_END = 0x1f; + public static final int CODE_C3_RANGE_START = 0x80; + public static final int CODE_C3_RANGE_END = 0x9f; + public static final int CODE_G2_RANGE_START = 0x20; + public static final int CODE_G2_RANGE_END = 0x7f; + public static final int CODE_G3_RANGE_START = 0xa0; + public static final int CODE_G3_RANGE_END = 0xff; + + // The following ranges are defined in CEA-708B Section 7.4.1. + public static final int CODE_C0_SKIP2_RANGE_START = 0x18; + public static final int CODE_C0_SKIP2_RANGE_END = 0x1f; + public static final int CODE_C0_SKIP1_RANGE_START = 0x10; + public static final int CODE_C0_SKIP1_RANGE_END = 0x17; + + // The following ranges are defined in CEA-708B Section 7.4.7. + public static final int CODE_C2_SKIP0_RANGE_START = 0x00; + public static final int CODE_C2_SKIP0_RANGE_END = 0x07; + public static final int CODE_C2_SKIP1_RANGE_START = 0x08; + public static final int CODE_C2_SKIP1_RANGE_END = 0x0f; + public static final int CODE_C2_SKIP2_RANGE_START = 0x10; + public static final int CODE_C2_SKIP2_RANGE_END = 0x17; + public static final int CODE_C2_SKIP3_RANGE_START = 0x18; + public static final int CODE_C2_SKIP3_RANGE_END = 0x1f; + + // The following ranges are defined in CEA-708B Section 7.4.8. + public static final int CODE_C3_SKIP4_RANGE_START = 0x80; + public static final int CODE_C3_SKIP4_RANGE_END = 0x87; + public static final int CODE_C3_SKIP5_RANGE_START = 0x88; + public static final int CODE_C3_SKIP5_RANGE_END = 0x8f; + + // The following values are the special characters of CEA-708 spec. + public static final int CODE_C0_NUL = 0x00; + public static final int CODE_C0_ETX = 0x03; + public static final int CODE_C0_BS = 0x08; + public static final int CODE_C0_FF = 0x0c; + public static final int CODE_C0_CR = 0x0d; + public static final int CODE_C0_HCR = 0x0e; + public static final int CODE_C0_EXT1 = 0x10; + public static final int CODE_C0_P16 = 0x18; + public static final int CODE_G0_MUSICNOTE = 0x7f; + public static final int CODE_G2_TSP = 0x20; + public static final int CODE_G2_NBTSP = 0x21; + public static final int CODE_G2_BLK = 0x30; + public static final int CODE_G3_CC = 0xa0; + + // The following values are the command bits of CEA-708 spec. + public static final int CODE_C1_CW0 = 0x80; + public static final int CODE_C1_CW1 = 0x81; + public static final int CODE_C1_CW2 = 0x82; + public static final int CODE_C1_CW3 = 0x83; + public static final int CODE_C1_CW4 = 0x84; + public static final int CODE_C1_CW5 = 0x85; + public static final int CODE_C1_CW6 = 0x86; + public static final int CODE_C1_CW7 = 0x87; + public static final int CODE_C1_CLW = 0x88; + public static final int CODE_C1_DSW = 0x89; + public static final int CODE_C1_HDW = 0x8a; + public static final int CODE_C1_TGW = 0x8b; + public static final int CODE_C1_DLW = 0x8c; + public static final int CODE_C1_DLY = 0x8d; + public static final int CODE_C1_DLC = 0x8e; + public static final int CODE_C1_RST = 0x8f; + public static final int CODE_C1_SPA = 0x90; + public static final int CODE_C1_SPC = 0x91; + public static final int CODE_C1_SPL = 0x92; + public static final int CODE_C1_SWA = 0x97; + public static final int CODE_C1_DF0 = 0x98; + public static final int CODE_C1_DF1 = 0x99; + public static final int CODE_C1_DF2 = 0x9a; + public static final int CODE_C1_DF3 = 0x9b; + public static final int CODE_C1_DF4 = 0x9c; + public static final int CODE_C1_DF5 = 0x9d; + public static final int CODE_C1_DF6 = 0x9e; + public static final int CODE_C1_DF7 = 0x9f; + + public static class CcPacket implements Comparable<CcPacket> { + public final byte[] bytes; + public final int ccCount; + public final long pts; + + public CcPacket(byte[] bytes, int ccCount, long pts) { + this.bytes = bytes; + this.ccCount = ccCount; + this.pts = pts; + } + + @Override + public int compareTo(@NonNull CcPacket another) { + return Long.compare(pts, another.pts); + } + } + + /** + * CEA-708B-specific color. + */ + public static class CaptionColor { + public static final int OPACITY_SOLID = 0; + public static final int OPACITY_FLASH = 1; + public static final int OPACITY_TRANSLUCENT = 2; + public static final int OPACITY_TRANSPARENT = 3; + + private static final int[] COLOR_MAP = new int[] { 0x00, 0x0f, 0xf0, 0xff }; + private static final int[] OPACITY_MAP = new int[] { 0xff, 0xfe, 0x80, 0x00 }; + + public final int opacity; + public final int red; + public final int green; + public final int blue; + + public CaptionColor(int opacity, int red, int green, int blue) { + this.opacity = opacity; + this.red = red; + this.green = green; + this.blue = blue; + } + + public int getArgbValue() { + return Color.argb( + OPACITY_MAP[opacity], COLOR_MAP[red], COLOR_MAP[green], COLOR_MAP[blue]); + } + } + + /** + * Caption event generated by {@link Cea708Parser}. + */ + public static class CaptionEvent { + @Cea708Parser.CaptionEmitType public final int type; + public final Object obj; + + public CaptionEvent(int type, Object obj) { + this.type = type; + this.obj = obj; + } + } + + /** + * Pen style information. + */ + public static class CaptionPenAttr { + // Pen sizes + public static final int PEN_SIZE_SMALL = 0; + public static final int PEN_SIZE_STANDARD = 1; + public static final int PEN_SIZE_LARGE = 2; + + // Offsets + public static final int OFFSET_SUBSCRIPT = 0; + public static final int OFFSET_NORMAL = 1; + public static final int OFFSET_SUPERSCRIPT = 2; + + public final int penSize; + public final int penOffset; + public final int textTag; + public final int fontTag; + public final int edgeType; + public final boolean underline; + public final boolean italic; + + public CaptionPenAttr(int penSize, int penOffset, int textTag, int fontTag, int edgeType, + boolean underline, boolean italic) { + this.penSize = penSize; + this.penOffset = penOffset; + this.textTag = textTag; + this.fontTag = fontTag; + this.edgeType = edgeType; + this.underline = underline; + this.italic = italic; + } + } + + /** + * {@link CaptionColor} objects that indicate the foreground, background, and edge color of a + * pen. + */ + public static class CaptionPenColor { + public final CaptionColor foregroundColor; + public final CaptionColor backgroundColor; + public final CaptionColor edgeColor; + + public CaptionPenColor(CaptionColor foregroundColor, CaptionColor backgroundColor, + CaptionColor edgeColor) { + this.foregroundColor = foregroundColor; + this.backgroundColor = backgroundColor; + this.edgeColor = edgeColor; + } + } + + /** + * Location information of a pen. + */ + public static class CaptionPenLocation { + public final int row; + public final int column; + + public CaptionPenLocation(int row, int column) { + this.row = row; + this.column = column; + } + } + + /** + * Attributes of a caption window, which is defined in CEA-708B. + */ + public static class CaptionWindowAttr { + public static final int JUSTIFY_LEFT = 0; + public static final int JUSTIFY_CENTER = 2; + public static final int PRINT_LEFT_TO_RIGHT = 0; + public static final int PRINT_RIGHT_TO_LEFT = 1; + public static final int PRINT_TOP_TO_BOTTOM = 2; + public static final int PRINT_BOTTOM_TO_TOP = 3; + + public final CaptionColor fillColor; + public final CaptionColor borderColor; + public final int borderType; + public final boolean wordWrap; + public final int printDirection; + public final int scrollDirection; + public final int justify; + public final int effectDirection; + public final int effectSpeed; + public final int displayEffect; + + public CaptionWindowAttr(CaptionColor fillColor, CaptionColor borderColor, int borderType, + boolean wordWrap, int printDirection, int scrollDirection, int justify, + int effectDirection, + int effectSpeed, int displayEffect) { + this.fillColor = fillColor; + this.borderColor = borderColor; + this.borderType = borderType; + this.wordWrap = wordWrap; + this.printDirection = printDirection; + this.scrollDirection = scrollDirection; + this.justify = justify; + this.effectDirection = effectDirection; + this.effectSpeed = effectSpeed; + this.displayEffect = displayEffect; + } + } + + /** + * Construction information of the caption window of CEA-708B. + */ + public static class CaptionWindow { + public final int id; + public final boolean visible; + public final boolean rowLock; + public final boolean columnLock; + public final int priority; + public final boolean relativePositioning; + public final int anchorVertical; + public final int anchorHorizontal; + public final int anchorId; + public final int rowCount; + public final int columnCount; + public final int penStyle; + public final int windowStyle; + + public CaptionWindow(int id, boolean visible, + boolean rowLock, boolean columnLock, int priority, boolean relativePositioning, + int anchorVertical, int anchorHorizontal, int anchorId, + int rowCount, int columnCount, int penStyle, int windowStyle) { + this.id = id; + this.visible = visible; + this.rowLock = rowLock; + this.columnLock = columnLock; + this.priority = priority; + this.relativePositioning = relativePositioning; + this.anchorVertical = anchorVertical; + this.anchorHorizontal = anchorHorizontal; + this.anchorId = anchorId; + this.rowCount = rowCount; + this.columnCount = columnCount; + this.penStyle = penStyle; + this.windowStyle = windowStyle; + } + } +} diff --git a/src/com/android/tv/tuner/data/PsiData.java b/src/com/android/tv/tuner/data/PsiData.java new file mode 100644 index 00000000..2c8a52db --- /dev/null +++ b/src/com/android/tv/tuner/data/PsiData.java @@ -0,0 +1,94 @@ +/* + * 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.tuner.data; + +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; + +import java.util.List; + +/** + * Collection of MPEG PSI table items. + */ +public class PsiData { + + private PsiData() { + } + + public static class PatItem { + private final int mProgramNo; + private final int mPmtPid; + + public PatItem(int programNo, int pmtPid) { + mProgramNo = programNo; + mPmtPid = pmtPid; + } + + public int getProgramNo() { + return mProgramNo; + } + + public int getPmtPid() { + return mPmtPid; + } + + @Override + public String toString() { + return String.format("Program No: %x PMT Pid: %x", mProgramNo, mPmtPid); + } + } + + public static class PmtItem { + public static final int ES_PID_PCR = 0x100; + + private final int mStreamType; + private final int mEsPid; + private final List<AtscAudioTrack> mAudioTracks; + private final List<AtscCaptionTrack> mCaptionTracks; + + public PmtItem(int streamType, int esPid, + List<AtscAudioTrack> audioTracks, List<AtscCaptionTrack> captionTracks) { + mStreamType = streamType; + mEsPid = esPid; + mAudioTracks = audioTracks; + mCaptionTracks = captionTracks; + } + + public int getStreamType() { + return mStreamType; + } + + public int getEsPid() { + return mEsPid; + } + + public List<AtscAudioTrack> getAudioTracks() { + return mAudioTracks; + } + + public List<AtscCaptionTrack> getCaptionTracks() { + return mCaptionTracks; + } + + @Override + public String toString() { + return String.format("Stream Type: %x ES Pid: %x AudioTracks: %s CaptionTracks: %s", + mStreamType, mEsPid, mAudioTracks, mCaptionTracks); + } + } +} diff --git a/src/com/android/tv/tuner/data/PsipData.java b/src/com/android/tv/tuner/data/PsipData.java new file mode 100644 index 00000000..e3cdb3a9 --- /dev/null +++ b/src/com/android/tv/tuner/data/PsipData.java @@ -0,0 +1,689 @@ +/* + * 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.tuner.data; + +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.ts.SectionParser; +import com.android.tv.tuner.util.ConvertUtils; +import com.android.tv.tuner.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Collection of ATSC PSIP table items. + */ +public class PsipData { + + private PsipData() { + } + + public static class PsipSection { + private final int mTableId; + private final int mTableIdExtension; + private final int mSectionNumber; + private final boolean mCurrentNextIndicator; + + public static PsipSection create(byte[] data) { + if (data.length < 9) { + return null; + } + int tableId = data[0] & 0xff; + int tableIdExtension = (data[3] & 0xff) << 8 | (data[4] & 0xff); + int sectionNumber = data[6] & 0xff; + boolean currentNextIndicator = (data[5] & 0x01) != 0; + return new PsipSection(tableId, tableIdExtension, sectionNumber, currentNextIndicator); + } + + private PsipSection(int tableId, int tableIdExtension, int sectionNumber, + boolean currentNextIndicator) { + mTableId = tableId; + mTableIdExtension = tableIdExtension; + mSectionNumber = sectionNumber; + mCurrentNextIndicator = currentNextIndicator; + } + + public int getTableId() { + return mTableId; + } + + public int getTableIdExtension() { + return mTableIdExtension; + } + + public int getSectionNumber() { + return mSectionNumber; + } + + // This is for indicating that the section sent is applicable. + // We only consider a situation where currentNextIndicator is expected to have a true value. + // So, we are not going to compare this variable in hashCode() and equals() methods. + public boolean getCurrentNextIndicator() { + return mCurrentNextIndicator; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + mTableId; + result = 31 * result + mTableIdExtension; + result = 31 * result + mSectionNumber; + return result; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof PsipSection) { + PsipSection another = (PsipSection) obj; + return mTableId == another.getTableId() + && mTableIdExtension == another.getTableIdExtension() + && mSectionNumber == another.getSectionNumber(); + } + return false; + } + } + + /** + * {@link TvTracksInterface} for serving the audio and caption tracks. + */ + public interface TvTracksInterface { + /** + * Set the flag that tells the caption tracks have been found in this section container. + */ + void setHasCaptionTrack(); + + /** + * Returns whether or not the caption tracks have been found in this section container. + * If true, zero caption track will be interpreted as a clearance of the caption tracks. + */ + boolean hasCaptionTrack(); + + /** + * Returns the audio tracks received. + */ + List<AtscAudioTrack> getAudioTracks(); + + /** + * Returns the caption tracks received. + */ + List<AtscCaptionTrack> getCaptionTracks(); + } + + public static class MgtItem { + public static final int TABLE_TYPE_EIT_RANGE_START = 0x0100; + public static final int TABLE_TYPE_EIT_RANGE_END = 0x017f; + public static final int TABLE_TYPE_CHANNEL_ETT = 0x0004; + public static final int TABLE_TYPE_ETT_RANGE_START = 0x0200; + public static final int TABLE_TYPE_ETT_RANGE_END = 0x027f; + + private final int mTableType; + private final int mTableTypePid; + + public MgtItem(int tableType, int tableTypePid) { + mTableType = tableType; + mTableTypePid = tableTypePid; + } + + public int getTableType() { + return mTableType; + } + + public int getTableTypePid() { + return mTableTypePid; + } + } + + public static class VctItem { + private final String mShortName; + private final String mLongName; + private final int mServiceType; + private final int mChannelTsid; + private final int mProgramNumber; + private final int mMajorChannelNumber; + private final int mMinorChannelNumber; + private final int mSourceId; + private String mDescription; + + public VctItem(String shortName, String longName, int serviceType, int channelTsid, + int programNumber, int majorChannelNumber, int minorChannelNumber, int sourceId) { + mShortName = shortName; + mLongName = longName; + mServiceType = serviceType; + mChannelTsid = channelTsid; + mProgramNumber = programNumber; + mMajorChannelNumber = majorChannelNumber; + mMinorChannelNumber = minorChannelNumber; + mSourceId = sourceId; + } + + public String getShortName() { + return mShortName; + } + + public String getLongName() { + return mLongName; + } + + public int getServiceType() { + return mServiceType; + } + + public int getChannelTsid() { + return mChannelTsid; + } + + public int getProgramNumber() { + return mProgramNumber; + } + + public int getMajorChannelNumber() { + return mMajorChannelNumber; + } + + public int getMinorChannelNumber() { + return mMinorChannelNumber; + } + + public int getSourceId() { + return mSourceId; + } + + @Override + public String toString() { + return String + .format(Locale.US, "ShortName: %s LongName: %s ServiceType: %d ChannelTsid: %x " + + "ProgramNumber:%d %d-%d SourceId: %x", + mShortName, mLongName, mServiceType, mChannelTsid, + mProgramNumber, mMajorChannelNumber, mMinorChannelNumber, mSourceId); + } + + public void setDescription(String description) { + mDescription = description; + } + + public String getDescription() { + return mDescription; + } + } + + /** + * A base class for descriptors of Ts packets. + */ + public abstract static class TsDescriptor { + public abstract int getTag(); + } + + public static class ContentAdvisoryDescriptor extends TsDescriptor { + private final List<RatingRegion> mRatingRegions; + + public ContentAdvisoryDescriptor(List<RatingRegion> ratingRegions) { + mRatingRegions = ratingRegions; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_CONTENT_ADVISORY; + } + + public List<RatingRegion> getRatingRegions() { + return mRatingRegions; + } + } + + public static class CaptionServiceDescriptor extends TsDescriptor { + private final List<AtscCaptionTrack> mCaptionTracks; + + public CaptionServiceDescriptor(List<AtscCaptionTrack> captionTracks) { + mCaptionTracks = captionTracks; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_CAPTION_SERVICE; + } + + public List<AtscCaptionTrack> getCaptionTracks() { + return mCaptionTracks; + } + } + + public static class ExtendedChannelNameDescriptor extends TsDescriptor { + private final String mLongChannelName; + + public ExtendedChannelNameDescriptor(String longChannelName) { + mLongChannelName = longChannelName; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME; + } + + public String getLongChannelName() { + return mLongChannelName; + } + } + + public static class GenreDescriptor extends TsDescriptor { + private final String[] mBroadcastGenres; + private final String[] mCanonicalGenres; + + public GenreDescriptor(String[] broadcastGenres, String[] canonicalGenres) { + mBroadcastGenres = broadcastGenres; + mCanonicalGenres = canonicalGenres; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_GENRE; + } + + public String[] getBroadcastGenres() { + return mBroadcastGenres; + } + + public String[] getCanonicalGenres() { + return mCanonicalGenres; + } + } + + public static class Ac3AudioDescriptor extends TsDescriptor { + // See A/52 Annex A. Table A4.2 + private static final byte SAMPLE_RATE_CODE_48000HZ = 0; + private static final byte SAMPLE_RATE_CODE_44100HZ = 1; + private static final byte SAMPLE_RATE_CODE_32000HZ = 2; + + private final byte mSampleRateCode; + private final byte mBsid; + private final byte mBitRateCode; + private final byte mSurroundMode; + private final byte mBsmod; + private final int mNumChannels; + private final boolean mFullSvc; + private final byte mLangCod; + private final byte mLangCod2; + private final byte mMainId; + private final byte mPriority; + private final byte mAsvcflags; + private final String mText; + private final String mLanguage; + private final String mLanguage2; + + public Ac3AudioDescriptor(byte sampleRateCode, byte bsid, byte bitRateCode, + byte surroundMode, byte bsmod, int numChannels, boolean fullSvc, byte langCod, + byte langCod2, byte mainId, byte priority, byte asvcflags, String text, + String language, String language2) { + mSampleRateCode = sampleRateCode; + mBsid = bsid; + mBitRateCode = bitRateCode; + mSurroundMode = surroundMode; + mBsmod = bsmod; + mNumChannels = numChannels; + mFullSvc = fullSvc; + mLangCod = langCod; + mLangCod2 = langCod2; + mMainId = mainId; + mPriority = priority; + mAsvcflags = asvcflags; + mText = text; + mLanguage = language; + mLanguage2 = language2; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_AC3_AUDIO_STREAM; + } + + public byte getSampleRateCode() { + return mSampleRateCode; + } + + public int getSampleRate() { + switch (mSampleRateCode) { + case SAMPLE_RATE_CODE_48000HZ: + return 48000; + case SAMPLE_RATE_CODE_44100HZ: + return 44100; + case SAMPLE_RATE_CODE_32000HZ: + return 32000; + default: + return 0; + } + } + + public byte getBsid() { + return mBsid; + } + + public byte getBitRateCode() { + return mBitRateCode; + } + + public byte getSurroundMode() { + return mSurroundMode; + } + + public byte getBsmod() { + return mBsmod; + } + + public int getNumChannels() { + return mNumChannels; + } + + public boolean isFullSvc() { + return mFullSvc; + } + + public byte getLangCod() { + return mLangCod; + } + + public byte getLangCod2() { + return mLangCod2; + } + + public byte getMainId() { + return mMainId; + } + + public byte getPriority() { + return mPriority; + } + + public byte getAsvcflags() { + return mAsvcflags; + } + + public String getText() { + return mText; + } + + public String getLanguage() { + return mLanguage; + } + + public String getLanguage2() { + return mLanguage2; + } + + @Override + public String toString() { + return String.format(Locale.US, + "AC3 audio stream sampleRateCode: %d, bsid: %d, bitRateCode: %d, " + + "surroundMode: %d, bsmod: %d, numChannels: %d, fullSvc: %s, langCod: %d, " + + "langCod2: %d, mainId: %d, priority: %d, avcflags: %d, text: %s, language: %s" + + ", language2: %s", mSampleRateCode, mBsid, mBitRateCode, mSurroundMode, + mBsmod, mNumChannels, mFullSvc, mLangCod, mLangCod2, mMainId, mPriority, + mAsvcflags, mText, mLanguage, mLanguage2); + } + } + + public static class Iso639LanguageDescriptor extends TsDescriptor { + private final List<AtscAudioTrack> mAudioTracks; + + public Iso639LanguageDescriptor(List<AtscAudioTrack> audioTracks) { + mAudioTracks = audioTracks; + } + + @Override + public int getTag() { + return SectionParser.DESCRIPTOR_TAG_ISO639LANGUAGE; + } + + public List<AtscAudioTrack> getAudioTracks() { + return mAudioTracks; + } + + @Override + public String toString() { + return String.format("%s %s", getClass().getName(), mAudioTracks); + } + } + + public static class RatingRegion { + private final int mName; + private final String mDescription; + private final List<RegionalRating> mRegionalRatings; + + public RatingRegion(int name, String description, List<RegionalRating> regionalRatings) { + mName = name; + mDescription = description; + mRegionalRatings = regionalRatings; + } + + public int getName() { + return mName; + } + + public String getDescription() { + return mDescription; + } + + public List<RegionalRating> getRegionalRatings() { + return mRegionalRatings; + } + } + + public static class RegionalRating { + private final int mDimension; + private final int mRating; + + public RegionalRating(int dimension, int rating) { + mDimension = dimension; + mRating = rating; + } + + public int getDimension() { + return mDimension; + } + + public int getRating() { + return mRating; + } + } + + public static class EitItem implements Comparable<EitItem>, TvTracksInterface { + public static final long INVALID_PROGRAM_ID = -1; + + // A program id is a primary key of TvContract.Programs table. So it must be positive. + private final long mProgramId; + private final int mEventId; + private final String mTitleText; + private String mDescription; + private final long mStartTime; + private final int mLengthInSecond; + private final String mContentRating; + private final List<AtscAudioTrack> mAudioTracks; + private final List<AtscCaptionTrack> mCaptionTracks; + private boolean mHasCaptionTrack; + private final String mBroadcastGenre; + private final String mCanonicalGenre; + + public EitItem(long programId, int eventId, String titleText, long startTime, + int lengthInSecond, String contentRating, List<AtscAudioTrack> audioTracks, + List<AtscCaptionTrack> captionTracks, String broadcastGenre, String canonicalGenre, + String description) { + mProgramId = programId; + mEventId = eventId; + mTitleText = titleText; + mStartTime = startTime; + mLengthInSecond = lengthInSecond; + mContentRating = contentRating; + mAudioTracks = audioTracks; + mCaptionTracks = captionTracks; + mBroadcastGenre = broadcastGenre; + mCanonicalGenre = canonicalGenre; + mDescription = description; + } + + public long getProgramId() { + return mProgramId; + } + + public int getEventId() { + return mEventId; + } + + public String getTitleText() { + return mTitleText; + } + + public void setDescription(String description) { + mDescription = description; + } + + public String getDescription() { + return mDescription; + } + + public long getStartTime() { + return mStartTime; + } + + public int getLengthInSecond() { + return mLengthInSecond; + } + + public long getStartTimeUtcMillis() { + return ConvertUtils.convertGPSTimeToUnixEpoch(mStartTime) * DateUtils.SECOND_IN_MILLIS; + } + + public long getEndTimeUtcMillis() { + return ConvertUtils.convertGPSTimeToUnixEpoch( + mStartTime + mLengthInSecond) * DateUtils.SECOND_IN_MILLIS; + } + + public String getContentRating() { + return mContentRating; + } + + @Override + public List<AtscAudioTrack> getAudioTracks() { + return mAudioTracks; + } + + @Override + public List<AtscCaptionTrack> getCaptionTracks() { + return mCaptionTracks; + } + + public String getBroadcastGenre() { + return mBroadcastGenre; + } + + public String getCanonicalGenre() { + return mCanonicalGenre; + } + + @Override + public void setHasCaptionTrack() { + mHasCaptionTrack = true; + } + + @Override + public boolean hasCaptionTrack() { + return mHasCaptionTrack; + } + + @Override + public int compareTo(@NonNull EitItem item) { + // The list of caption tracks and the program ids are not compared in here because the + // channels in TIF have the concept of the caption and audio tracks while the programs + // do not and the programs in TIF only have a program id since they are the rows of + // Content Provider. + int ret = mEventId - item.getEventId(); + if (ret != 0) { + return ret; + } + ret = StringUtils.compare(mTitleText, item.getTitleText()); + if (ret != 0) { + return ret; + } + if (mStartTime > item.getStartTime()) { + return 1; + } else if (mStartTime < item.getStartTime()) { + return -1; + } + if (mLengthInSecond > item.getLengthInSecond()) { + return 1; + } else if (mLengthInSecond < item.getLengthInSecond()) { + return -1; + } + + // Compares content ratings + ret = StringUtils.compare(mContentRating, item.getContentRating()); + if (ret != 0) { + return ret; + } + + // Compares broadcast genres + ret = StringUtils.compare(mBroadcastGenre, item.getBroadcastGenre()); + if (ret != 0) { + return ret; + } + // Compares canonical genres + ret = StringUtils.compare(mCanonicalGenre, item.getCanonicalGenre()); + if (ret != 0) { + return ret; + } + + // Compares descriptions + return StringUtils.compare(mDescription, item.getDescription()); + } + + public String getAudioLanguage() { + if (mAudioTracks == null) { + return ""; + } + ArrayList<String> languages = new ArrayList<>(); + for (AtscAudioTrack audioTrack : mAudioTracks) { + languages.add(audioTrack.language); + } + return TextUtils.join(",", languages); + } + + @Override + public String toString() { + return String.format(Locale.US, + "EitItem programId: %d, eventId: %d, title: %s, startTime: %10d, " + + "length: %6d, rating: %s, audio tracks: %d, caption tracks: %d, " + + "genres (broadcast: %s, canonical: %s), description: %s", + mProgramId, mEventId, mTitleText, mStartTime, mLengthInSecond, mContentRating, + mAudioTracks != null ? mAudioTracks.size() : 0, + mCaptionTracks != null ? mCaptionTracks.size() : 0, + mBroadcastGenre, mCanonicalGenre, mDescription); + } + } + + public static class EttItem { + public final int eventId; + public final String text; + + public EttItem(int eventId, String text) { + this.eventId = eventId; + this.text = text; + } + } +} diff --git a/src/com/android/tv/tuner/data/TunerChannel.java b/src/com/android/tv/tuner/data/TunerChannel.java new file mode 100644 index 00000000..22cf2aa6 --- /dev/null +++ b/src/com/android/tv/tuner/data/TunerChannel.java @@ -0,0 +1,396 @@ +/* + * 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.tuner.data; + +import android.support.annotation.NonNull; +import android.util.Log; + +import com.android.tv.tuner.data.Channel; +import com.android.tv.tuner.data.Channel.TunerChannelProto; +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.util.Ints; +import com.android.tv.tuner.util.StringUtils; +import com.google.protobuf.nano.MessageNano; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A class that represents a single channel accessible through a tuner. + */ +public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracksInterface { + private static final String TAG = "TunerChannel"; + + // See ATSC Code Points Registry. + private static final String[] ATSC_SERVICE_TYPE_NAMES = new String[] { + "ATSC Reserved", + "Analog television channels", + "ATSC_digital_television", + "ATSC_audio", + "ATSC_data_only_service", + "Software Download", + "Unassociated/Small Screen Service", + "Parameterized Service", + "ATSC NRT Service", + "Extended Parameterized Service" }; + private static final String ATSC_SERVICE_TYPE_NAME_RESERVED = + ATSC_SERVICE_TYPE_NAMES[Channel.SERVICE_TYPE_ATSC_RESERVED]; + + public static final int INVALID_FREQUENCY = -1; + + // According to RFC4259, The number of available PIDs ranges from 0 to 8191. + public static final int INVALID_PID = -1; + + // According to ISO13818-1, Mpeg2 StreamType has a range from 0x00 to 0xff. + public static final int INVALID_STREAMTYPE = -1; + + private final TunerChannelProto mProto; + + private TunerChannel(PsipData.VctItem channel, int programNumber, + List<PsiData.PmtItem> pmtItems, int type) { + mProto = new TunerChannelProto(); + if (channel == null) { + mProto.shortName = ""; + mProto.tsid = 0; + mProto.programNumber = programNumber; + mProto.virtualMajor = 0; + mProto.virtualMinor = 0; + } else { + mProto.shortName = channel.getShortName(); + if (channel.getLongName() != null) { + mProto.longName = channel.getLongName(); + } + mProto.tsid = channel.getChannelTsid(); + mProto.programNumber = channel.getProgramNumber(); + mProto.virtualMajor = channel.getMajorChannelNumber(); + mProto.virtualMinor = channel.getMinorChannelNumber(); + if (channel.getDescription() != null) { + mProto.description = channel.getDescription(); + } + mProto.serviceType = channel.getServiceType(); + } + mProto.type = type; + mProto.channelId = -1L; + mProto.frequency = INVALID_FREQUENCY; + mProto.videoPid = INVALID_PID; + mProto.videoStreamType = INVALID_STREAMTYPE; + List<Integer> audioPids = new ArrayList<>(); + List<Integer> audioStreamTypes = new ArrayList<>(); + for (PsiData.PmtItem pmt : pmtItems) { + switch (pmt.getStreamType()) { + // MPEG ES stream video types + case Channel.MPEG1: + case Channel.MPEG2: + case Channel.H263: + case Channel.H264: + case Channel.H265: + mProto.videoPid = pmt.getEsPid(); + mProto.videoStreamType = pmt.getStreamType(); + break; + + // MPEG ES stream audio types + case Channel.MPEG1AUDIO: + case Channel.MPEG2AUDIO: + case Channel.MPEG2AACAUDIO: + case Channel.MPEG4LATMAACAUDIO: + case Channel.A52AC3AUDIO: + case Channel.EAC3AUDIO: + audioPids.add(pmt.getEsPid()); + audioStreamTypes.add(pmt.getStreamType()); + break; + + // Non MPEG ES stream types + case 0x100: // PmtItem.ES_PID_PCR: + mProto.pcrPid = pmt.getEsPid(); + break; + } + } + mProto.audioPids = Ints.toArray(audioPids); + mProto.audioStreamTypes = Ints.toArray(audioStreamTypes); + mProto.audioTrackIndex = (audioPids.size() > 0) ? 0 : -1; + } + + public TunerChannel(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) { + this(channel, 0, pmtItems, Channel.TYPE_TUNER); + } + + public TunerChannel(int programNumber, List<PsiData.PmtItem> pmtItems) { + this(null, programNumber, pmtItems, Channel.TYPE_TUNER); + } + + private TunerChannel(TunerChannelProto tunerChannelProto) { + mProto = tunerChannelProto; + } + + public static TunerChannel forFile(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) { + return new TunerChannel(channel, 0, pmtItems, Channel.TYPE_FILE); + } + + public String getName() { + return (!mProto.shortName.isEmpty()) ? mProto.shortName : mProto.longName; + } + + public String getShortName() { + return mProto.shortName; + } + + public int getProgramNumber() { + return mProto.programNumber; + } + + public int getServiceType() { + return mProto.serviceType; + } + + public String getServiceTypeName() { + int serviceType = mProto.serviceType; + if (serviceType >= 0 && serviceType < ATSC_SERVICE_TYPE_NAMES.length) { + return ATSC_SERVICE_TYPE_NAMES[serviceType]; + } + return ATSC_SERVICE_TYPE_NAME_RESERVED; + } + + public int getVirtualMajor() { + return mProto.virtualMajor; + } + + public int getVirtualMinor() { + return mProto.virtualMinor; + } + + public int getFrequency() { + return mProto.frequency; + } + + public String getModulation() { + return mProto.modulation; + } + + public int getTsid() { + return mProto.tsid; + } + + public int getVideoPid() { + return mProto.videoPid; + } + + public void setVideoPid(int videoPid) { + mProto.videoPid = videoPid; + } + + public int getVideoStreamType() { + return mProto.videoStreamType; + } + + public int getAudioPid() { + if (mProto.audioTrackIndex == -1) { + return INVALID_PID; + } + return mProto.audioPids[mProto.audioTrackIndex]; + } + + public int getAudioStreamType() { + if (mProto.audioTrackIndex == -1) { + return INVALID_STREAMTYPE; + } + return mProto.audioStreamTypes[mProto.audioTrackIndex]; + } + + public List<Integer> getAudioPids() { + return Ints.asList(mProto.audioPids); + } + + public void setAudioPids(List<Integer> audioPids) { + mProto.audioPids = Ints.toArray(audioPids); + } + + public List<Integer> getAudioStreamTypes() { + return Ints.asList(mProto.audioStreamTypes); + } + + public void setAudioStreamTypes(List<Integer> audioStreamTypes) { + mProto.audioStreamTypes = Ints.toArray(audioStreamTypes); + } + + public int getPcrPid() { + return mProto.pcrPid; + } + + public int getType() { + return mProto.type; + } + + public void setFilepath(String filepath) { + mProto.filepath = filepath; + } + + public String getFilepath() { + return mProto.filepath; + } + + public void setVirtualMajor(int virtualMajor) { + mProto.virtualMajor = virtualMajor; + } + + public void setVirtualMinor(int virtualMinor) { + mProto.virtualMinor = virtualMinor; + } + + public void setShortName(String shortName) { + mProto.shortName = shortName; + } + + public void setFrequency(int frequency) { + mProto.frequency = frequency; + } + + public void setModulation(String modulation) { + mProto.modulation = modulation; + } + + public boolean hasVideo() { + return mProto.videoPid != INVALID_PID; + } + + public boolean hasAudio() { + return getAudioPid() != INVALID_PID; + } + + public long getChannelId() { + return mProto.channelId; + } + + public void setChannelId(long channelId) { + mProto.channelId = channelId; + } + + public String getDisplayNumber() { + if (mProto.virtualMajor != 0 && mProto.virtualMinor != 0) { + return String.format("%d-%d", mProto.virtualMajor, mProto.virtualMinor); + } else if (mProto.virtualMajor != 0) { + return Integer.toString(mProto.virtualMajor); + } else { + return Integer.toString(mProto.programNumber); + } + } + + public String getDescription() { + return mProto.description; + } + + @Override + public void setHasCaptionTrack() { + mProto.hasCaptionTrack = true; + } + + @Override + public boolean hasCaptionTrack() { + return mProto.hasCaptionTrack; + } + + @Override + public List<AtscAudioTrack> getAudioTracks() { + return Collections.unmodifiableList(Arrays.asList(mProto.audioTracks)); + } + + public void setAudioTracks(List<AtscAudioTrack> audioTracks) { + mProto.audioTracks = audioTracks.toArray(new AtscAudioTrack[audioTracks.size()]); + } + + @Override + public List<AtscCaptionTrack> getCaptionTracks() { + return Collections.unmodifiableList(Arrays.asList(mProto.captionTracks)); + } + + public void setCaptionTracks(List<AtscCaptionTrack> captionTracks) { + mProto.captionTracks = captionTracks.toArray(new AtscCaptionTrack[captionTracks.size()]); + } + + public void selectAudioTrack(int index) { + if (0 <= index && index < mProto.audioPids.length) { + mProto.audioTrackIndex = index; + } else { + mProto.audioTrackIndex = -1; + } + } + + @Override + public String toString() { + switch (mProto.type) { + case Channel.TYPE_FILE: + return String.format("{%d-%d %s} Filepath: %s, ProgramNumber %d", + mProto.virtualMajor, mProto.virtualMinor, mProto.shortName, + mProto.filepath, mProto.programNumber); + //case Channel.TYPE_TUNER: + default: + return String.format("{%d-%d %s} Frequency: %d, ProgramNumber %d", + mProto.virtualMajor, mProto.virtualMinor, mProto.shortName, + mProto.frequency, mProto.programNumber); + } + } + + @Override + public int compareTo(@NonNull TunerChannel channel) { + // In the same frequency, the program number acts as the sub-channel number. + int ret = getFrequency() - channel.getFrequency(); + if (ret != 0) { + return ret; + } + ret = getProgramNumber() - channel.getProgramNumber(); + if (ret != 0) { + return ret; + } + + // For FileTsStreamer, file paths should be compared. + return StringUtils.compare(getFilepath(), channel.getFilepath()); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof TunerChannel)) { + return false; + } + return compareTo((TunerChannel) o) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(getFrequency(), getProgramNumber(), getFilepath()); + } + + // Serialization + public byte[] toByteArray() { + return MessageNano.toByteArray(mProto); + } + + public static TunerChannel parseFrom(byte[] data) { + if (data == null) { + return null; + } + try { + return new TunerChannel(TunerChannelProto.parseFrom(data)); + } catch (IOException e) { + Log.e(TAG, "Could not parse from byte array", e); + return null; + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java new file mode 100644 index 00000000..5e839223 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java @@ -0,0 +1,283 @@ +/* + * 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.tuner.exoplayer; + +import android.util.Log; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaClock; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.util.Assertions; +import com.android.tv.tuner.cc.Cea708Parser; +import com.android.tv.tuner.data.Cea708Data.CaptionEvent; + +import java.io.IOException; + +/** + * A {@link TrackRenderer} for CEA-708 textual subtitles. + */ +public class Cea708TextTrackRenderer extends TrackRenderer implements + Cea708Parser.OnCea708ParserListener { + private static final String TAG = "Cea708TextTrackRenderer"; + private static final boolean DEBUG = false; + + public static final int MSG_SERVICE_NUMBER = 1; + + // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps. + private static final int DEFAULT_INPUT_BUFFER_SIZE = 9600 / 8; + + private final SampleSource.SampleSourceReader mSource; + private final SampleHolder mSampleHolder; + private final MediaFormatHolder mFormatHolder; + private int mServiceNumber; + private boolean mInputStreamEnded; + private long mCurrentPositionUs; + private long mPresentationTimeUs; + private int mTrackIndex; + private Cea708Parser mCea708Parser; + private CcListener mCcListener; + + public interface CcListener { + void emitEvent(CaptionEvent captionEvent); + void discoverServiceNumber(int serviceNumber); + } + + public Cea708TextTrackRenderer(SampleSource source) { + mSource = source.register(); + mTrackIndex = -1; + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT); + mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE); + mFormatHolder = new MediaFormatHolder(); + } + + @Override + protected MediaClock getMediaClock() { + return null; + } + + private boolean handlesMimeType(String mimeType) { + return mimeType.equals(MpegTsSampleExtractor.MIMETYPE_TEXT_CEA_708); + } + + @Override + protected boolean doPrepare(long positionUs) throws ExoPlaybackException { + boolean sourcePrepared = mSource.prepare(positionUs); + if (!sourcePrepared) { + return false; + } + int trackCount = mSource.getTrackCount(); + for (int i = 0; i < trackCount; ++i) { + MediaFormat trackFormat = mSource.getFormat(i); + if (handlesMimeType(trackFormat.mimeType)) { + mTrackIndex = i; + clearDecodeState(); + return true; + } + } + // TODO: Check this case. (Source do not have the proper mime type.) + return true; + } + + @Override + protected void onEnabled(int track, long positionUs, boolean joining) { + Assertions.checkArgument(mTrackIndex != -1 && track == 0); + mSource.enable(mTrackIndex, positionUs); + mInputStreamEnded = false; + mPresentationTimeUs = positionUs; + mCurrentPositionUs = Long.MIN_VALUE; + } + + @Override + protected void onDisabled() { + mSource.disable(mTrackIndex); + } + + @Override + protected void onReleased() { + mSource.release(); + mCea708Parser = null; + } + + @Override + protected boolean isEnded() { + return mInputStreamEnded; + } + + @Override + protected boolean isReady() { + // Since this track will be fed by {@link VideoTrackRenderer}, + // it is not required to control transition between ready state and buffering state. + return true; + } + + @Override + protected int getTrackCount() { + return mTrackIndex < 0 ? 0 : 1; + } + + @Override + protected MediaFormat getFormat(int track) { + Assertions.checkArgument(mTrackIndex != -1 && track == 0); + return mSource.getFormat(mTrackIndex); + } + + @Override + protected void maybeThrowError() throws ExoPlaybackException { + try { + mSource.maybeThrowError(); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + @Override + protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + try { + mPresentationTimeUs = positionUs; + if (!mInputStreamEnded) { + processOutput(); + feedInputBuffer(); + } + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + private boolean processOutput() { + return !mInputStreamEnded && mCea708Parser != null && + mCea708Parser.processClosedCaptions(mPresentationTimeUs); + } + + private boolean feedInputBuffer() throws IOException, ExoPlaybackException { + if (mInputStreamEnded) { + return false; + } + long discontinuity = mSource.readDiscontinuity(mTrackIndex); + if (discontinuity != SampleSource.NO_DISCONTINUITY) { + if (DEBUG) { + Log.d(TAG, "Read discontinuity happened"); + } + + // TODO: handle input discontinuity for trickplay. + clearDecodeState(); + mPresentationTimeUs = discontinuity; + return false; + } + mSampleHolder.data.clear(); + mSampleHolder.size = 0; + int result = mSource.readData(mTrackIndex, mPresentationTimeUs, + mFormatHolder, mSampleHolder); + switch (result) { + case SampleSource.NOTHING_READ: { + return false; + } + case SampleSource.FORMAT_READ: { + if (DEBUG) { + Log.i(TAG, "Format was read again"); + } + return true; + } + case SampleSource.END_OF_STREAM: { + if (DEBUG) { + Log.i(TAG, "End of stream from SampleSource"); + } + mInputStreamEnded = true; + return false; + } + case SampleSource.SAMPLE_READ: { + mSampleHolder.data.flip(); + if (mCea708Parser != null) { + mCea708Parser.parseClosedCaption(mSampleHolder.data, mSampleHolder.timeUs); + } + return true; + } + } + return false; + } + + private void clearDecodeState() { + mCea708Parser = new Cea708Parser(); + mCea708Parser.setListener(this); + mCea708Parser.setListenServiceNumber(mServiceNumber); + } + + @Override + protected long getDurationUs() { + return mSource.getFormat(mTrackIndex).durationUs; + } + + @Override + protected long getBufferedPositionUs() { + return mSource.getBufferedPositionUs(); + } + + @Override + protected void seekTo(long currentPositionUs) throws ExoPlaybackException { + mSource.seekToUs(currentPositionUs); + mInputStreamEnded = false; + mPresentationTimeUs = currentPositionUs; + mCurrentPositionUs = Long.MIN_VALUE; + } + + @Override + protected void onStarted() { + // do nothing. + } + + @Override + protected void onStopped() { + // do nothing. + } + + private void setServiceNumber(int serviceNumber) { + mServiceNumber = serviceNumber; + if (mCea708Parser != null) { + mCea708Parser.setListenServiceNumber(serviceNumber); + } + } + + @Override + public void emitEvent(CaptionEvent event) { + if (mCcListener != null) { + mCcListener.emitEvent(event); + } + } + + @Override + public void discoverServiceNumber(int serviceNumber) { + if (mCcListener != null) { + mCcListener.discoverServiceNumber(serviceNumber); + } + } + + public void setCcListener(CcListener ccListener) { + mCcListener = ccListener; + } + + @Override + public void handleMessage(int messageType, Object message) throws ExoPlaybackException { + if (messageType == MSG_SERVICE_NUMBER) { + setServiceNumber((int) message); + } else { + super.handleMessage(messageType, message); + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java new file mode 100644 index 00000000..c105e222 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java @@ -0,0 +1,398 @@ +/* + * 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.tuner.exoplayer; + +import android.net.Uri; +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.extractor.ExtractorSampleSource; +import com.google.android.exoplayer.extractor.ExtractorSampleSource.EventListener; +import com.google.android.exoplayer.upstream.Allocator; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.upstream.DefaultAllocator; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer; +import com.android.tv.tuner.exoplayer.buffer.SimpleSampleBuffer; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A class that extracts samples from a live broadcast stream while storing the sample on the disk. + * For demux, this class relies on {@link com.google.android.exoplayer.extractor.ts.TsExtractor}. + */ +public class ExoPlayerSampleExtractor implements SampleExtractor { + private static final String TAG = "ExoPlayerSampleExtracto"; + + // Buffer segment size for memory allocator. Copied from demo implementation of ExoPlayer. + private static final int BUFFER_SEGMENT_SIZE_IN_BYTES = 64 * 1024; + // Buffer segment count for sample source. Copied from demo implementation of ExoPlayer. + private static final int BUFFER_SEGMENT_COUNT = 256; + + private final HandlerThread mSourceReaderThread; + private final long mId; + + private final Handler.Callback mSourceReaderWorker; + + private BufferManager.SampleBuffer mSampleBuffer; + private Handler mSourceReaderHandler; + private volatile boolean mPrepared; + private AtomicBoolean mOnCompletionCalled = new AtomicBoolean(); + private IOException mExceptionOnPrepare; + private List<MediaFormat> mTrackFormats; + private HashMap<Integer, Long> mLastExtractedPositionUsMap = new HashMap<>(); + private OnCompletionListener mOnCompletionListener; + private Handler mOnCompletionListenerHandler; + private IOException mError; + + public ExoPlayerSampleExtractor(Uri uri, DataSource source, BufferManager bufferManager, + PlaybackBufferListener bufferListener, boolean isRecording) { + // It'll be used as a timeshift file chunk name's prefix. + mId = System.currentTimeMillis(); + Allocator allocator = new DefaultAllocator(BUFFER_SEGMENT_SIZE_IN_BYTES); + + EventListener eventListener = new EventListener() { + + @Override + public void onLoadError(int sourceId, IOException e) { + mError = e; + } + }; + + mSourceReaderThread = new HandlerThread("SourceReaderThread"); + mSourceReaderWorker = new SourceReaderWorker(new ExtractorSampleSource(uri, source, + allocator, BUFFER_SEGMENT_COUNT * BUFFER_SEGMENT_SIZE_IN_BYTES, + // Do not create a handler if we not on a looper. e.g. test. + Looper.myLooper() != null ? new Handler() : null, + eventListener, 0)); + if (isRecording) { + mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, false, + RecordingSampleBuffer.BUFFER_REASON_RECORDING); + } else { + if (bufferManager == null || bufferManager.isDisabled()) { + mSampleBuffer = new SimpleSampleBuffer(bufferListener); + } else { + mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, true, + RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK); + } + } + } + + @Override + public void setOnCompletionListener(OnCompletionListener listener, Handler handler) { + mOnCompletionListener = listener; + mOnCompletionListenerHandler = handler; + } + + private class SourceReaderWorker implements Handler.Callback { + public static final int MSG_PREPARE = 1; + public static final int MSG_FETCH_SAMPLES = 2; + public static final int MSG_RELEASE = 3; + private static final int RETRY_INTERVAL_MS = 50; + + private final SampleSource mSampleSource; + private SampleSource.SampleSourceReader mSampleSourceReader; + private boolean[] mTrackMetEos; + private boolean mMetEos = false; + private long mCurrentPosition; + + public SourceReaderWorker(SampleSource sampleSource) { + mSampleSource = sampleSource; + } + + @Override + public boolean handleMessage(Message message) { + switch (message.what) { + case MSG_PREPARE: + mPrepared = prepare(); + if (!mPrepared && mExceptionOnPrepare == null) { + mSourceReaderHandler + .sendEmptyMessageDelayed(MSG_PREPARE, RETRY_INTERVAL_MS); + } else{ + mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES); + } + return true; + case MSG_FETCH_SAMPLES: + boolean didSomething = false; + SampleHolder sample = new SampleHolder( + SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + ConditionVariable conditionVariable = new ConditionVariable(); + int trackCount = mSampleSourceReader.getTrackCount(); + for (int i = 0; i < trackCount; ++i) { + if (!mTrackMetEos[i] && SampleSource.NOTHING_READ + != fetchSample(i, sample, conditionVariable)) { + if (mMetEos) { + // If mMetEos was on during fetchSample() due to an error, + // fetching from other tracks is not necessary. + break; + } + didSomething = true; + } + } + if (!mMetEos) { + if (didSomething) { + mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES); + } else { + mSourceReaderHandler.sendEmptyMessageDelayed(MSG_FETCH_SAMPLES, + RETRY_INTERVAL_MS); + } + } else { + notifyCompletionIfNeeded(false); + } + return true; + case MSG_RELEASE: + if (mSampleSourceReader != null) { + if (mPrepared) { + // ExtractorSampleSource expects all the tracks should be disabled + // before releasing. + int count = mSampleSourceReader.getTrackCount(); + for (int i = 0; i < count; ++i) { + mSampleSourceReader.disable(i); + } + } + mSampleSourceReader.release(); + mSampleSourceReader = null; + } + cleanUp(); + mSourceReaderHandler.removeCallbacksAndMessages(null); + return true; + } + return false; + } + + private boolean prepare() { + if (mSampleSourceReader == null) { + mSampleSourceReader = mSampleSource.register(); + } + if(!mSampleSourceReader.prepare(0)) { + return false; + } + if (mTrackFormats == null) { + int trackCount = mSampleSourceReader.getTrackCount(); + mTrackMetEos = new boolean[trackCount]; + List<MediaFormat> trackFormats = new ArrayList<>(); + for (int i = 0; i < trackCount; i++) { + trackFormats.add(mSampleSourceReader.getFormat(i)); + mSampleSourceReader.enable(i, 0); + + } + mTrackFormats = trackFormats; + List<String> ids = new ArrayList<>(); + for (int i = 0; i < mTrackFormats.size(); i++) { + ids.add(String.format(Locale.ENGLISH, "%s_%x", Long.toHexString(mId), i)); + } + try { + mSampleBuffer.init(ids, mTrackFormats); + } catch (IOException e) { + // In this case, we will not schedule any further operation. + // mExceptionOnPrepare will be notified to ExoPlayer, and ExoPlayer will + // call release() eventually. + mExceptionOnPrepare = e; + return false; + } + } + return true; + } + + private int fetchSample(int track, SampleHolder sample, + ConditionVariable conditionVariable) { + mSampleSourceReader.continueBuffering(track, mCurrentPosition); + + MediaFormatHolder formatHolder = new MediaFormatHolder(); + sample.clearData(); + int ret = mSampleSourceReader.readData(track, mCurrentPosition, formatHolder, sample); + if (ret == SampleSource.SAMPLE_READ) { + if (mCurrentPosition < sample.timeUs) { + mCurrentPosition = sample.timeUs; + } + try { + Long lastExtractedPositionUs = mLastExtractedPositionUsMap.get(track); + if (lastExtractedPositionUs == null) { + mLastExtractedPositionUsMap.put(track, sample.timeUs); + } else { + mLastExtractedPositionUsMap.put(track, + Math.max(lastExtractedPositionUs, sample.timeUs)); + } + queueSample(track, sample, conditionVariable); + } catch (IOException e) { + mLastExtractedPositionUsMap.clear(); + mMetEos = true; + mSampleBuffer.setEos(); + } + } else if (ret == SampleSource.END_OF_STREAM) { + mTrackMetEos[track] = true; + for (int i = 0; i < mTrackMetEos.length; ++i) { + if (!mTrackMetEos[i]) { + break; + } + if (i == mTrackMetEos.length -1) { + mMetEos = true; + mSampleBuffer.setEos(); + } + } + } + // TODO: Handle SampleSource.FORMAT_READ for dynamic resolution change. b/28169263 + return ret; + } + } + + private void queueSample(int index, SampleHolder sample, ConditionVariable conditionVariable) + throws IOException { + long writeStartTimeNs = SystemClock.elapsedRealtimeNanos(); + mSampleBuffer.writeSample(index, sample, conditionVariable); + + // Checks whether the storage has enough bandwidth for recording samples. + if (mSampleBuffer.isWriteSpeedSlow(sample.size, + SystemClock.elapsedRealtimeNanos() - writeStartTimeNs)) { + mSampleBuffer.handleWriteSpeedSlow(); + } + } + + @Override + public void maybeThrowError() throws IOException { + if (mError != null) { + IOException e = mError; + mError = null; + throw e; + } + } + + @Override + public boolean prepare() throws IOException { + if (!mSourceReaderThread.isAlive()) { + mSourceReaderThread.start(); + mSourceReaderHandler = new Handler(mSourceReaderThread.getLooper(), + mSourceReaderWorker); + mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_PREPARE); + } + if (mExceptionOnPrepare != null) { + throw mExceptionOnPrepare; + } + return mPrepared; + } + + @Override + public List<MediaFormat> getTrackFormats() { + return mTrackFormats; + } + + @Override + public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) { + outMediaFormatHolder.format = mTrackFormats.get(track); + outMediaFormatHolder.drmInitData = null; + } + + @Override + public void selectTrack(int index) { + mSampleBuffer.selectTrack(index); + } + + @Override + public void deselectTrack(int index) { + mSampleBuffer.deselectTrack(index); + } + + @Override + public long getBufferedPositionUs() { + return mSampleBuffer.getBufferedPositionUs(); + } + + @Override + public boolean continueBuffering(long positionUs) { + return mSampleBuffer.continueBuffering(positionUs); + } + + @Override + public void seekTo(long positionUs) { + mSampleBuffer.seekTo(positionUs); + } + + @Override + public int readSample(int track, SampleHolder sampleHolder) { + return mSampleBuffer.readSample(track, sampleHolder); + } + + @Override + public void release() { + if (mSourceReaderThread.isAlive()) { + mSourceReaderHandler.removeCallbacksAndMessages(null); + mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_RELEASE); + mSourceReaderThread.quitSafely(); + // Return early in this case so that session worker can start working on the next + // request as early as it can. The clean up will be done in the reader thread while + // handling MSG_RELEASE. + } else { + cleanUp(); + } + } + + private void cleanUp() { + boolean result = true; + try { + if (mSampleBuffer != null) { + mSampleBuffer.release(); + mSampleBuffer = null; + } + } catch (IOException e) { + result = false; + } + notifyCompletionIfNeeded(result); + setOnCompletionListener(null, null); + } + + private void notifyCompletionIfNeeded(final boolean result) { + if (!mOnCompletionCalled.getAndSet(true)) { + final OnCompletionListener listener = mOnCompletionListener; + final long lastExtractedPositionUs = getLastExtractedPositionUs(); + if (mOnCompletionListenerHandler != null && mOnCompletionListener != null) { + mOnCompletionListenerHandler.post(new Runnable() { + @Override + public void run() { + listener.onCompletion(result, lastExtractedPositionUs); + } + }); + } + } + } + + private long getLastExtractedPositionUs() { + long lastExtractedPositionUs = Long.MAX_VALUE; + for (long value : mLastExtractedPositionUsMap.values()) { + lastExtractedPositionUs = Math.min(lastExtractedPositionUs, value); + } + if (lastExtractedPositionUs == Long.MAX_VALUE) { + lastExtractedPositionUs = C.UNKNOWN_TIME_US; + } + return lastExtractedPositionUs; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java new file mode 100644 index 00000000..ec7b4b16 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java @@ -0,0 +1,140 @@ +/* + * 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.tuner.exoplayer; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.MediaFormatUtil; +import com.google.android.exoplayer.SampleHolder; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; + +import android.os.Handler; +import android.util.Pair; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * A class that plays a recorded stream without using {@link android.media.MediaExtractor}, + * since all samples are extracted and stored to the permanent storage already. + */ +public class FileSampleExtractor implements SampleExtractor{ + private static final String TAG = "FileSampleExtractor"; + private static final boolean DEBUG = false; + + private int mTrackCount; + private boolean mReleased; + + private final List<MediaFormat> mTrackFormats = new ArrayList<>(); + private final BufferManager mBufferManager; + private final PlaybackBufferListener mBufferListener; + private BufferManager.SampleBuffer mSampleBuffer; + + public FileSampleExtractor( + BufferManager bufferManager, PlaybackBufferListener bufferListener) { + mBufferManager = bufferManager; + mBufferListener = bufferListener; + mTrackCount = -1; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public boolean prepare() throws IOException { + ArrayList<Pair<String, android.media.MediaFormat>> trackInfos = + mBufferManager.readTrackInfoFiles(); + if (trackInfos == null || trackInfos.isEmpty()) { + throw new IOException("Cannot find meta files for the recording."); + } + mTrackCount = trackInfos.size(); + List<String> ids = new ArrayList<>(); + mTrackFormats.clear(); + for (int i = 0; i < mTrackCount; ++i) { + Pair<String, android.media.MediaFormat> pair = trackInfos.get(i); + ids.add(pair.first); + mTrackFormats.add(MediaFormatUtil.createMediaFormat(pair.second)); + } + mSampleBuffer = new RecordingSampleBuffer(mBufferManager, mBufferListener, true, + RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK); + mSampleBuffer.init(ids, mTrackFormats); + return true; + } + + @Override + public List<MediaFormat> getTrackFormats() { + return mTrackFormats; + } + + @Override + public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) { + outMediaFormatHolder.format = mTrackFormats.get(track); + outMediaFormatHolder.drmInitData = null; + } + + @Override + public void release() { + if (!mReleased) { + if (mSampleBuffer != null) { + try { + mSampleBuffer.release(); + } catch (IOException e) { + // Do nothing. Playback ends now. + } + } + } + mReleased = true; + } + + @Override + public void selectTrack(int index) { + mSampleBuffer.selectTrack(index); + } + + @Override + public void deselectTrack(int index) { + mSampleBuffer.deselectTrack(index); + } + + @Override + public long getBufferedPositionUs() { + return mSampleBuffer.getBufferedPositionUs(); + } + + @Override + public void seekTo(long positionUs) { + mSampleBuffer.seekTo(positionUs); + } + + @Override + public int readSample(int track, SampleHolder sampleHolder) { + return mSampleBuffer.readSample(track, sampleHolder); + } + + @Override + public boolean continueBuffering(long positionUs) { + return mSampleBuffer.continueBuffering(positionUs); + } + + @Override + public void setOnCompletionListener(OnCompletionListener listener, Handler handler) { } +} diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java new file mode 100644 index 00000000..381b22e9 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java @@ -0,0 +1,653 @@ +/* + * 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.tuner.exoplayer; + +import android.content.Context; +import android.media.AudioFormat; +import android.media.MediaCodec.CryptoException; +import android.media.PlaybackParams; +import android.os.Handler; +import android.support.annotation.IntDef; +import android.view.Surface; + +import com.google.android.exoplayer.DummyTrackRenderer; +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.ExoPlayer; +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaCodecTrackRenderer.DecoderInitializationException; +import com.google.android.exoplayer.MediaCodecVideoTrackRenderer; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.audio.AudioCapabilities; +import com.google.android.exoplayer.audio.AudioTrack; +import com.google.android.exoplayer.upstream.DataSource; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.data.Cea708Data; +import com.android.tv.tuner.data.Cea708Data.CaptionEvent; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.exoplayer.ac3.Ac3PassthroughTrackRenderer; +import com.android.tv.tuner.exoplayer.ac3.Ac3TrackRenderer; +import com.android.tv.tuner.source.TsDataSource; +import com.android.tv.tuner.source.TsDataSourceManager; +import com.android.tv.tuner.tvinput.EventDetector; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * MPEG-2 TS stream player implementation using ExoPlayer. + */ +public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRenderer.EventListener, + Ac3PassthroughTrackRenderer.EventListener, Ac3TrackRenderer.Ac3EventListener { + private int mCaptionServiceNumber = Cea708Data.EMPTY_SERVICE_NUMBER; + + /** + * Interface definition for building specific track renderers. + */ + public interface RendererBuilder { + void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource, + RendererBuilderCallback callback); + } + + /** + * Interface definition for {@link RendererBuilder#buildRenderers} to notify the result. + */ + public interface RendererBuilderCallback { + void onRenderers(String[][] trackNames, TrackRenderer[] renderers); + void onRenderersError(Exception e); + } + + /** + * Interface definition for a callback to be notified of changes in player state. + */ + public interface Listener { + void onStateChanged(boolean playWhenReady, int playbackState); + void onError(Exception e); + void onVideoSizeChanged(int width, int height, + float pixelWidthHeightRatio); + void onDrawnToSurface(MpegTsPlayer player, Surface surface); + void onAudioUnplayable(); + void onSmoothTrickplayForceStopped(); + } + + /** + * Interface definition for a callback to be notified of changes on video display. + */ + public interface VideoEventListener { + /** + * Notifies the caption event. + */ + void onEmitCaptionEvent(CaptionEvent event); + + /** + * Notifies the discovered caption service number. + */ + void onDiscoverCaptionServiceNumber(int serviceNumber); + } + + public static final int RENDERER_COUNT = 3; + public static final int MIN_BUFFER_MS = 0; + public static final int MIN_REBUFFER_MS = 500; + + @IntDef({TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO, TRACK_TYPE_TEXT}) + @Retention(RetentionPolicy.SOURCE) + public @interface TrackType {} + public static final int TRACK_TYPE_VIDEO = 0; + public static final int TRACK_TYPE_AUDIO = 1; + public static final int TRACK_TYPE_TEXT = 2; + + @IntDef({RENDERER_BUILDING_STATE_IDLE, RENDERER_BUILDING_STATE_BUILDING, + RENDERER_BUILDING_STATE_BUILT}) + @Retention(RetentionPolicy.SOURCE) + public @interface RendererBuildingState {} + private static final int RENDERER_BUILDING_STATE_IDLE = 1; + private static final int RENDERER_BUILDING_STATE_BUILDING = 2; + private static final int RENDERER_BUILDING_STATE_BUILT = 3; + + private static final float MAX_SMOOTH_TRICKPLAY_SPEED = 9.0f; + private static final float MIN_SMOOTH_TRICKPLAY_SPEED = 0.1f; + + private final RendererBuilder mRendererBuilder; + private final ExoPlayer mPlayer; + private final Handler mMainHandler; + private final AudioCapabilities mAudioCapabilities; + private final TsDataSourceManager mSourceManager; + + private Listener mListener; + @RendererBuildingState private int mRendererBuildingState; + + private Surface mSurface; + private TsDataSource mDataSource; + private InternalRendererBuilderCallback mBuilderCallback; + private TrackRenderer mVideoRenderer; + private TrackRenderer mAudioRenderer; + private Cea708TextTrackRenderer mTextRenderer; + private final Cea708TextTrackRenderer.CcListener mCcListener; + private VideoEventListener mVideoEventListener; + private boolean mTrickplayRunning; + private float mVolume; + + /** + * Creates MPEG2-TS stream player. + * + * @param rendererBuilder the builder of track renderers + * @param handler the handler for the playback events in track renderers + * @param sourceManager the manager for {@link DataSource} + * @param capabilities the {@link AudioCapabilities} of the current device + * @param listener the listener for playback state changes + */ + public MpegTsPlayer(RendererBuilder rendererBuilder, Handler handler, + TsDataSourceManager sourceManager, AudioCapabilities capabilities, + Listener listener) { + mRendererBuilder = rendererBuilder; + mPlayer = ExoPlayer.Factory.newInstance(RENDERER_COUNT, MIN_BUFFER_MS, MIN_REBUFFER_MS); + mPlayer.addListener(this); + mMainHandler = handler; + mAudioCapabilities = capabilities; + mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE; + mCcListener = new MpegTsCcListener(); + mSourceManager = sourceManager; + mListener = listener; + } + + /** + * Sets the video event listener. + * + * @param videoEventListener the listener for video events + */ + public void setVideoEventListener(VideoEventListener videoEventListener) { + mVideoEventListener = videoEventListener; + } + + /** + * Sets the closed caption service number. + * + * @param captionServiceNumber the service number of CEA-708 closed caption + */ + public void setCaptionServiceNumber(int captionServiceNumber) { + mCaptionServiceNumber = captionServiceNumber; + if (mTextRenderer != null) { + mPlayer.sendMessage(mTextRenderer, + Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber); + } + } + + /** + * Sets the surface for the player. + * + * @param surface the {@link Surface} to render video + */ + public void setSurface(Surface surface) { + mSurface = surface; + pushSurface(false); + } + + /** + * Returns the current surface of the player. + */ + public Surface getSurface() { + return mSurface; + } + + /** + * Clears the surface and waits until the surface is being cleaned. + */ + public void blockingClearSurface() { + mSurface = null; + pushSurface(true); + } + + /** + * Creates renderers and {@link DataSource} and initializes player. + * @param context a {@link Context} instance + * @param channel to play + * @param eventListener for program information which will be scanned from MPEG2-TS stream + * @return true when everything is created and initialized well, false otherwise + */ + public boolean prepare(Context context, TunerChannel channel, + EventDetector.EventListener eventListener) { + TsDataSource source = null; + if (channel != null) { + source = mSourceManager.createDataSource(context, channel, eventListener); + if (source == null) { + return false; + } + } + mDataSource = source; + if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILT) { + mPlayer.stop(); + } + if (mBuilderCallback != null) { + mBuilderCallback.cancel(); + } + mRendererBuildingState = RENDERER_BUILDING_STATE_BUILDING; + mBuilderCallback = new InternalRendererBuilderCallback(); + mRendererBuilder.buildRenderers(this, source, mBuilderCallback); + return true; + } + + /** + * Returns {@link TsDataSource} which provides MPEG2-TS stream. + */ + public TsDataSource getDataSource() { + return mDataSource; + } + + private void onRenderers(TrackRenderer[] renderers) { + mBuilderCallback = null; + for (int i = 0; i < RENDERER_COUNT; i++) { + if (renderers[i] == null) { + // Convert a null renderer to a dummy renderer. + renderers[i] = new DummyTrackRenderer(); + } + } + mVideoRenderer = renderers[TRACK_TYPE_VIDEO]; + mAudioRenderer = renderers[TRACK_TYPE_AUDIO]; + mTextRenderer = (Cea708TextTrackRenderer) renderers[TRACK_TYPE_TEXT]; + mTextRenderer.setCcListener(mCcListener); + mPlayer.sendMessage( + mTextRenderer, Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber); + mRendererBuildingState = RENDERER_BUILDING_STATE_BUILT; + pushSurface(false); + mPlayer.prepare(renderers); + pushTrackSelection(TRACK_TYPE_VIDEO, true); + pushTrackSelection(TRACK_TYPE_AUDIO, true); + pushTrackSelection(TRACK_TYPE_TEXT, true); + } + + private void onRenderersError(Exception e) { + mBuilderCallback = null; + mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE; + if (mListener != null) { + mListener.onError(e); + } + } + + /** + * Sets the player state to pause or play. + * + * @param playWhenReady sets the player state to being ready to play when {@code true}, + * sets the player state to being paused when {@code false} + * + */ + public void setPlayWhenReady(boolean playWhenReady) { + mPlayer.setPlayWhenReady(playWhenReady); + stopSmoothTrickplay(false); + } + + /** + * Returns true, if trickplay is supported. + */ + public boolean supportSmoothTrickPlay(float playbackSpeed) { + return playbackSpeed > MIN_SMOOTH_TRICKPLAY_SPEED + && playbackSpeed < MAX_SMOOTH_TRICKPLAY_SPEED; + } + + /** + * Starts trickplay. It'll be reset, if {@link #seekTo} or {@link #setPlayWhenReady} is called. + */ + public void startSmoothTrickplay(PlaybackParams playbackParams) { + SoftPreconditions.checkState(supportSmoothTrickPlay(playbackParams.getSpeed())); + mPlayer.setPlayWhenReady(true); + mTrickplayRunning = true; + if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED, + playbackParams.getSpeed()); + } else { + mPlayer.sendMessage(mAudioRenderer, + MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS, + playbackParams); + } + } + + private void stopSmoothTrickplay(boolean calledBySeek) { + if (mTrickplayRunning) { + mTrickplayRunning = false; + if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, + Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED, + 1.0f); + } else { + mPlayer.sendMessage(mAudioRenderer, + MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS, + new PlaybackParams().setSpeed(1.0f)); + } + if (!calledBySeek) { + mPlayer.seekTo(mPlayer.getCurrentPosition()); + } + } + } + + /** + * Seeks to the specified position of the current playback. + * + * @param positionMs the specified position in milli seconds. + */ + public void seekTo(long positionMs) { + mPlayer.seekTo(positionMs); + stopSmoothTrickplay(true); + } + + /** + * Releases the player. + */ + public void release() { + if (mDataSource != null) { + mSourceManager.releaseDataSource(mDataSource); + mDataSource = null; + } + if (mBuilderCallback != null) { + mBuilderCallback.cancel(); + mBuilderCallback = null; + } + mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE; + mSurface = null; + mListener = null; + mPlayer.release(); + } + + /** + * Returns the current status of the player. + */ + public int getPlaybackState() { + if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILDING) { + return ExoPlayer.STATE_PREPARING; + } + return mPlayer.getPlaybackState(); + } + + /** + * Returns {@code true} when the player is prepared to play, {@code false} otherwise. + */ + public boolean isPrepared() { + int state = getPlaybackState(); + return state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING; + } + + /** + * Returns {@code true} when the player is being ready to play, {@code false} otherwise. + */ + public boolean isPlaying() { + int state = getPlaybackState(); + return (state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING) + && mPlayer.getPlayWhenReady(); + } + + /** + * Returns {@code true} when the player is buffering, {@code false} otherwise. + */ + public boolean isBuffering() { + return getPlaybackState() == ExoPlayer.STATE_BUFFERING; + } + + /** + * Returns the current position of the playback in milli seconds. + */ + public long getCurrentPosition() { + return mPlayer.getCurrentPosition(); + } + + /** + * Returns the total duration of the playback. + */ + public long getDuration() { + return mPlayer.getDuration(); + } + + /** + * Returns {@code true} when the player is being ready to play, + * {@code false} when the player is paused. + */ + public boolean getPlayWhenReady() { + return mPlayer.getPlayWhenReady(); + } + + /** + * Sets the volume of the audio. + * + * @param volume see also {@link AudioTrack#setVolume(float)} + */ + public void setVolume(float volume) { + mVolume = volume; + if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_VOLUME, volume); + } else { + mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, + volume); + } + } + + /** + * Enables or disables audio. + * + * @param enable enables the audio when {@code true}, disables otherwise. + */ + public void setAudioTrack(boolean enable) { + if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_AUDIO_TRACK, + enable ? 1 : 0); + } else { + mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, + enable ? mVolume : 0.0f); + } + } + + /** + * Returns {@code true} when AC3 audio can be played, {@code false} otherwise. + */ + public boolean isAc3Playable() { + return mAudioCapabilities != null + && mAudioCapabilities.supportsEncoding(AudioFormat.ENCODING_AC3); + } + + /** + * Notifies when the audio cannot be played by the current device. + */ + public void onAudioUnplayable() { + if (mListener != null) { + mListener.onAudioUnplayable(); + } + } + + /** + * Returns {@code true} if the player has any video track, {@code false} otherwise. + */ + public boolean hasVideo() { + return mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0; + } + + /** + * Returns {@code true} if the player has any audio trock, {@code false} otherwise. + */ + public boolean hasAudio() { + return mPlayer.getTrackCount(TRACK_TYPE_AUDIO) > 0; + } + + /** + * Returns the number of tracks exposed by the specified renderer. + */ + public int getTrackCount(int rendererIndex) { + return mPlayer.getTrackCount(rendererIndex); + } + + /** + * Selects a track for the specified renderer. + */ + public void setSelectedTrack(int rendererIndex, int trackIndex) { + if (trackIndex >= getTrackCount(rendererIndex)) { + return; + } + mPlayer.setSelectedTrack(rendererIndex, trackIndex); + } + + /** + * Gets the main handler of the player. + */ + /* package */ Handler getMainHandler() { + return mMainHandler; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int state) { + if (mListener == null) { + return; + } + mListener.onStateChanged(playWhenReady, state); + if (state == ExoPlayer.STATE_READY && mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0 + && playWhenReady) { + MediaFormat format = mPlayer.getTrackFormat(TRACK_TYPE_VIDEO, 0); + mListener.onVideoSizeChanged(format.width, + format.height, format.pixelWidthHeightRatio); + } + } + + @Override + public void onPlayerError(ExoPlaybackException exception) { + mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE; + if (mListener != null) { + mListener.onError(exception); + } + } + + @Override + public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + if (mListener != null) { + mListener.onVideoSizeChanged(width, height, pixelWidthHeightRatio); + } + } + + @Override + public void onDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs) { + // Do nothing. + } + + @Override + public void onDecoderInitializationError(DecoderInitializationException e) { + // Do nothing. + } + + @Override + public void onAudioTrackInitializationError(AudioTrack.InitializationException e) { + if (mListener != null) { + mListener.onAudioUnplayable(); + } + } + + @Override + public void onAudioTrackWriteError(AudioTrack.WriteException e) { + // Do nothing. + } + + @Override + public void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, + long elapsedSinceLastFeedMs) { + // Do nothing. + } + + @Override + public void onCryptoError(CryptoException e) { + // Do nothing. + } + + @Override + public void onPlayWhenReadyCommitted() { + // Do nothing. + } + + @Override + public void onDrawnToSurface(Surface surface) { + if (mListener != null) { + mListener.onDrawnToSurface(this, surface); + } + } + + @Override + public void onDroppedFrames(int count, long elapsed) { + if (mTrickplayRunning && mListener != null) { + mListener.onSmoothTrickplayForceStopped(); + } + } + + @Override + public void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e) { + if (mTrickplayRunning && mListener != null) { + mListener.onSmoothTrickplayForceStopped(); + } + } + + private void pushSurface(boolean blockForSurfacePush) { + if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) { + return; + } + + if (blockForSurfacePush) { + mPlayer.blockingSendMessage( + mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface); + } else { + mPlayer.sendMessage( + mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface); + } + } + + private void pushTrackSelection(@TrackType int type, boolean allowRendererEnable) { + if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) { + return; + } + mPlayer.setSelectedTrack(type, allowRendererEnable ? 0 : -1); + } + + private class MpegTsCcListener implements Cea708TextTrackRenderer.CcListener { + + @Override + public void emitEvent(CaptionEvent captionEvent) { + if (mVideoEventListener != null) { + mVideoEventListener.onEmitCaptionEvent(captionEvent); + } + } + + @Override + public void discoverServiceNumber(int serviceNumber) { + if (mVideoEventListener != null) { + mVideoEventListener.onDiscoverCaptionServiceNumber(serviceNumber); + } + } + } + + private class InternalRendererBuilderCallback implements RendererBuilderCallback { + private boolean canceled; + + public void cancel() { + canceled = true; + } + + @Override + public void onRenderers(String[][] trackNames, TrackRenderer[] renderers) { + if (!canceled) { + MpegTsPlayer.this.onRenderers(renderers); + } + } + + @Override + public void onRenderersError(Exception e) { + if (!canceled) { + MpegTsPlayer.this.onRenderersError(e); + } + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java new file mode 100644 index 00000000..0e46c9cf --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java @@ -0,0 +1,67 @@ +/* + * 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.tuner.exoplayer; + +import android.content.Context; + +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.upstream.DataSource; +import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilder; +import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilderCallback; +import com.android.tv.tuner.exoplayer.ac3.Ac3PassthroughTrackRenderer; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; + +/** + * Builder for renderer objects for {@link MpegTsPlayer}. + */ +public class MpegTsRendererBuilder implements RendererBuilder { + private final Context mContext; + private final BufferManager mBufferManager; + private final PlaybackBufferListener mBufferListener; + + public MpegTsRendererBuilder(Context context, BufferManager bufferManager, + PlaybackBufferListener bufferListener) { + mContext = context; + mBufferManager = bufferManager; + mBufferListener = bufferListener; + } + + @Override + public void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource, + RendererBuilderCallback callback) { + // Build the video and audio renderers. + SampleExtractor extractor = dataSource == null ? + new MpegTsSampleExtractor(mBufferManager, mBufferListener) : + new MpegTsSampleExtractor(dataSource, mBufferManager, mBufferListener); + SampleSource sampleSource = new MpegTsSampleSource(extractor); + MpegTsVideoTrackRenderer videoRenderer = new MpegTsVideoTrackRenderer(mContext, + sampleSource, mpegTsPlayer.getMainHandler(), mpegTsPlayer); + // TODO: Only using Ac3PassthroughTrackRenderer for A/V sync issue. We will use + // {@link Ac3TrackRenderer} when we use ExoPlayer's extractor. + TrackRenderer audioRenderer = new Ac3PassthroughTrackRenderer(sampleSource, + mpegTsPlayer.getMainHandler(), mpegTsPlayer); + Cea708TextTrackRenderer textRenderer = new Cea708TextTrackRenderer(sampleSource); + + TrackRenderer[] renderers = new TrackRenderer[MpegTsPlayer.RENDERER_COUNT]; + renderers[MpegTsPlayer.TRACK_TYPE_VIDEO] = videoRenderer; + renderers[MpegTsPlayer.TRACK_TYPE_AUDIO] = audioRenderer; + renderers[MpegTsPlayer.TRACK_TYPE_TEXT] = textRenderer; + callback.onRenderers(null, renderers); + } +} diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java new file mode 100644 index 00000000..7bf116c8 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java @@ -0,0 +1,335 @@ +/* + * 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.tuner.exoplayer; + +import android.net.Uri; +import android.os.Handler; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.upstream.DataSource; +import com.google.android.exoplayer.util.MimeTypes; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.SamplePool; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +/** + * Extracts samples from {@link DataSource} for MPEG-TS streams. + */ +public final class MpegTsSampleExtractor implements SampleExtractor { + public static final String MIMETYPE_TEXT_CEA_708 = "text/cea-708"; + + private static final int CC_BUFFER_SIZE_IN_BYTES = 9600 / 8; + + private final SampleExtractor mSampleExtractor; + private final List<MediaFormat> mTrackFormats = new ArrayList<>(); + private final List<Boolean> mReachedEos = new ArrayList<>(); + private int mVideoTrackIndex; + private final SamplePool mCcSamplePool = new SamplePool(); + private final List<SampleHolder> mPendingCcSamples = new LinkedList<>(); + + private int mCea708TextTrackIndex; + private boolean mCea708TextTrackSelected; + + private CcParser mCcParser; + + private void init() { + mVideoTrackIndex = -1; + mCea708TextTrackIndex = -1; + mCea708TextTrackSelected = false; + } + + /** + * Creates MpegTsSampleExtractor for {@link DataSource}. + * + * @param source the {@link DataSource} to extract from + * @param bufferManager the manager for reading & writing samples backed by physical storage + * @param bufferListener the {@link PlaybackBufferListener} + * to notify buffer storage status change + */ + public MpegTsSampleExtractor(DataSource source, BufferManager bufferManager, + PlaybackBufferListener bufferListener) { + mSampleExtractor = new ExoPlayerSampleExtractor(Uri.EMPTY, source, bufferManager, + bufferListener, false); + init(); + } + + /** + * Creates MpegTsSampleExtractor for a recorded program. + * + * @param bufferManager the samples provider which is stored in physical storage + * @param bufferListener the {@link PlaybackBufferListener} + * to notify buffer storage status change + */ + public MpegTsSampleExtractor(BufferManager bufferManager, + PlaybackBufferListener bufferListener) { + mSampleExtractor = new FileSampleExtractor(bufferManager, bufferListener); + init(); + } + + @Override + public void maybeThrowError() throws IOException { + if (mSampleExtractor != null) { + mSampleExtractor.maybeThrowError(); + } + } + + @Override + public boolean prepare() throws IOException { + if(!mSampleExtractor.prepare()) { + return false; + } + List<MediaFormat> formats = mSampleExtractor.getTrackFormats(); + int trackCount = formats.size(); + mTrackFormats.clear(); + mReachedEos.clear(); + + for (int i = 0; i < trackCount; ++i) { + mTrackFormats.add(formats.get(i)); + mReachedEos.add(false); + String mime = formats.get(i).mimeType; + if (MimeTypes.isVideo(mime) && mVideoTrackIndex == -1) { + mVideoTrackIndex = i; + if (android.media.MediaFormat.MIMETYPE_VIDEO_MPEG2.equals(mime)) { + mCcParser = new Mpeg2CcParser(); + } else if (android.media.MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) { + mCcParser = new H264CcParser(); + } + } + } + + if (mVideoTrackIndex != -1) { + mCea708TextTrackIndex = trackCount; + } + if (mCea708TextTrackIndex >= 0) { + mTrackFormats.add(MediaFormat.createTextFormat(null, MIMETYPE_TEXT_CEA_708, 0, + mTrackFormats.get(0).durationUs, "")); + } + return true; + } + + @Override + public List<MediaFormat> getTrackFormats() { + return mTrackFormats; + } + + @Override + public void selectTrack(int index) { + if (index == mCea708TextTrackIndex) { + mCea708TextTrackSelected = true; + return; + } + mSampleExtractor.selectTrack(index); + } + + @Override + public void deselectTrack(int index) { + if (index == mCea708TextTrackIndex) { + mCea708TextTrackSelected = false; + return; + } + mSampleExtractor.deselectTrack(index); + } + + @Override + public long getBufferedPositionUs() { + return mSampleExtractor.getBufferedPositionUs(); + } + + @Override + public void seekTo(long positionUs) { + mSampleExtractor.seekTo(positionUs); + for (SampleHolder holder : mPendingCcSamples) { + mCcSamplePool.releaseSample(holder); + } + mPendingCcSamples.clear(); + } + + @Override + public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) { + if (track != mCea708TextTrackIndex) { + mSampleExtractor.getTrackMediaFormat(track, outMediaFormatHolder); + } + } + + @Override + public int readSample(int track, SampleHolder sampleHolder) { + if (track == mCea708TextTrackIndex) { + if (mCea708TextTrackSelected && !mPendingCcSamples.isEmpty()) { + SampleHolder holder = mPendingCcSamples.remove(0); + holder.data.flip(); + sampleHolder.timeUs = holder.timeUs; + sampleHolder.data.put(holder.data); + mCcSamplePool.releaseSample(holder); + return SampleSource.SAMPLE_READ; + } else { + return mVideoTrackIndex < 0 || mReachedEos.get(mVideoTrackIndex) + ? SampleSource.END_OF_STREAM : SampleSource.NOTHING_READ; + } + } + + int result = mSampleExtractor.readSample(track, sampleHolder); + switch (result) { + case SampleSource.END_OF_STREAM: { + mReachedEos.set(track, true); + break; + } + case SampleSource.SAMPLE_READ: { + if (mCea708TextTrackSelected && track == mVideoTrackIndex + && sampleHolder.data != null) { + mCcParser.mayParseClosedCaption(sampleHolder.data, sampleHolder.timeUs); + } + break; + } + } + return result; + } + + @Override + public void release() { + mSampleExtractor.release(); + mVideoTrackIndex = -1; + mCea708TextTrackIndex = -1; + mCea708TextTrackSelected = false; + } + + @Override + public boolean continueBuffering(long positionUs) { + return mSampleExtractor.continueBuffering(positionUs); + } + + @Override + public void setOnCompletionListener(OnCompletionListener listener, Handler handler) { } + + private abstract class CcParser { + // Interim buffer for reduce direct access to ByteBuffer which is expensive. Using + // relatively small buffer size in order to minimize memory footprint increase. + protected final byte[] mBuffer = new byte[1024]; + + abstract void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs); + + protected int parseClosedCaption(ByteBuffer buffer, int offset, long presentationTimeUs) { + // For the details of user_data_type_structure, see ATSC A/53 Part 4 - Table 6.9. + int pos = offset; + if (pos + 2 >= buffer.position()) { + return offset; + } + boolean processCcDataFlag = (buffer.get(pos) & 64) != 0; + int ccCount = buffer.get(pos) & 0x1f; + pos += 2; + if (!processCcDataFlag || pos + 3 * ccCount >= buffer.position() || ccCount == 0) { + return offset; + } + SampleHolder holder = mCcSamplePool.acquireSample(CC_BUFFER_SIZE_IN_BYTES); + for (int i = 0; i < 3 * ccCount; i++) { + holder.data.put(buffer.get(pos++)); + } + holder.timeUs = presentationTimeUs; + mPendingCcSamples.add(holder); + return pos; + } + } + + private class Mpeg2CcParser extends CcParser { + private static final int PATTERN_LENGTH = 9; + + @Override + public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) { + int totalSize = buffer.position(); + // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with + // overlapping to handle the case that the pattern exists in the boundary. + for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) { + buffer.position(i); + int size = Math.min(totalSize - i, mBuffer.length); + buffer.get(mBuffer, 0, size); + int j = 0; + while (j < size - PATTERN_LENGTH) { + // Find the start prefix code of private user data. + if (mBuffer[j] == 0 + && mBuffer[j + 1] == 0 + && mBuffer[j + 2] == 1 + && (mBuffer[j + 3] & 0xff) == 0xb2) { + // ATSC closed caption data embedded in MPEG2VIDEO stream has 'GA94' user + // identifier and user data type code 3. + if (mBuffer[j + 4] == 'G' + && mBuffer[j + 5] == 'A' + && mBuffer[j + 6] == '9' + && mBuffer[j + 7] == '4' + && mBuffer[j + 8] == 3) { + j = parseClosedCaption(buffer, i + j + PATTERN_LENGTH, + presentationTimeUs) - i; + } else { + j += PATTERN_LENGTH; + } + } else { + ++j; + } + } + } + buffer.position(totalSize); + } + } + + private class H264CcParser extends CcParser { + private static final int PATTERN_LENGTH = 14; + + @Override + public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) { + int totalSize = buffer.position(); + // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with + // overlapping to handle the case that the pattern exists in the boundary. + for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) { + buffer.position(i); + int size = Math.min(totalSize - i, mBuffer.length); + buffer.get(mBuffer, 0, size); + int j = 0; + while (j < size - PATTERN_LENGTH) { + // Find the start prefix code of a NAL Unit. + if (mBuffer[j] == 0 + && mBuffer[j + 1] == 0 + && mBuffer[j + 2] == 1) { + int nalType = mBuffer[j + 3] & 0x1f; + int payloadType = mBuffer[j + 4] & 0xff; + + // ATSC closed caption data embedded in H264 private user data has NAL type + // 6, payload type 4, and 'GA94' user identifier for ATSC. + if (nalType == 6 && payloadType == 4 && mBuffer[j + 9] == 'G' + && mBuffer[j + 10] == 'A' + && mBuffer[j + 11] == '9' + && mBuffer[j + 12] == '4') { + j = parseClosedCaption(buffer, i + j + PATTERN_LENGTH, + presentationTimeUs) - i; + } else { + j += 7; + } + } else { + ++j; + } + } + } + buffer.position(totalSize); + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java new file mode 100644 index 00000000..6007b0be --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java @@ -0,0 +1,196 @@ +/* + * 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.tuner.exoplayer; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.SampleSource.SampleSourceReader; +import com.google.android.exoplayer.util.Assertions; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** {@link SampleSource} that extracts sample data using a {@link SampleExtractor}. */ +public final class MpegTsSampleSource implements SampleSource, SampleSourceReader { + + private static final int TRACK_STATE_DISABLED = 0; + private static final int TRACK_STATE_ENABLED = 1; + private static final int TRACK_STATE_FORMAT_SENT = 2; + + private final SampleExtractor mSampleExtractor; + private final List<Integer> mTrackStates = new ArrayList<>(); + private final List<Boolean> mPendingDiscontinuities = new ArrayList<>(); + + private boolean mPrepared; + private IOException mPreparationError; + private int mRemainingReleaseCount; + + private long mLastSeekPositionUs; + private long mPendingSeekPositionUs; + + /** + * Creates a new sample source that extracts samples using {@code mSampleExtractor}. + * + * @param sampleExtractor a sample extractor for accessing media samples + */ + public MpegTsSampleSource(SampleExtractor sampleExtractor) { + mSampleExtractor = Assertions.checkNotNull(sampleExtractor); + } + + @Override + public SampleSourceReader register() { + mRemainingReleaseCount++; + return this; + } + + @Override + public boolean prepare(long positionUs) { + if (!mPrepared) { + if (mPreparationError != null) { + return false; + } + try { + if (mSampleExtractor.prepare()) { + int trackCount = mSampleExtractor.getTrackFormats().size(); + mTrackStates.clear(); + mPendingDiscontinuities.clear(); + for (int i = 0; i < trackCount; ++i) { + mTrackStates.add(i, TRACK_STATE_DISABLED); + mPendingDiscontinuities.add(i, false); + } + mPrepared = true; + } else { + return false; + } + } catch (IOException e) { + mPreparationError = e; + return false; + } + } + return true; + } + + @Override + public int getTrackCount() { + Assertions.checkState(mPrepared); + return mSampleExtractor.getTrackFormats().size(); + } + + @Override + public MediaFormat getFormat(int track) { + Assertions.checkState(mPrepared); + return mSampleExtractor.getTrackFormats().get(track); + } + + @Override + public void enable(int track, long positionUs) { + Assertions.checkState(mPrepared); + Assertions.checkState(mTrackStates.get(track) == TRACK_STATE_DISABLED); + mTrackStates.set(track, TRACK_STATE_ENABLED); + mSampleExtractor.selectTrack(track); + seekToUsInternal(positionUs, positionUs != 0); + } + + @Override + public void disable(int track) { + Assertions.checkState(mPrepared); + Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED); + mSampleExtractor.deselectTrack(track); + mPendingDiscontinuities.set(track, false); + mTrackStates.set(track, TRACK_STATE_DISABLED); + } + + @Override + public boolean continueBuffering(int track, long positionUs) { + return mSampleExtractor.continueBuffering(positionUs); + } + + @Override + public long readDiscontinuity(int track) { + if (mPendingDiscontinuities.get(track)) { + mPendingDiscontinuities.set(track, false); + return mLastSeekPositionUs; + } + return NO_DISCONTINUITY; + } + + @Override + public int readData(int track, long positionUs, MediaFormatHolder formatHolder, + SampleHolder sampleHolder) { + Assertions.checkState(mPrepared); + Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED); + if (mPendingDiscontinuities.get(track)) { + return NOTHING_READ; + } + if (mTrackStates.get(track) != TRACK_STATE_FORMAT_SENT) { + mSampleExtractor.getTrackMediaFormat(track, formatHolder); + mTrackStates.set(track, TRACK_STATE_FORMAT_SENT); + return FORMAT_READ; + } + + mPendingSeekPositionUs = C.UNKNOWN_TIME_US; + return mSampleExtractor.readSample(track, sampleHolder); + } + + @Override + public void maybeThrowError() throws IOException { + if (mPreparationError != null) { + throw mPreparationError; + } + if (mSampleExtractor != null) { + mSampleExtractor.maybeThrowError(); + } + } + + @Override + public void seekToUs(long positionUs) { + Assertions.checkState(mPrepared); + seekToUsInternal(positionUs, false); + } + + @Override + public long getBufferedPositionUs() { + Assertions.checkState(mPrepared); + return mSampleExtractor.getBufferedPositionUs(); + } + + @Override + public void release() { + Assertions.checkState(mRemainingReleaseCount > 0); + if (--mRemainingReleaseCount == 0) { + mSampleExtractor.release(); + } + } + + private void seekToUsInternal(long positionUs, boolean force) { + // Unless forced, avoid duplicate calls to the underlying extractor's seek method + // in the case that there have been no interleaving calls to readSample. + if (force || mPendingSeekPositionUs != positionUs) { + mLastSeekPositionUs = positionUs; + mPendingSeekPositionUs = positionUs; + mSampleExtractor.seekTo(positionUs); + for (int i = 0; i < mTrackStates.size(); ++i) { + if (mTrackStates.get(i) != TRACK_STATE_DISABLED) { + mPendingDiscontinuities.set(i, true); + } + } + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java new file mode 100644 index 00000000..19360c69 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java @@ -0,0 +1,101 @@ +package com.android.tv.tuner.exoplayer; + +import android.content.Context; +import android.media.MediaCodec; +import android.os.Handler; +import android.util.Log; + +import com.google.android.exoplayer.DecoderInfo; +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaCodecSelector; +import com.google.android.exoplayer.MediaCodecUtil; +import com.google.android.exoplayer.MediaCodecVideoTrackRenderer; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.MediaSoftwareCodecUtil; +import com.google.android.exoplayer.SampleSource; +import com.android.tv.common.feature.CommonFeatures; + +import java.lang.reflect.Field; + +/** + * MPEG-2 TS video track renderer + */ +public class MpegTsVideoTrackRenderer extends MediaCodecVideoTrackRenderer { + private static final String TAG = "MpegTsVideoTrackRender"; + + private static final int VIDEO_PLAYBACK_DEADLINE_IN_MS = 5000; + // If DROPPED_FRAMES_NOTIFICATION_THRESHOLD frames are consecutively dropped, it'll be notified. + private static final int DROPPED_FRAMES_NOTIFICATION_THRESHOLD = 10; + private static final int MIN_HD_HEIGHT = 720; + private static final String MIMETYPE_MPEG2 = "video/mpeg2"; + private static Field sRenderedFirstFrameField; + + private final boolean mIsSwCodecEnabled; + private boolean mCodecIsSwPreferred; + private boolean mSetRenderedFirstFrame; + + static { + // Remove the reflection below once b/31223646 is resolved. + try { + sRenderedFirstFrameField = MediaCodecVideoTrackRenderer.class.getDeclaredField( + "renderedFirstFrame"); + sRenderedFirstFrameField.setAccessible(true); + } catch (NoSuchFieldException e) { + // Null-checking for {@code sRenderedFirstFrameField} will do the error handling. + } + } + + public MpegTsVideoTrackRenderer(Context context, SampleSource source, Handler handler, + MediaCodecVideoTrackRenderer.EventListener listener) { + super(context, source, MediaCodecSelector.DEFAULT, + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_PLAYBACK_DEADLINE_IN_MS, handler, + listener, DROPPED_FRAMES_NOTIFICATION_THRESHOLD); + mIsSwCodecEnabled = CommonFeatures.USE_SW_CODEC_FOR_SD.isEnabled(context); + } + + @Override + protected DecoderInfo getDecoderInfo(MediaCodecSelector codecSelector, String mimeType, + boolean requiresSecureDecoder) throws MediaCodecUtil.DecoderQueryException { + try { + if (mIsSwCodecEnabled && mCodecIsSwPreferred) { + DecoderInfo swCodec = MediaSoftwareCodecUtil.getSoftwareDecoderInfo( + mimeType, requiresSecureDecoder); + if (swCodec != null) { + return swCodec; + } + } + } catch (MediaSoftwareCodecUtil.DecoderQueryException e) { + } + return super.getDecoderInfo(codecSelector, mimeType,requiresSecureDecoder); + } + + @Override + protected void onInputFormatChanged(MediaFormatHolder holder) throws ExoPlaybackException { + mCodecIsSwPreferred = MIMETYPE_MPEG2.equalsIgnoreCase(holder.format.mimeType) + && holder.format.height < MIN_HD_HEIGHT; + super.onInputFormatChanged(holder); + } + + @Override + protected void onDiscontinuity(long positionUs) throws ExoPlaybackException { + super.onDiscontinuity(positionUs); + // Disabling pre-rendering of the first frame in order to avoid a frozen picture when + // starting the playback. We do this only once, when the renderer is enabled at first, since + // we need to pre-render the frame in advance when we do trickplay backed by seeking. + if (!mSetRenderedFirstFrame) { + setRenderedFirstFrame(true); + mSetRenderedFirstFrame = true; + } + } + + private void setRenderedFirstFrame(boolean renderedFirstFrame) { + if (sRenderedFirstFrameField != null) { + try { + sRenderedFirstFrameField.setBoolean(this, renderedFirstFrame); + } catch (IllegalAccessException e) { + Log.w(TAG, "renderedFirstFrame is not accessible. Playback may start with a frozen" + +" picture."); + } + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/SampleExtractor.java b/src/com/android/tv/tuner/exoplayer/SampleExtractor.java new file mode 100644 index 00000000..543588c7 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/SampleExtractor.java @@ -0,0 +1,136 @@ +/* + * 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.tuner.exoplayer; + +import android.os.Handler; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; + +import java.io.IOException; +import java.util.List; + +/** + * Extractor for reading track metadata and samples stored in tracks. + * + * <p>Call {@link #prepare} until it returns {@code true}, then access track metadata via + * {@link #getTrackFormats} and {@link #getTrackMediaFormat}. + * + * <p>Pass indices of tracks to read from to {@link #selectTrack}. A track can later be deselected + * by calling {@link #deselectTrack}. It is safe to select/deselect tracks after reading sample + * data or seeking. Initially, all tracks are deselected. + * + * <p>Call {@link #release()} when the extractor is no longer needed to free resources. + */ +public interface SampleExtractor { + + /** + * If the extractor is currently having difficulty preparing or loading samples, then this + * method throws the underlying error. Otherwise does nothing. + * + * @throws IOException The underlying error. + */ + void maybeThrowError() throws IOException; + + /** + * Prepares the extractor for reading track metadata and samples. + * + * @return whether the source is ready; if {@code false}, this method must be called again. + * @throws IOException thrown if the source can't be read + */ + boolean prepare() throws IOException; + + /** Returns track information about all tracks that can be selected. */ + List<MediaFormat> getTrackFormats(); + + /** Selects the track at {@code index} for reading sample data. */ + void selectTrack(int index); + + /** Deselects the track at {@code index}, so no more samples will be read from that track. */ + void deselectTrack(int index); + + /** + * Returns an estimate of the position up to which data is buffered. + * + * <p>This method should not be called until after the extractor has been successfully prepared. + * + * @return an estimate of the absolute position in microseconds up to which data is buffered, + * or {@link TrackRenderer#END_OF_TRACK_US} if data is buffered to the end of the stream, or + * {@link TrackRenderer#UNKNOWN_TIME_US} if no estimate is available. + */ + long getBufferedPositionUs(); + + /** + * Seeks to the specified time in microseconds. + * + * <p>This method should not be called until after the extractor has been successfully prepared. + * + * @param positionUs the seek position in microseconds + */ + void seekTo(long positionUs); + + /** Stores the {@link MediaFormat} of {@code track}. */ + void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder); + + /** + * Reads the next sample in the track at index {@code track} into {@code sampleHolder}, returning + * {@link SampleSource#SAMPLE_READ} if it is available. + * + * <p>Advances to the next sample if a sample was read. + * + * @param track the index of the track from which to read a sample + * @param sampleHolder the holder for read sample data, if {@link SampleSource#SAMPLE_READ} is + * returned + * @return {@link SampleSource#SAMPLE_READ} if a sample was read into {@code sampleHolder}, or + * {@link SampleSource#END_OF_STREAM} if the last samples in all tracks have been read, or + * {@link SampleSource#NOTHING_READ} if the sample cannot be read immediately as it is not + * loaded. + */ + int readSample(int track, SampleHolder sampleHolder); + + /** Releases resources associated with this extractor. */ + void release(); + + /** Indicates to the source that it should still be buffering data. */ + boolean continueBuffering(long positionUs); + + /** + * Sets OnCompletionListener for notifying the completion of SampleExtractor. + * + * @param listener the OnCompletionListener + * @param handler the {@link Handler} for {@link Handler#post(Runnable)} of OnCompletionListener + */ + void setOnCompletionListener(OnCompletionListener listener, Handler handler); + + /** + * The listener for SampleExtractor being completed. + */ + interface OnCompletionListener { + + /** + * Called when sample extraction is completed. + * + * @param result {@code true} when the extractor is finished without an error, + * {@code false} otherwise (storage error, weak signal, being reached at EoS + * prematurely, etc.) + * @param lastExtractedPositionUs the last extracted position when extractor is completed + */ + void onCompletion(boolean result, long lastExtractedPositionUs); + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java new file mode 100644 index 00000000..9dae2e34 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java @@ -0,0 +1,540 @@ +/* + * 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.tuner.exoplayer.ac3; + +import android.os.Handler; +import android.os.SystemClock; +import android.util.Log; + +import com.google.android.exoplayer.CodecCounters; +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaClock; +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.MediaFormatHolder; +import com.google.android.exoplayer.MediaFormatUtil; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.TrackRenderer; +import com.google.android.exoplayer.audio.AudioTrack; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.MimeTypes; +import com.android.tv.tuner.tvinput.TunerDebug; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; + +/** + * Decodes and renders AC3 audio. + */ +public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaClock { + public static final int MSG_SET_VOLUME = 10000; + public static final int MSG_SET_AUDIO_TRACK = MSG_SET_VOLUME + 1; + public static final int MSG_SET_PLAYBACK_SPEED = MSG_SET_VOLUME + 2; + + // ATSC/53 allows sample rate to be only 48Khz. + // One AC3 sample has 1536 frames, and its duration is 32ms. + public static final long AC3_SAMPLE_DURATION_US = 32000; + + private static final String TAG = "Ac3PassthroughTrackRenderer"; + private static final boolean DEBUG = false; + + /** + * Interface definition for a callback to be notified of + * {@link com.google.android.exoplayer.audio.AudioTrack} error. + */ + public interface EventListener { + void onAudioTrackInitializationError(AudioTrack.InitializationException e); + void onAudioTrackWriteError(AudioTrack.WriteException e); + } + + private static final int DEFAULT_INPUT_BUFFER_SIZE = 16384 * 2; + private static final int DEFAULT_OUTPUT_BUFFER_SIZE = 1024*1024; + private static final int MONITOR_DURATION_MS = 1000; + private static final int AC3_HEADER_BITRATE_OFFSET = 4; + + // Keep this as static in order to prevent new framework AudioTrack creation + // while old AudioTrack is being released. + private static final AudioTrackWrapper AUDIO_TRACK = new AudioTrackWrapper(); + private static final long KEEP_ALIVE_AFTER_EOS_DURATION_MS = 3000; + + // Ignore AudioTrack backward movement if duration of movement is below the threshold. + private static final long BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US = 3000; + + // AudioTrack position cannot go ahead beyond this limit. + private static final long CURRENT_POSITION_FROM_PTS_LIMIT_US = 1000000; + + // Since MediaCodec processing and AudioTrack playing add delay, + // PTS interpolated time should be delayed reasonably when AudioTrack is not used. + private static final long ESTIMATED_TRACK_RENDERING_DELAY_US = 500000; + + private final CodecCounters mCodecCounters; + private final SampleSource.SampleSourceReader mSource; + private final SampleHolder mSampleHolder; + private final MediaFormatHolder mFormatHolder; + private final EventListener mEventListener; + private final Handler mEventHandler; + private final AudioTrackMonitor mMonitor; + private final AudioClock mAudioClock; + + private MediaFormat mFormat; + private final ByteBuffer mOutputBuffer; + private boolean mOutputReady; + private int mTrackIndex; + private boolean mSourceStateReady; + private boolean mInputStreamEnded; + private boolean mOutputStreamEnded; + private long mEndOfStreamMs; + private long mCurrentPositionUs; + private int mPresentationCount; + private long mPresentationTimeUs; + private long mInterpolatedTimeUs; + private long mPreviousPositionUs; + private boolean mIsStopped; + private ArrayList<Integer> mTracksIndex; + + public Ac3PassthroughTrackRenderer(SampleSource source, Handler eventHandler, + EventListener listener) { + mSource = source.register(); + mEventHandler = eventHandler; + mEventListener = listener; + mTrackIndex = -1; + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT); + mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE); + mOutputBuffer = ByteBuffer.allocate(DEFAULT_OUTPUT_BUFFER_SIZE); + mFormatHolder = new MediaFormatHolder(); + AUDIO_TRACK.restart(); + mCodecCounters = new CodecCounters(); + mMonitor = new AudioTrackMonitor(); + mAudioClock = new AudioClock(); + mTracksIndex = new ArrayList<>(); + } + + @Override + protected MediaClock getMediaClock() { + return this; + } + + private static boolean handlesMimeType(String mimeType) { + return mimeType.equals(MimeTypes.AUDIO_AC3) || mimeType.equals(MimeTypes.AUDIO_E_AC3); + } + + @Override + protected boolean doPrepare(long positionUs) throws ExoPlaybackException { + boolean sourcePrepared = mSource.prepare(positionUs); + if (!sourcePrepared) { + return false; + } + for (int i = 0; i < mSource.getTrackCount(); i++) { + if (handlesMimeType(mSource.getFormat(i).mimeType)) { + if (mTrackIndex < 0) { + mTrackIndex = i; + } + mTracksIndex.add(i); + } + } + + // TODO: Check this case. Source does not have the proper mime type. + return true; + } + + @Override + protected int getTrackCount() { + return mTracksIndex.size(); + } + + @Override + protected MediaFormat getFormat(int track) { + Assertions.checkArgument(track >= 0 && track < mTracksIndex.size()); + return mSource.getFormat(mTracksIndex.get(track)); + } + + @Override + protected void onEnabled(int track, long positionUs, boolean joining) { + Assertions.checkArgument(track >= 0 && track < mTracksIndex.size()); + mTrackIndex = mTracksIndex.get(track); + mSource.enable(mTrackIndex, positionUs); + seekToInternal(positionUs); + } + + @Override + protected void onDisabled() { + AUDIO_TRACK.resetSessionId(); + clearDecodeState(); + mFormat = null; + mSource.disable(mTrackIndex); + } + + @Override + protected void onReleased() { + AUDIO_TRACK.release(); + mSource.release(); + } + + @Override + protected boolean isEnded() { + return mOutputStreamEnded && AUDIO_TRACK.isEnded(); + } + + @Override + protected boolean isReady() { + return AUDIO_TRACK.isReady() || (mFormat != null && (mSourceStateReady || mOutputReady)); + } + + private void seekToInternal(long positionUs) { + mMonitor.reset(MONITOR_DURATION_MS); + mSourceStateReady = false; + mInputStreamEnded = false; + mOutputStreamEnded = false; + mPresentationTimeUs = positionUs; + mPresentationCount = 0; + mPreviousPositionUs = 0; + mCurrentPositionUs = Long.MIN_VALUE; + mInterpolatedTimeUs = Long.MIN_VALUE; + mAudioClock.setPositionUs(positionUs); + } + + @Override + protected void seekTo(long positionUs) { + mSource.seekToUs(positionUs); + AUDIO_TRACK.reset(); + // resetSessionId() will create a new framework AudioTrack instead of reusing old one. + AUDIO_TRACK.resetSessionId(); + seekToInternal(positionUs); + } + + @Override + protected void onStarted() { + AUDIO_TRACK.play(); + mAudioClock.start(); + mIsStopped = false; + } + + @Override + protected void onStopped() { + AUDIO_TRACK.pause(); + mAudioClock.stop(); + mIsStopped = true; + } + + @Override + protected void maybeThrowError() throws ExoPlaybackException { + try { + mSource.maybeThrowError(); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + @Override + protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + mMonitor.maybeLog(); + try { + if (mEndOfStreamMs != 0) { + // Ensure playback stops, after EoS was notified. + // Sometimes MediaCodecTrackRenderer does not fetch EoS timely + // after EoS was notified here long before. + long diff = SystemClock.elapsedRealtime() - mEndOfStreamMs; + if (diff >= KEEP_ALIVE_AFTER_EOS_DURATION_MS && !mIsStopped) { + throw new ExoPlaybackException("Much time has elapsed after EoS"); + } + } + boolean continueBuffering = mSource.continueBuffering(mTrackIndex, positionUs); + if (mSourceStateReady != continueBuffering) { + mSourceStateReady = continueBuffering; + if (DEBUG) { + Log.d(TAG, "mSourceStateReady: " + String.valueOf(mSourceStateReady)); + } + } + long discontinuity = mSource.readDiscontinuity(mTrackIndex); + if (discontinuity != SampleSource.NO_DISCONTINUITY) { + AUDIO_TRACK.handleDiscontinuity(); + mPresentationTimeUs = discontinuity; + mPresentationCount = 0; + clearDecodeState(); + return; + } + if (mFormat == null) { + readFormat(); + return; + } + + // Process only one sample at a time for doSomeWork() + if (processOutput()) { + if (!mOutputReady) { + while (feedInputBuffer()) { + if (mOutputReady) break; + } + } + } + mCodecCounters.ensureUpdated(); + } catch (IOException e) { + throw new ExoPlaybackException(e); + } + } + + private void ensureAudioTrackInitialized() { + if (!AUDIO_TRACK.isInitialized()) { + try { + if (DEBUG) { + Log.d(TAG, "AudioTrack initialized"); + } + AUDIO_TRACK.initialize(); + } catch (AudioTrack.InitializationException e) { + Log.e(TAG, "Error on AudioTrack initialization", e); + notifyAudioTrackInitializationError(e); + + // Do not throw exception here but just disabling audioTrack to keep playing + // video without audio. + AUDIO_TRACK.setStatus(false); + } + if (getState() == TrackRenderer.STATE_STARTED) { + if (DEBUG) { + Log.d(TAG, "AudioTrack played"); + } + AUDIO_TRACK.play(); + } + } + } + + private void clearDecodeState() { + mOutputReady = false; + AUDIO_TRACK.reset(); + } + + private void readFormat() throws IOException, ExoPlaybackException { + int result = mSource.readData(mTrackIndex, mCurrentPositionUs, + mFormatHolder, mSampleHolder); + if (result == SampleSource.FORMAT_READ) { + onInputFormatChanged(mFormatHolder); + } + } + + private void onInputFormatChanged(MediaFormatHolder formatHolder) + throws ExoPlaybackException { + mFormat = formatHolder.format; + if (DEBUG) { + Log.d(TAG, "AudioTrack was configured to FORMAT: " + mFormat.toString()); + } + clearDecodeState(); + AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16()); + } + + private boolean feedInputBuffer() throws IOException, ExoPlaybackException { + if (mInputStreamEnded) { + return false; + } + + mSampleHolder.data.clear(); + mSampleHolder.size = 0; + int result = mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, + mSampleHolder); + switch (result) { + case SampleSource.NOTHING_READ: { + return false; + } + case SampleSource.FORMAT_READ: { + Log.i(TAG, "Format was read again"); + onInputFormatChanged(mFormatHolder); + return true; + } + case SampleSource.END_OF_STREAM: { + Log.i(TAG, "End of stream from SampleSource"); + mInputStreamEnded = true; + return false; + } + default: { + mSampleHolder.data.flip(); + decodeDone(mSampleHolder.data, mSampleHolder.timeUs); + return true; + } + } + } + + private boolean processOutput() throws ExoPlaybackException { + if (mOutputStreamEnded) { + return false; + } + if (!mOutputReady) { + if (mInputStreamEnded) { + mOutputStreamEnded = true; + mEndOfStreamMs = SystemClock.elapsedRealtime(); + return false; + } + return true; + } + + ensureAudioTrackInitialized(); + int handleBufferResult; + try { + // To reduce discontinuity, interpolate presentation time. + mInterpolatedTimeUs = mPresentationTimeUs + + mPresentationCount * AC3_SAMPLE_DURATION_US; + handleBufferResult = AUDIO_TRACK.handleBuffer(mOutputBuffer, + 0, mOutputBuffer.limit(), mInterpolatedTimeUs); + } catch (AudioTrack.WriteException e) { + notifyAudioTrackWriteError(e); + throw new ExoPlaybackException(e); + } + + if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) { + Log.i(TAG, "Play discontinuity happened"); + mCurrentPositionUs = Long.MIN_VALUE; + } + if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) { + mCodecCounters.renderedOutputBufferCount++; + mOutputReady = false; + return true; + } + return false; + } + + @Override + protected long getDurationUs() { + return mSource.getFormat(mTrackIndex).durationUs; + } + + @Override + protected long getBufferedPositionUs() { + long pos = mSource.getBufferedPositionUs(); + return pos == UNKNOWN_TIME_US || pos == END_OF_TRACK_US + ? pos : Math.max(pos, getPositionUs()); + } + + @Override + public long getPositionUs() { + if (!AUDIO_TRACK.isInitialized()) { + return mAudioClock.getPositionUs(); + } else if (!AUDIO_TRACK.isEnabled()) { + if (mInterpolatedTimeUs > 0) { + return mInterpolatedTimeUs - ESTIMATED_TRACK_RENDERING_DELAY_US; + } + return mPresentationTimeUs; + } + long audioTrackCurrentPositionUs = AUDIO_TRACK.getCurrentPositionUs(isEnded()); + if (audioTrackCurrentPositionUs == AudioTrack.CURRENT_POSITION_NOT_SET) { + mPreviousPositionUs = 0L; + if (DEBUG) { + long oldPositionUs = Math.max(mCurrentPositionUs, 0); + long currentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs); + Log.d(TAG, "Audio position is not set, diff in us: " + + String.valueOf(currentPositionUs - oldPositionUs)); + } + mCurrentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs); + } else { + if (mPreviousPositionUs + > audioTrackCurrentPositionUs + BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US) { + Log.e(TAG, "audio_position BACK JUMP: " + + (mPreviousPositionUs - audioTrackCurrentPositionUs)); + mCurrentPositionUs = audioTrackCurrentPositionUs; + } else { + mCurrentPositionUs = Math.max(mCurrentPositionUs, audioTrackCurrentPositionUs); + } + mPreviousPositionUs = audioTrackCurrentPositionUs; + } + long upperBound = mPresentationTimeUs + CURRENT_POSITION_FROM_PTS_LIMIT_US; + if (mCurrentPositionUs > upperBound) { + mCurrentPositionUs = upperBound; + } + return mCurrentPositionUs; + } + + private void decodeDone(ByteBuffer outputBuffer, long presentationTimeUs) { + if (outputBuffer == null || mOutputBuffer == null) { + return; + } + if (presentationTimeUs < 0) { + Log.e(TAG, "decodeDone - invalid presentationTimeUs"); + return; + } + + if (TunerDebug.ENABLED) { + TunerDebug.setAudioPtsUs(presentationTimeUs); + } + + mOutputBuffer.clear(); + Assertions.checkState(mOutputBuffer.remaining() >= outputBuffer.limit()); + + mOutputBuffer.put(outputBuffer); + mMonitor.addPts(presentationTimeUs, mOutputBuffer.position(), + mOutputBuffer.get(AC3_HEADER_BITRATE_OFFSET)); + if (presentationTimeUs == mPresentationTimeUs) { + mPresentationCount++; + } else { + mPresentationCount = 0; + mPresentationTimeUs = presentationTimeUs; + } + mOutputBuffer.flip(); + mOutputReady = true; + } + + private void notifyAudioTrackInitializationError(final AudioTrack.InitializationException e) { + if (mEventHandler == null || mEventListener == null) { + return; + } + mEventHandler.post(new Runnable() { + @Override + public void run() { + mEventListener.onAudioTrackInitializationError(e); + } + }); + } + + private void notifyAudioTrackWriteError(final AudioTrack.WriteException e) { + if (mEventHandler == null || mEventListener == null) { + return; + } + mEventHandler.post(new Runnable() { + @Override + public void run() { + mEventListener.onAudioTrackWriteError(e); + } + }); + } + + @Override + public void handleMessage(int messageType, Object message) throws ExoPlaybackException { + switch (messageType) { + case MSG_SET_VOLUME: + AUDIO_TRACK.setVolume((Float) message); + break; + case MSG_SET_AUDIO_TRACK: + boolean enabled = (Integer) message == 1; + if (enabled == AUDIO_TRACK.isEnabled()) { + return; + } + if (!enabled) { + // mAudioClock can be different from getPositionUs. In order to sync them, + // we set mAudioClock. + mAudioClock.setPositionUs(getPositionUs()); + } + AUDIO_TRACK.setStatus(enabled); + if (enabled) { + // When AUDIO_TRACK is enabled, we need to clear AUDIO_TRACK and seek to + // the current position. If not, AUDIO_TRACK has the obsolete data. + seekTo(mAudioClock.getPositionUs()); + } + break; + case MSG_SET_PLAYBACK_SPEED: + mAudioClock.setPlaybackSpeed((Float) message); + break; + default: + super.handleMessage(messageType, message); + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java b/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java new file mode 100644 index 00000000..2bf86b5a --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.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.tuner.exoplayer.ac3; + +import android.os.Handler; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaCodecAudioTrackRenderer; +import com.google.android.exoplayer.MediaCodecSelector; +import com.google.android.exoplayer.SampleSource; + +/** + * MPEG-2 TS audio track renderer. + * <p>Since the audio output from {@link android.media.MediaExtractor} contains extra samples at + * the beginning, using original {@link MediaCodecAudioTrackRenderer} as audio renderer causes + * asynchronous Audio/Video outputs. + * This class calculates the offset of audio data and adjust the presentation times to avoid the + * asynchronous Audio/Video problem. + */ +public class Ac3TrackRenderer extends MediaCodecAudioTrackRenderer { + private final String TAG = "Ac3TrackRenderer"; + private final boolean DEBUG = false; + + private final Ac3EventListener mListener; + + public interface Ac3EventListener extends EventListener { + /** + * Invoked when a {@link android.media.PlaybackParams} set to an + * {@link android.media.AudioTrack} is not valid. + * + * @param e The corresponding exception. + */ + void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e); + } + + public Ac3TrackRenderer(SampleSource source, MediaCodecSelector mediaCodecSelector, + Handler eventHandler, EventListener eventListener) { + super(source, mediaCodecSelector, eventHandler, eventListener); + mListener = (Ac3EventListener) eventListener; + } + + @Override + public void handleMessage(int messageType, Object message) throws ExoPlaybackException { + if (messageType == MSG_SET_PLAYBACK_PARAMS) { + try { + super.handleMessage(messageType, message); + } catch (IllegalArgumentException e) { + if (isAudioTrackSetPlaybackParamsError(e)) { + notifyAudioTrackSetPlaybackParamsError(e); + } + } + return; + } + super.handleMessage(messageType, message); + } + + private void notifyAudioTrackSetPlaybackParamsError(final IllegalArgumentException e) { + if (eventHandler != null && mListener != null) { + eventHandler.post(new Runnable() { + @Override + public void run() { + mListener.onAudioTrackSetPlaybackParamsError(e); + } + }); + } + } + + static private boolean isAudioTrackSetPlaybackParamsError(IllegalArgumentException e) { + if (e.getStackTrace() == null || e.getStackTrace().length < 1) { + return false; + } + for (StackTraceElement element : e.getStackTrace()) { + String elementString = element.toString(); + if (elementString.startsWith("android.media.AudioTrack.setPlaybackParams")) { + return true; + } + } + return false; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java new file mode 100644 index 00000000..600c2c88 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java @@ -0,0 +1,107 @@ +/* + * 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.tuner.exoplayer.ac3; + +import com.android.tv.common.SoftPreconditions; + +import android.os.SystemClock; + +/** + * Copy of {@link com.google.android.exoplayer.MediaClock}. + * <p> + * A simple clock for tracking the progression of media time. The clock can be started, stopped and + * its time can be set and retrieved. When started, this clock is based on + * {@link SystemClock#elapsedRealtime()}. + */ +/* package */ class AudioClock { + private boolean mStarted; + + /** + * The media time when the clock was last set or stopped. + */ + private long mPositionUs; + + /** + * The difference between {@link SystemClock#elapsedRealtime()} and {@link #mPositionUs} + * when the clock was last set or mStarted. + */ + private long mDeltaUs; + + private float mPlaybackSpeed = 1.0f; + private long mDeltaUpdatedTimeUs; + + /** + * Starts the clock. Does nothing if the clock is already started. + */ + public void start() { + if (!mStarted) { + mStarted = true; + mDeltaUs = elapsedRealtimeMinus(mPositionUs); + mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000; + } + } + + /** + * Stops the clock. Does nothing if the clock is already stopped. + */ + public void stop() { + if (mStarted) { + mPositionUs = elapsedRealtimeMinus(mDeltaUs); + mStarted = false; + } + } + + /** + * @param timeUs The position to set in microseconds. + */ + public void setPositionUs(long timeUs) { + this.mPositionUs = timeUs; + mDeltaUs = elapsedRealtimeMinus(timeUs); + mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000; + } + + /** + * @return The current position in microseconds. + */ + public long getPositionUs() { + if (!mStarted) { + return mPositionUs; + } + if (mPlaybackSpeed != 1.0f) { + long elapsedTimeFromPlaybackSpeedChanged = SystemClock.elapsedRealtime() * 1000 + - mDeltaUpdatedTimeUs; + return elapsedRealtimeMinus(mDeltaUs) + + (long) ((mPlaybackSpeed - 1.0f) * elapsedTimeFromPlaybackSpeedChanged); + } else { + return elapsedRealtimeMinus(mDeltaUs); + } + } + + /** + * Sets playback speed. {@code speed} should be positive. + */ + public void setPlaybackSpeed(float speed) { + SoftPreconditions.checkState(speed > 0); + mDeltaUs = elapsedRealtimeMinus(getPositionUs()); + mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000; + mPlaybackSpeed = speed; + } + + private long elapsedRealtimeMinus(long toSubtractUs) { + return SystemClock.elapsedRealtime() * 1000 - toSubtractUs; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java new file mode 100644 index 00000000..bfdf08ac --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java @@ -0,0 +1,121 @@ +/* + * 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.tuner.exoplayer.ac3; + +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * Monitors the rendering position of {@link AudioTrack}. + */ +public class AudioTrackMonitor { + private static final String TAG = "AudioTrackMonitor"; + private static final boolean DEBUG = false; + + // For fetched audio samples + private final ArrayList<Pair<Long, Integer>> mPtsList = new ArrayList<>(); + private final Set<Integer> mSampleSize = new HashSet<>(); + private final Set<Integer> mCurSampleSize = new HashSet<>(); + private final Set<Integer> mAc3Header = new HashSet<>(); + + private long mExpireMs; + private long mDuration; + private long mSampleCount; + private long mTotalCount; + private long mStartMs; + + private void flush() { + mExpireMs += mDuration; + mSampleCount = 0; + mCurSampleSize.clear(); + mPtsList.clear(); + } + + /** + * Resets and initializes {@link AudioTrackMonitor}. + * + * @param duration the frequency of monitoring in milliseconds + */ + public void reset(long duration) { + mExpireMs = SystemClock.elapsedRealtime(); + mDuration = duration; + mTotalCount = 0; + mStartMs = 0; + mSampleSize.clear(); + mAc3Header.clear(); + flush(); + } + + /** + * Adds an audio sample information for monitoring. + * + * @param pts the presentation timestamp of the sample + * @param sampleSize the size in bytes of the sample + * @param header the bitrate & sampling information header of the sample + */ + public void addPts(long pts, int sampleSize, int header) { + mTotalCount++; + mSampleCount++; + mSampleSize.add(sampleSize); + mAc3Header.add(header); + mCurSampleSize.add(sampleSize); + if (mTotalCount == 1) { + mStartMs = SystemClock.elapsedRealtime(); + } + if (mPtsList.isEmpty() || mPtsList.get(mPtsList.size() - 1).first != pts) { + mPtsList.add(Pair.create(pts, 1)); + return; + } + Pair<Long, Integer> pair = mPtsList.get(mPtsList.size() - 1); + mPtsList.set(mPtsList.size() - 1, Pair.create(pair.first, pair.second + 1)); + } + + /** + * Logs if interested events are present. + * <p> + * Periodic logging is not enabled in release mode in order to avoid verbose logging. + */ + public void maybeLog() { + long now = SystemClock.elapsedRealtime(); + if (mExpireMs != 0 && now >= mExpireMs) { + if (DEBUG) { + long sampleDuration = (mTotalCount - 1) * + Ac3PassthroughTrackRenderer.AC3_SAMPLE_DURATION_US / 1000; + long totalDuration = now - mStartMs; + StringBuilder ptsBuilder = new StringBuilder(); + ptsBuilder.append("PTS received ").append(mSampleCount).append(", ") + .append(totalDuration - sampleDuration).append(' '); + + for (Pair<Long, Integer> pair : mPtsList) { + ptsBuilder.append('[').append(pair.first).append(':').append(pair.second) + .append("], "); + } + Log.d(TAG, ptsBuilder.toString()); + } + if (DEBUG || mCurSampleSize.size() > 1) { + Log.d(TAG, "PTS received sample size: " + + String.valueOf(mSampleSize) + mCurSampleSize + mAc3Header); + } + flush(); + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java new file mode 100644 index 00000000..bc3c5d00 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java @@ -0,0 +1,164 @@ +/* + * 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.tuner.exoplayer.ac3; + +import android.media.MediaFormat; + +import com.google.android.exoplayer.audio.AudioTrack; + +import java.nio.ByteBuffer; + +/** + * {@link AudioTrack} wrapper class for trickplay operations including FF/RW. + * FF/RW trickplay operations do not need framework {@link AudioTrack}. + * This wrapper class will do nothing in disabled status for those operations. + */ +public class AudioTrackWrapper { + private final AudioTrack mAudioTrack = new AudioTrack(); + private int mAudioSessionID; + private boolean mIsEnabled; + + AudioTrackWrapper() { + mIsEnabled = true; + } + + public void resetSessionId() { + mAudioSessionID = AudioTrack.SESSION_ID_NOT_SET; + } + + public boolean isInitialized() { + return mIsEnabled && mAudioTrack.isInitialized(); + } + + public void restart() { + if (mAudioTrack.isInitialized()) { + mAudioTrack.release(); + } + mIsEnabled = true; + resetSessionId(); + } + + public void release() { + if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) { + mAudioTrack.release(); + } + } + + public void initialize() throws AudioTrack.InitializationException { + if (!mIsEnabled) { + return; + } + if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) { + mAudioTrack.initialize(mAudioSessionID); + } else { + mAudioSessionID = mAudioTrack.initialize(); + } + } + + public void reset() { + if (!mIsEnabled) { + return; + } + mAudioTrack.reset(); + } + + public boolean isEnded() { + return !mIsEnabled || !mAudioTrack.hasPendingData(); + } + + public boolean isReady() { + // In the case of not playing actual audio data, Audio track is always ready. + return !mIsEnabled || mAudioTrack.hasPendingData(); + } + + public void play() { + if (!mIsEnabled) { + return; + } + mAudioTrack.play(); + } + + public void pause() { + if (!mIsEnabled) { + return; + } + mAudioTrack.pause(); + } + + public void setVolume(float volume) { + if (!mIsEnabled) { + return; + } + mAudioTrack.setVolume(volume); + } + + public void reconfigure(MediaFormat format) { + if (!mIsEnabled || format == null) { + return; + } + String mimeType = format.getString(MediaFormat.KEY_MIME); + int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); + int pcmEncoding; + try { + pcmEncoding = format.getInteger(MediaFormat.KEY_PCM_ENCODING); + } catch (Exception e) { + pcmEncoding = com.google.android.exoplayer.MediaFormat.NO_VALUE; + } + // TODO: Handle non-AC3 or non-passthrough audio. + if (MediaFormat.MIMETYPE_AUDIO_AC3.equalsIgnoreCase(mimeType) && channelCount != 2) { + // Workarounds b/25955476. + // Since all devices and platforms does not support passthrough for non-stereo AC3, + // It is safe to fake non-stereo AC3 as AC3 stereo which is default passthrough mode. + // In other words, the channel count should be always 2. + channelCount = 2; + } + mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding); + } + + public void handleDiscontinuity() { + if (!mIsEnabled) { + return; + } + mAudioTrack.handleDiscontinuity(); + } + + public int handleBuffer(ByteBuffer buffer, int offset, int size, long presentationTimeUs) + throws AudioTrack.WriteException { + if (!mIsEnabled) { + return AudioTrack.RESULT_BUFFER_CONSUMED; + } + return mAudioTrack.handleBuffer(buffer, offset, size, presentationTimeUs); + } + + public void setStatus(boolean enable) { + if (enable == mIsEnabled) { + return; + } + mAudioTrack.reset(); + mIsEnabled = enable; + } + + public boolean isEnabled() { + return mIsEnabled; + } + + // This should be used only in case of being enabled. + public long getCurrentPositionUs(boolean isEnded) { + return mAudioTrack.getCurrentPositionUs(isEnded); + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java new file mode 100644 index 00000000..eb596e93 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java @@ -0,0 +1,644 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.media.MediaFormat; +import android.os.ConditionVariable; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.util.ArrayMap; +import android.util.Log; +import android.util.Pair; + +import com.google.android.exoplayer.SampleHolder; +import com.android.tv.tuner.exoplayer.SampleExtractor; +import com.android.tv.util.Utils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * Manages {@link SampleChunk} objects. + * <p> + * The buffer manager can be disabled, while running, if the write throughput to the associated + * external storage is detected to be lower than a threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}". + * This leads to restarting playback flow. + */ +public class BufferManager { + private static final String TAG = "BufferManager"; + private static final boolean DEBUG = false; + + // Constants for the disk write speed checking + private static final long MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK = + 10L * 1024 * 1024; // Checks for every 10M disk write + private static final int MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK = 15 * 1024; + private static final int MAXIMUM_SPEED_CHECK_COUNT = 5; // Checks only 5 times + private static final int MINIMUM_DISK_WRITE_SPEED_MBPS = 3; // 3 Megabytes per second + + private final SampleChunk.SampleChunkCreator mSampleChunkCreator; + // Maps from track name to a map which maps from starting position to {@link SampleChunk}. + private final Map<String, SortedMap<Long, SampleChunk>> mChunkMap = new ArrayMap<>(); + private final Map<String, Long> mStartPositionMap = new ArrayMap<>(); + private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>(); + private final StorageManager mStorageManager; + private long mBufferSize = 0; + private final EvictChunkQueueMap mPendingDelete = new EvictChunkQueueMap(); + private final SampleChunk.ChunkCallback mChunkCallback = new SampleChunk.ChunkCallback() { + @Override + public void onChunkWrite(SampleChunk chunk) { + mBufferSize += chunk.getSize(); + } + + @Override + public void onChunkDelete(SampleChunk chunk) { + mBufferSize -= chunk.getSize(); + } + }; + + private volatile boolean mClosed = false; + private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK; + private long mTotalWriteSize; + private long mTotalWriteTimeNs; + private float mWriteBandwidth = 0.0f; + private volatile int mSpeedCheckCount; + private boolean mDisabled = false; + + public interface ChunkEvictedListener { + void onChunkEvicted(String id, long createdTimeMs); + } + /** + * Handles I/O + * between BufferManager and {@link SampleExtractor}. + */ + public interface SampleBuffer { + + /** + * Initializes SampleBuffer. + * @param Ids track identifiers for storage read/write. + * @param mediaFormats meta-data for each track. + * @throws IOException + */ + void init(@NonNull List<String> Ids, + @NonNull List<com.google.android.exoplayer.MediaFormat> mediaFormats) + throws IOException; + + /** + * Selects the track {@code index} for reading sample data. + */ + void selectTrack(int index); + + /** + * Deselects the track at {@code index}, + * so that no more samples will be read from the track. + */ + void deselectTrack(int index); + + /** + * Writes sample to storage. + * + * @param index track index + * @param sample sample to write at storage + * @param conditionVariable notifies the completion of writing sample. + * @throws IOException + */ + void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable) + throws IOException; + + /** + * Checks whether storage write speed is slow. + */ + boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs); + + /** + * Handles when write speed is slow. + * @throws IOException + */ + void handleWriteSpeedSlow() throws IOException; + + /** + * Sets the flag when EoS was reached. + */ + void setEos(); + + /** + * Reads the next sample in the track at index {@code track} into {@code sampleHolder}, + * returning {@link com.google.android.exoplayer.SampleSource#SAMPLE_READ} + * if it is available. + * If the next sample is not available, + * returns {@link com.google.android.exoplayer.SampleSource#NOTHING_READ}. + */ + int readSample(int index, SampleHolder outSample); + + /** + * Seeks to the specified time in microseconds. + */ + void seekTo(long positionUs); + + /** + * Returns an estimate of the position up to which data is buffered. + */ + long getBufferedPositionUs(); + + /** + * Returns whether there is buffered data. + */ + boolean continueBuffering(long positionUs); + + /** + * Cleans up and releases everything. + * @throws IOException + */ + void release() throws IOException; + } + + /** + * Storage configuration and policy manager for {@link BufferManager} + */ + public interface StorageManager { + + /** + * Provides eligible storage directory for {@link BufferManager}. + * + * @return a directory to save buffer(chunks) and meta files + */ + File getBufferDir(); + + /** + * Cleans up storage. + */ + void clearStorage(); + + /** + * Informs whether the storage is used for persistent use. (eg. dvr recording/play) + * + * @return {@code true} if stored files are persistent + */ + boolean isPersistent(); + + /** + * Informs whether the storage usage exceeds pre-determined size. + * + * @param bufferSize the current total usage of Storage in bytes. + * @param pendingDelete the current storage usage which will be deleted in near future by + * bytes + * @return {@code true} if it reached pre-determined max size + */ + boolean reachedStorageMax(long bufferSize, long pendingDelete); + + /** + * Informs whether the storage has enough remained space. + * + * @param pendingDelete the current storage usage which will be deleted in near future by + * bytes + * @return {@code true} if it has enough space + */ + boolean hasEnoughBuffer(long pendingDelete); + + /** + * Reads track name & {@link MediaFormat} from storage. + * + * @param isAudio {@code true} if it is for audio track + * @return {@link Pair} of track name & {@link MediaFormat} + * @throws IOException + */ + Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException; + + /** + * Reads sample indexes for each written sample from storage. + * + * @param trackId track name + * @return indexes of the specified track + * @throws IOException + */ + ArrayList<Long> readIndexFile(String trackId) throws IOException; + + /** + * Writes track information to storage. + * + * @param trackId track name + * @param format {@link android.media.MediaFormat} of the track + * @param isAudio {@code true} if it is for audio track + * @throws IOException + */ + void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) + throws IOException; + + /** + * Writes index file to storage. + * + * @param trackName track name + * @param index {@link SampleChunk} container + * @throws IOException + */ + void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) + throws IOException; + } + + private static class EvictChunkQueueMap { + private final Map<String, LinkedList<SampleChunk>> mEvictMap = new ArrayMap<>(); + private long mSize; + + private void init(String key) { + mEvictMap.put(key, new LinkedList<>()); + } + + private void add(String key, SampleChunk chunk) { + LinkedList<SampleChunk> queue = mEvictMap.get(key); + if (queue != null) { + mSize += chunk.getSize(); + queue.add(chunk); + } + } + + private SampleChunk poll(String key, long startPositionUs) { + LinkedList<SampleChunk> queue = mEvictMap.get(key); + if (queue != null) { + SampleChunk chunk = queue.peek(); + if (chunk != null && chunk.getStartPositionUs() < startPositionUs) { + mSize -= chunk.getSize(); + return queue.poll(); + } + } + return null; + } + + private long getSize() { + return mSize; + } + + private void release() { + for (Map.Entry<String, LinkedList<SampleChunk>> entry : mEvictMap.entrySet()) { + for (SampleChunk chunk : entry.getValue()) { + SampleChunk.IoState.release(chunk, true); + } + } + mEvictMap.clear(); + mSize = 0; + } + } + + public BufferManager(StorageManager storageManager) { + this(storageManager, new SampleChunk.SampleChunkCreator()); + } + + public BufferManager(StorageManager storageManager, + SampleChunk.SampleChunkCreator sampleChunkCreator) { + mStorageManager = storageManager; + mSampleChunkCreator = sampleChunkCreator; + clearBuffer(true); + } + + public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) { + mEvictListeners.put(id, listener); + } + + public void unregisterChunkEvictedListener(String id) { + mEvictListeners.remove(id); + } + + private void clearBuffer(boolean deleteFiles) { + mChunkMap.clear(); + if (deleteFiles) { + mStorageManager.clearStorage(); + } + mBufferSize = 0; + } + + private static String getFileName(String id, long positionUs) { + return String.format(Locale.ENGLISH, "%s_%016x.chunk", id, positionUs); + } + + /** + * Creates a new {@link SampleChunk} for caching samples. + * + * @param id the name of the track + * @param positionUs starting position of the {@link SampleChunk} in micro seconds. + * @param samplePool {@link SamplePool} for the fast creation of samples. + * @return returns the created {@link SampleChunk}. + * @throws IOException + */ + public SampleChunk createNewWriteFile(String id, long positionUs, + SamplePool samplePool) throws IOException { + if (!maybeEvictChunk()) { + throw new IOException("Not enough storage space"); + } + SortedMap<Long, SampleChunk> map = mChunkMap.get(id); + if (map == null) { + map = new TreeMap<>(); + mChunkMap.put(id, map); + mStartPositionMap.put(id, positionUs); + mPendingDelete.init(id); + } + File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs)); + SampleChunk sampleChunk = mSampleChunkCreator.createSampleChunk(samplePool, file, + positionUs, mChunkCallback); + map.put(positionUs, sampleChunk); + return sampleChunk; + } + + /** + * Loads a track using {@link BufferManager.StorageManager}. + * + * @param trackId the name of the track. + * @param samplePool {@link SamplePool} for the fast creation of samples. + * @throws IOException + */ + public void loadTrackFromStorage(String trackId, SamplePool samplePool) throws IOException { + ArrayList<Long> keyPositions = mStorageManager.readIndexFile(trackId); + long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0) : 0; + + SortedMap<Long, SampleChunk> map = mChunkMap.get(trackId); + if (map == null) { + map = new TreeMap<>(); + mChunkMap.put(trackId, map); + mStartPositionMap.put(trackId, startPositionUs); + mPendingDelete.init(trackId); + } + SampleChunk chunk = null; + for (long positionUs: keyPositions) { + chunk = mSampleChunkCreator.loadSampleChunkFromFile(samplePool, + mStorageManager.getBufferDir(), getFileName(trackId, positionUs), positionUs, + mChunkCallback, chunk); + map.put(positionUs, chunk); + } + } + + /** + * Finds a {@link SampleChunk} for the specified track name and the position. + * + * @param id the name of the track. + * @param positionUs the position. + * @return returns the found {@link SampleChunk}. + */ + public SampleChunk getReadFile(String id, long positionUs) { + SortedMap<Long, SampleChunk> map = mChunkMap.get(id); + if (map == null) { + return null; + } + SampleChunk sampleChunk; + SortedMap<Long, SampleChunk> headMap = map.headMap(positionUs + 1); + if (!headMap.isEmpty()) { + sampleChunk = headMap.get(headMap.lastKey()); + } else { + sampleChunk = map.get(map.firstKey()); + } + return sampleChunk; + } + + /** + * Evicts chunks which are ready to be evicted for the specified track + * + * @param id the specified track + * @param earlierThanPositionUs the start position of the {@link SampleChunk} + * should be earlier than + */ + public void evictChunks(String id, long earlierThanPositionUs) { + SampleChunk chunk = null; + while ((chunk = mPendingDelete.poll(id, earlierThanPositionUs)) != null) { + SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent()) ; + } + } + + /** + * Returns the start position of the specified track in micro seconds. + * + * @param id the specified track + */ + public long getStartPositionUs(String id) { + Long ret = mStartPositionMap.get(id); + return ret == null ? 0 : ret; + } + + private boolean maybeEvictChunk() { + long pendingDelete = mPendingDelete.getSize(); + while (mStorageManager.reachedStorageMax(mBufferSize, pendingDelete) + || !mStorageManager.hasEnoughBuffer(pendingDelete)) { + if (mStorageManager.isPersistent()) { + // Since chunks are persistent, we cannot evict chunks. + return false; + } + SortedMap<Long, SampleChunk> earliestChunkMap = null; + SampleChunk earliestChunk = null; + String earliestChunkId = null; + for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) { + SortedMap<Long, SampleChunk> map = entry.getValue(); + if (map.isEmpty()) { + continue; + } + SampleChunk chunk = map.get(map.firstKey()); + if (earliestChunk == null + || chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) { + earliestChunkMap = map; + earliestChunk = chunk; + earliestChunkId = entry.getKey(); + } + } + if (earliestChunk == null) { + break; + } + mPendingDelete.add(earliestChunkId, earliestChunk); + earliestChunkMap.remove(earliestChunk.getStartPositionUs()); + if (DEBUG) { + Log.d(TAG, String.format("bufferSize = %d; pendingDelete = %b; " + + "earliestChunk size = %d; %s@%d (%s)", + mBufferSize, pendingDelete, earliestChunk.getSize(), earliestChunkId, + earliestChunk.getStartPositionUs(), + Utils.toIsoDateTimeString(earliestChunk.getCreatedTimeMs()))); + } + ChunkEvictedListener listener = mEvictListeners.get(earliestChunkId); + if (listener != null) { + listener.onChunkEvicted(earliestChunkId, earliestChunk.getCreatedTimeMs()); + } + pendingDelete = mPendingDelete.getSize(); + } + for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) { + SortedMap<Long, SampleChunk> map = entry.getValue(); + if (map.isEmpty()) { + continue; + } + mStartPositionMap.put(entry.getKey(), map.firstKey()); + } + return true; + } + + /** + * Reads track information which includes {@link MediaFormat}. + * + * @return returns all track information which is found by {@link BufferManager.StorageManager}. + * @throws IOException + */ + public ArrayList<Pair<String, MediaFormat>> readTrackInfoFiles() throws IOException { + ArrayList<Pair<String, MediaFormat>> trackInfos = new ArrayList<>(); + try { + trackInfos.add(mStorageManager.readTrackInfoFile(false)); + } catch (FileNotFoundException e) { + // There can be a single track only recording. (eg. audio-only, video-only) + // So the exception should not stop the read. + } + try { + trackInfos.add(mStorageManager.readTrackInfoFile(true)); + } catch (FileNotFoundException e) { + // See above catch block. + } + return trackInfos; + } + + /** + * Writes track information and index information for all tracks. + * + * @param audio audio information. + * @param video video information. + * @throws IOException + */ + public void writeMetaFiles(Pair<String, MediaFormat> audio, Pair<String, MediaFormat> video) + throws IOException { + if (audio != null) { + mStorageManager.writeTrackInfoFile(audio.first, audio.second, true); + SortedMap<Long, SampleChunk> map = mChunkMap.get(audio.first); + if (map == null) { + throw new IOException("Audio track index missing"); + } + mStorageManager.writeIndexFile(audio.first, map); + } + if (video != null) { + mStorageManager.writeTrackInfoFile(video.first, video.second, false); + SortedMap<Long, SampleChunk> map = mChunkMap.get(video.first); + if (map == null) { + throw new IOException("Video track index missing"); + } + mStorageManager.writeIndexFile(video.first, map); + } + } + + /** + * Marks it is closed and it is not used anymore. + */ + public void close() { + // Clean-up may happen after this is called. + mClosed = true; + } + + /** + * Releases all the resources. + */ + public void release() { + mPendingDelete.release(); + for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) { + for (SampleChunk chunk : entry.getValue().values()) { + SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent()); + } + } + mChunkMap.clear(); + if (mClosed) { + clearBuffer(!mStorageManager.isPersistent()); + } + } + + private void resetWriteStat(float writeBandwidth) { + mWriteBandwidth = writeBandwidth; + mTotalWriteSize = 0; + mTotalWriteTimeNs = 0; + } + + /** + * Adds a disk write sample size to calculate the average disk write bandwidth. + */ + public void addWriteStat(long size, long timeNs) { + if (size >= mMinSampleSizeForSpeedCheck) { + mTotalWriteSize += size; + mTotalWriteTimeNs += timeNs; + } + } + + /** + * Returns if the average disk write bandwidth is slower than + * threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}. + */ + public boolean isWriteSlow() { + if (mTotalWriteSize < MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK) { + return false; + } + + // Checks write speed for only MAXIMUM_SPEED_CHECK_COUNT times to ignore outliers + // by temporary system overloading during the playback. + if (mSpeedCheckCount > MAXIMUM_SPEED_CHECK_COUNT) { + return false; + } + mSpeedCheckCount++; + float megabytePerSecond = calculateWriteBandwidth(); + resetWriteStat(megabytePerSecond); + if (DEBUG) { + Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps"); + } + return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS; + } + + /** + * Returns recent write bandwidth in MBps. If recent bandwidth is not available, + * returns {float -1.0f}. + */ + public float getWriteBandwidth() { + return mWriteBandwidth == 0.0f ? -1.0f : mWriteBandwidth; + } + + private float calculateWriteBandwidth() { + if (mTotalWriteTimeNs == 0) { + return -1; + } + return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs); + } + + /** + * Marks {@link BufferManager} object disabled to prevent it from the future use. + */ + public void disable() { + mDisabled = true; + } + + /** + * Returns if {@link BufferManager} object is disabled. + */ + public boolean isDisabled() { + return mDisabled; + } + + /** + * Returns if {@link BufferManager} has checked the write speed, + * which is suitable for Trickplay. + */ + @VisibleForTesting + public boolean hasSpeedCheckDone() { + return mSpeedCheckCount > 0; + } + + /** + * Sets minimum sample size for write speed check. + * @param sampleSize minimum sample size for write speed check. + */ + @VisibleForTesting + public void setMinimumSampleSizeForSpeedCheck(int sampleSize) { + mMinSampleSizeForSpeedCheck = sampleSize; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java new file mode 100644 index 00000000..6a0502a7 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java @@ -0,0 +1,287 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.media.MediaFormat; +import android.util.Pair; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.SortedMap; + +/** + * Manages DVR storage. + */ +public class DvrStorageManager implements BufferManager.StorageManager { + + // TODO: make serializable classes and use protobuf after internal data structure is finalized. + private static final String KEY_PIXEL_WIDTH_HEIGHT_RATIO = + "com.google.android.videos.pixelWidthHeightRatio"; + private static final String META_FILE_SUFFIX = ".meta"; + private static final String IDX_FILE_SUFFIX = ".idx"; + + // Size of minimum reserved storage buffer which will be used to save meta files + // and index files after actual recording finished. + private static final long MIN_BUFFER_BYTES = 256L * 1024 * 1024; + private static final int NO_VALUE = -1; + private static final long NO_VALUE_LONG = -1L; + + private final File mBufferDir; + + // {@code true} when this is for recording, {@code false} when this is for replaying. + private final boolean mIsRecording; + + public DvrStorageManager(File file, boolean isRecording) { + mBufferDir = file; + mBufferDir.mkdirs(); + mIsRecording = isRecording; + } + + @Override + public void clearStorage() { + if (mIsRecording) { + File[] files = mBufferDir.listFiles(); + if (files != null && files.length > 0) { + for (File file : files) { + file.delete(); + } + } + } + } + + @Override + public File getBufferDir() { + return mBufferDir; + } + + @Override + public boolean isPersistent() { + return true; + } + + @Override + public boolean reachedStorageMax(long bufferSize, long pendingDelete) { + return false; + } + + @Override + public boolean hasEnoughBuffer(long pendingDelete) { + return !mIsRecording || mBufferDir.getUsableSpace() >= MIN_BUFFER_BYTES; + } + + private void readFormatInt(DataInputStream in, MediaFormat format, String key) + throws IOException { + int val = in.readInt(); + if (val != NO_VALUE) { + format.setInteger(key, val); + } + } + + private void readFormatLong(DataInputStream in, MediaFormat format, String key) + throws IOException { + long val = in.readLong(); + if (val != NO_VALUE_LONG) { + format.setLong(key, val); + } + } + + private void readFormatFloat(DataInputStream in, MediaFormat format, String key) + throws IOException { + float val = in.readFloat(); + if (val != NO_VALUE) { + format.setFloat(key, val); + } + } + + private String readString(DataInputStream in) throws IOException { + int len = in.readInt(); + if (len <= 0) { + return null; + } + byte [] strBytes = new byte[len]; + in.readFully(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } + + private void readFormatString(DataInputStream in, MediaFormat format, String key) + throws IOException { + String str = readString(in); + if (str != null) { + format.setString(key, str); + } + } + + private ByteBuffer readByteBuffer(DataInputStream in) throws IOException { + int len = in.readInt(); + if (len <= 0) { + return null; + } + byte [] bytes = new byte[len]; + in.readFully(bytes); + ByteBuffer buffer = ByteBuffer.allocate(len); + buffer.put(bytes); + buffer.flip(); + + return buffer; + } + + private void readFormatByteBuffer(DataInputStream in, MediaFormat format, String key) + throws IOException { + ByteBuffer buffer = readByteBuffer(in); + if (buffer != null) { + format.setByteBuffer(key, buffer); + } + } + + @Override + public Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException { + File file = new File(getBufferDir(), (isAudio ? "audio" : "video") + META_FILE_SUFFIX); + try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { + String name = readString(in); + MediaFormat format = new MediaFormat(); + readFormatString(in, format, MediaFormat.KEY_MIME); + readFormatInt(in, format, MediaFormat.KEY_MAX_INPUT_SIZE); + readFormatInt(in, format, MediaFormat.KEY_WIDTH); + readFormatInt(in, format, MediaFormat.KEY_HEIGHT); + readFormatInt(in, format, MediaFormat.KEY_CHANNEL_COUNT); + readFormatInt(in, format, MediaFormat.KEY_SAMPLE_RATE); + readFormatFloat(in, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO); + for (int i = 0; i < 3; ++i) { + readFormatByteBuffer(in, format, "csd-" + i); + } + readFormatLong(in, format, MediaFormat.KEY_DURATION); + return new Pair<>(name, format); + } + } + + @Override + public ArrayList<Long> readIndexFile(String trackId) throws IOException { + ArrayList<Long> indices = new ArrayList<>(); + File file = new File(getBufferDir(), trackId + IDX_FILE_SUFFIX); + try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { + long count = in.readLong(); + for (long i = 0; i < count; ++i) { + indices.add(in.readLong()); + } + return indices; + } + } + + private void writeFormatInt(DataOutputStream out, MediaFormat format, String key) + throws IOException { + if (format.containsKey(key)) { + out.writeInt(format.getInteger(key)); + } else { + out.writeInt(NO_VALUE); + } + } + + private void writeFormatLong(DataOutputStream out, MediaFormat format, String key) + throws IOException { + if (format.containsKey(key)) { + out.writeLong(format.getLong(key)); + } else { + out.writeLong(NO_VALUE_LONG); + } + } + + private void writeFormatFloat(DataOutputStream out, MediaFormat format, String key) + throws IOException { + if (format.containsKey(key)) { + out.writeFloat(format.getFloat(key)); + } else { + out.writeFloat(NO_VALUE); + } + } + + private void writeString(DataOutputStream out, String str) throws IOException { + byte [] data = str.getBytes(StandardCharsets.UTF_8); + out.writeInt(data.length); + if (data.length > 0) { + out.write(data); + } + } + + private void writeFormatString(DataOutputStream out, MediaFormat format, String key) + throws IOException { + if (format.containsKey(key)) { + writeString(out, format.getString(key)); + } else { + out.writeInt(0); + } + } + + private void writeByteBuffer(DataOutputStream out, ByteBuffer buffer) throws IOException { + byte [] data = new byte[buffer.limit()]; + buffer.get(data); + buffer.flip(); + out.writeInt(data.length); + if (data.length > 0) { + out.write(data); + } else { + out.writeInt(0); + } + } + + private void writeFormatByteBuffer(DataOutputStream out, MediaFormat format, String key) + throws IOException { + if (format.containsKey(key)) { + writeByteBuffer(out, format.getByteBuffer(key)); + } else { + out.writeInt(0); + } + } + + @Override + public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) + throws IOException { + File file = new File(getBufferDir(), (isAudio ? "audio" : "video") + META_FILE_SUFFIX); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + writeString(out, trackId); + writeFormatString(out, format, MediaFormat.KEY_MIME); + writeFormatInt(out, format, MediaFormat.KEY_MAX_INPUT_SIZE); + writeFormatInt(out, format, MediaFormat.KEY_WIDTH); + writeFormatInt(out, format, MediaFormat.KEY_HEIGHT); + writeFormatInt(out, format, MediaFormat.KEY_CHANNEL_COUNT); + writeFormatInt(out, format, MediaFormat.KEY_SAMPLE_RATE); + writeFormatFloat(out, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO); + for (int i = 0; i < 3; ++i) { + writeFormatByteBuffer(out, format, "csd-" + i); + } + writeFormatLong(out, format, MediaFormat.KEY_DURATION); + } + } + + @Override + public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) + throws IOException { + File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(indexFile))) { + out.writeLong(index.size()); + for (Long key : index.keySet()) { + out.writeLong(key); + } + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java new file mode 100644 index 00000000..4869b49f --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java @@ -0,0 +1,306 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.os.ConditionVariable; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.google.android.exoplayer.util.Assertions; +import com.android.tv.tuner.exoplayer.MpegTsPlayer; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; +import com.android.tv.tuner.exoplayer.SampleExtractor; + +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Handles I/O between {@link SampleExtractor} and + * {@link BufferManager}.Reads & writes samples from/to {@link SampleChunk} which is backed + * by physical storage. + */ +public class RecordingSampleBuffer implements BufferManager.SampleBuffer, + BufferManager.ChunkEvictedListener { + private static final String TAG = "RecordingSampleBuffer"; + + @IntDef({BUFFER_REASON_LIVE_PLAYBACK, BUFFER_REASON_RECORDED_PLAYBACK, BUFFER_REASON_RECORDING}) + @Retention(RetentionPolicy.SOURCE) + public @interface BufferReason {} + + /** + * A buffer reason for live-stream playback. + */ + public static final int BUFFER_REASON_LIVE_PLAYBACK = 0; + + /** + * A buffer reason for playback of a recorded program. + */ + public static final int BUFFER_REASON_RECORDED_PLAYBACK = 1; + + /** + * A buffer reason for recording a program. + */ + public static final int BUFFER_REASON_RECORDING = 2; + + /** + * The duration of a chunk of samples, {@link SampleChunk}. + */ + static final long CHUNK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500); + private static final long BUFFER_WRITE_TIMEOUT_MS = 10 * 1000; // 10 seconds + private static final long BUFFER_NEEDED_US = + 1000L * Math.max(MpegTsPlayer.MIN_BUFFER_MS, MpegTsPlayer.MIN_REBUFFER_MS); + + private final BufferManager mBufferManager; + private final PlaybackBufferListener mBufferListener; + private final @BufferReason int mBufferReason; + + private int mTrackCount; + private boolean[] mTrackSelected; + private List<String> mIds; + private List<SampleQueue> mReadSampleQueues; + private final SamplePool mSamplePool = new SamplePool(); + private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US; + private long mCurrentPlaybackPositionUs = 0; + + // An error in I/O thread of {@link SampleChunkIoHelper} will be notified. + private volatile boolean mError; + + // Eos was reached in I/O thread of {@link SampleChunkIoHelper}. + private volatile boolean mEos; + private SampleChunkIoHelper mSampleChunkIoHelper; + private final SampleChunkIoHelper.IoCallback mIoCallback = + new SampleChunkIoHelper.IoCallback() { + @Override + public void onIoReachedEos() { + mEos = true; + } + + @Override + public void onIoError() { + mError = true; + } + }; + + /** + * Creates {@link BufferManager.SampleBuffer} with + * cached I/O backed by physical storage (e.g. trickplay,recording,recorded-playback). + * + * @param bufferManager the manager of {@link SampleChunk} + * @param bufferListener the listener for buffer I/O event + * @param enableTrickplay {@code true} when trickplay should be enabled + * @param bufferReason the reason for caching samples {@link RecordingSampleBuffer.BufferReason} + */ + public RecordingSampleBuffer(BufferManager bufferManager, PlaybackBufferListener bufferListener, + boolean enableTrickplay, @BufferReason int bufferReason) { + mBufferManager = bufferManager; + mBufferListener = bufferListener; + if (bufferListener != null) { + bufferListener.onBufferStateChanged(enableTrickplay); + } + mBufferReason = bufferReason; + } + + @Override + public void init(@NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats) + throws IOException { + mTrackCount = ids.size(); + if (mTrackCount <= 0) { + throw new IOException("No tracks to initialize"); + } + mIds = ids; + mTrackSelected = new boolean[mTrackCount]; + mReadSampleQueues = new ArrayList<>(); + mSampleChunkIoHelper = new SampleChunkIoHelper(ids, mediaFormats, mBufferReason, + mBufferManager, mSamplePool, mIoCallback); + for (int i = 0; i < mTrackCount; ++i) { + mReadSampleQueues.add(i, new SampleQueue(mSamplePool)); + } + mSampleChunkIoHelper.init(); + } + + @Override + public void selectTrack(int index) { + if (!mTrackSelected[index]) { + mTrackSelected[index] = true; + mReadSampleQueues.get(index).clear(); + mBufferManager.registerChunkEvictedListener(mIds.get(index), + RecordingSampleBuffer.this); + mSampleChunkIoHelper.openRead(index, mCurrentPlaybackPositionUs); + } + } + + @Override + public void deselectTrack(int index) { + if (mTrackSelected[index]) { + mTrackSelected[index] = false; + mReadSampleQueues.get(index).clear(); + mBufferManager.unregisterChunkEvictedListener(mIds.get(index)); + } + } + + @Override + public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable) + throws IOException { + mSampleChunkIoHelper.writeSample(index, sample, conditionVariable); + + if (!conditionVariable.block(BUFFER_WRITE_TIMEOUT_MS)) { + Log.e(TAG, "Error: Serious delay on writing buffer"); + conditionVariable.block(); + } + } + + @Override + public boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs) { + if (mBufferReason == BUFFER_REASON_RECORDED_PLAYBACK) { + return false; + } + mBufferManager.addWriteStat(sampleSize, writeDurationNs); + return mBufferManager.isWriteSlow(); + } + + @Override + public void handleWriteSpeedSlow() throws IOException{ + if (mBufferReason == BUFFER_REASON_RECORDING) { + // Recording does not need to stop because I/O speed is slow temporarily. + // If fixed size buffer of TsStreamer overflows, TsDataSource will reach EoS. + // Reaching EoS will stop recording eventually. + Log.w(TAG, "Disk I/O speed is slow for recording temporarily: " + + mBufferManager.getWriteBandwidth() + "MBps"); + return; + } + // Disables buffering samples afterwards, and notifies the disk speed is slow. + Log.w(TAG, "Disk is too slow for trickplay"); + mBufferManager.disable(); + mBufferListener.onDiskTooSlow(); + } + + @Override + public void setEos() { + mSampleChunkIoHelper.closeWrite(); + } + + private boolean maybeReadSample(SampleQueue queue, int index) { + if (queue.getLastQueuedPositionUs() != null + && queue.getLastQueuedPositionUs() > mCurrentPlaybackPositionUs + BUFFER_NEEDED_US + && queue.isDurationGreaterThan(CHUNK_DURATION_US)) { + // The speed of queuing samples can be higher than the playback speed. + // If the duration of the samples in the queue is not limited, + // samples can be accumulated and there can be out-of-memory issues. + // But, the throttling should provide enough samples for the player to + // finish the buffering state. + return false; + } + SampleHolder sample = mSampleChunkIoHelper.readSample(index); + if (sample != null) { + queue.queueSample(sample); + return true; + } + return false; + } + + @Override + public int readSample(int track, SampleHolder outSample) { + Assertions.checkState(mTrackSelected[track]); + maybeReadSample(mReadSampleQueues.get(track), track); + int result = mReadSampleQueues.get(track).dequeueSample(outSample); + if ((result != SampleSource.SAMPLE_READ && mEos) || mError) { + return SampleSource.END_OF_STREAM; + } + return result; + } + + @Override + public void seekTo(long positionUs) { + for (int i = 0; i < mTrackCount; ++i) { + if (mTrackSelected[i]) { + mReadSampleQueues.get(i).clear(); + mSampleChunkIoHelper.openRead(i, positionUs); + } + } + mLastBufferedPositionUs = positionUs; + } + + @Override + public long getBufferedPositionUs() { + Long result = null; + for (int i = 0; i < mTrackCount; ++i) { + if (!mTrackSelected[i]) { + continue; + } + Long lastQueuedSamplePositionUs = + mReadSampleQueues.get(i).getLastQueuedPositionUs(); + if (lastQueuedSamplePositionUs == null) { + // No sample has been queued. + result = mLastBufferedPositionUs; + continue; + } + if (result == null || result > lastQueuedSamplePositionUs) { + result = lastQueuedSamplePositionUs; + } + } + if (result == null) { + return mLastBufferedPositionUs; + } + return (mLastBufferedPositionUs = result); + } + + @Override + public boolean continueBuffering(long positionUs) { + mCurrentPlaybackPositionUs = positionUs; + for (int i = 0; i < mTrackCount; ++i) { + if (!mTrackSelected[i]) { + continue; + } + SampleQueue queue = mReadSampleQueues.get(i); + maybeReadSample(queue, i); + if (queue.getLastQueuedPositionUs() == null + || positionUs > queue.getLastQueuedPositionUs()) { + // No more buffered data. + return false; + } + } + return true; + } + + @Override + public void release() throws IOException { + if (mTrackCount <= 0) { + return; + } + if (mSampleChunkIoHelper != null) { + mSampleChunkIoHelper.release(); + } + } + + // onChunkEvictedListener + @Override + public void onChunkEvicted(String id, long createdTimeMs) { + if (mBufferListener != null) { + mBufferListener.onBufferStartTimeChanged( + createdTimeMs + TimeUnit.MICROSECONDS.toMillis(CHUNK_DURATION_US)); + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java new file mode 100644 index 00000000..552caaef --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java @@ -0,0 +1,419 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import com.google.android.exoplayer.SampleHolder; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; + +/** + * {@link SampleChunk} stores samples into file and makes them available for read. + * Stored file = { Header, Sample } * N + * Header = sample size : int, sample flag : int, sample PTS in micro second : long + */ +public class SampleChunk { + private static final String TAG = "SampleChunk"; + private static final boolean DEBUG = false; + + private final long mCreatedTimeMs; + private final long mStartPositionUs; + private SampleChunk mNextChunk; + + // Header = sample size : int, sample flag : int, sample PTS in micro second : long + private static final int SAMPLE_HEADER_LENGTH = 16; + + private final File mFile; + private final ChunkCallback mChunkCallback; + private final SamplePool mSamplePool; + private RandomAccessFile mAccessFile; + private long mWriteOffset; + private boolean mWriteFinished; + private boolean mIsReading; + private boolean mIsWriting; + + /** + * A callback for chunks being committed to permanent storage. + */ + public static abstract class ChunkCallback { + + /** + * Notifies when writing a SampleChunk is completed. + * + * @param chunk SampleChunk which is written completely + */ + public void onChunkWrite(SampleChunk chunk) { + + } + + /** + * Notifies when a SampleChunk is deleted. + * + * @param chunk SampleChunk which is deleted from storage + */ + public void onChunkDelete(SampleChunk chunk) { + } + } + + /** + * A class for SampleChunk creation. + */ + @VisibleForTesting + public static class SampleChunkCreator { + + /** + * Returns a newly created SampleChunk to read & write samples. + * + * @param samplePool sample allocator + * @param file filename which will be created newly + * @param startPositionUs the start position of the earliest sample to be stored + * @param chunkCallback for total storage usage change notification + */ + SampleChunk createSampleChunk(SamplePool samplePool, File file, + long startPositionUs, ChunkCallback chunkCallback) { + return new SampleChunk(samplePool, file, startPositionUs, System.currentTimeMillis(), + chunkCallback); + } + + /** + * Returns a newly created SampleChunk which is backed by an existing file. + * Created SampleChunk is read-only. + * + * @param samplePool sample allocator + * @param bufferDir the directory where the file to read is located + * @param filename the filename which will be read afterwards + * @param startPositionUs the start position of the earliest sample in the file + * @param chunkCallback for total storage usage change notification + * @param prev the previous SampleChunk just before the newly created SampleChunk + * @throws IOException + */ + SampleChunk loadSampleChunkFromFile(SamplePool samplePool, File bufferDir, + String filename, long startPositionUs, ChunkCallback chunkCallback, + SampleChunk prev) throws IOException { + File file = new File(bufferDir, filename); + SampleChunk chunk = + new SampleChunk(samplePool, file, startPositionUs, chunkCallback); + if (prev != null) { + prev.mNextChunk = chunk; + } + return chunk; + } + } + + /** + * Handles I/O for SampleChunk. + * Maintains current SampleChunk and the current offset for next I/O operation. + */ + static class IoState { + private SampleChunk mChunk; + private long mCurrentOffset; + + private boolean equals(SampleChunk chunk, long offset) { + return chunk == mChunk && mCurrentOffset == offset; + } + + /** + * Returns whether read I/O operation is finished. + */ + boolean isReadFinished() { + return mChunk == null; + } + + /** + * Returns the start position of the current SampleChunk + */ + long getStartPositionUs() { + return mChunk == null ? 0 : mChunk.getStartPositionUs(); + } + + private void reset(@Nullable SampleChunk chunk) { + mChunk = chunk; + mCurrentOffset = 0; + } + + /** + * Prepares for read I/O operation from a new SampleChunk. + * + * @param chunk the new SampleChunk to read from + * @throws IOException + */ + void openRead(SampleChunk chunk) throws IOException { + if (mChunk != null) { + mChunk.closeRead(); + } + chunk.openRead(); + reset(chunk); + } + + /** + * Prepares for write I/O operation to a new SampleChunk. + * + * @param chunk the new SampleChunk to write samples afterwards + * @throws IOException + */ + void openWrite(SampleChunk chunk) throws IOException{ + if (mChunk != null) { + mChunk.closeWrite(chunk); + } + chunk.openWrite(); + reset(chunk); + } + + /** + * Reads a sample if it is available. + * + * @return Returns a sample if it is available, null otherwise. + * @throws IOException + */ + SampleHolder read() throws IOException { + if (mChunk != null && mChunk.isReadFinished(this)) { + SampleChunk next = mChunk.mNextChunk; + mChunk.closeRead(); + if (next != null) { + next.openRead(); + } + reset(next); + } + if (mChunk != null) { + try { + return mChunk.read(this); + } catch (IllegalStateException e) { + // Write is finished and there is no additional buffer to read. + Log.w(TAG, "Tried to read sample over EOS."); + return null; + } + } else { + return null; + } + } + + /** + * Writes a sample. + * + * @param sample to write + * @param nextChunk if this is {@code null} writes at the current SampleChunk, + * otherwise close current SampleChunk and writes at this + * @throws IOException + */ + void write(SampleHolder sample, SampleChunk nextChunk) + throws IOException { + if (nextChunk != null) { + if (mChunk == null || mChunk.mNextChunk != null) { + throw new IllegalStateException("Requested write for wrong SampleChunk"); + } + mChunk.closeWrite(nextChunk); + mChunk.mChunkCallback.onChunkWrite(mChunk); + nextChunk.openWrite(); + reset(nextChunk); + } + mChunk.write(sample, this); + } + + /** + * Finishes write I/O operation. + * + * @throws IOException + */ + void closeWrite() throws IOException { + if (mChunk != null) { + mChunk.closeWrite(null); + } + } + + /** + * Releases SampleChunk. the SampleChunk will not be used anymore. + * + * @param chunk to release + * @param delete {@code true} when the backed file needs to be deleted, + * {@code false} otherwise. + */ + static void release(SampleChunk chunk, boolean delete) { + chunk.release(delete); + } + } + + @VisibleForTesting + protected SampleChunk(SamplePool samplePool, File file, long startPositionUs, + long createdTimeMs, ChunkCallback chunkCallback) { + mStartPositionUs = startPositionUs; + mCreatedTimeMs = createdTimeMs; + mSamplePool = samplePool; + mFile = file; + mChunkCallback = chunkCallback; + } + + // Constructor of SampleChunk which is backed by the given existing file. + private SampleChunk(SamplePool samplePool, File file, long startPositionUs, + ChunkCallback chunkCallback) throws IOException { + mStartPositionUs = startPositionUs; + mCreatedTimeMs = mStartPositionUs / 1000; + mSamplePool = samplePool; + mFile = file; + mChunkCallback = chunkCallback; + mWriteFinished = true; + } + + private void openRead() throws IOException { + if (!mIsReading) { + if (mAccessFile == null) { + mAccessFile = new RandomAccessFile(mFile, "r"); + } + if (mWriteFinished && mWriteOffset == 0) { + // Lazy loading of write offset, in order not to load + // all SampleChunk's write offset at start time of recorded playback. + mWriteOffset = mAccessFile.length(); + } + mIsReading = true; + } + } + + private void openWrite() throws IOException { + if (mWriteFinished) { + throw new IllegalStateException("Opened for write though write is already finished"); + } + if (!mIsWriting) { + if (mIsReading) { + throw new IllegalStateException("Write is requested for " + + "an already opened SampleChunk"); + } + mAccessFile = new RandomAccessFile(mFile, "rw"); + mIsWriting = true; + } + } + + private void CloseAccessFileIfNeeded() throws IOException { + if (!mIsReading && !mIsWriting) { + try { + if (mAccessFile != null) { + mAccessFile.close(); + } + } finally { + mAccessFile = null; + } + } + } + + private void closeRead() throws IOException{ + if (mIsReading) { + mIsReading = false; + CloseAccessFileIfNeeded(); + } + } + + private void closeWrite(SampleChunk nextChunk) + throws IOException { + if (mIsWriting) { + mNextChunk = nextChunk; + mIsWriting = false; + mWriteFinished = true; + CloseAccessFileIfNeeded(); + } + } + + private boolean isReadFinished(IoState state) { + return mWriteFinished && state.equals(this, mWriteOffset); + } + + private SampleHolder read(IoState state) throws IOException { + if (mAccessFile == null || state.mChunk != this) { + throw new IllegalStateException("Requested read for wrong SampleChunk"); + } + long offset = state.mCurrentOffset; + if (offset >= mWriteOffset) { + if (mWriteFinished) { + throw new IllegalStateException("Requested read for wrong range"); + } else { + if (offset != mWriteOffset) { + Log.e(TAG, "This should not happen!"); + } + return null; + } + } + mAccessFile.seek(offset); + int size = mAccessFile.readInt(); + SampleHolder sample = mSamplePool.acquireSample(size); + sample.size = size; + sample.flags = mAccessFile.readInt(); + sample.timeUs = mAccessFile.readLong(); + sample.clearData(); + sample.data.put(mAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, + offset + SAMPLE_HEADER_LENGTH, sample.size)); + offset += sample.size + SAMPLE_HEADER_LENGTH; + state.mCurrentOffset = offset; + return sample; + } + + @VisibleForTesting + protected void write(SampleHolder sample, IoState state) + throws IOException { + if (mAccessFile == null || mNextChunk != null || !state.equals(this, mWriteOffset)) { + throw new IllegalStateException("Requested write for wrong SampleChunk"); + } + + mAccessFile.seek(mWriteOffset); + mAccessFile.writeInt(sample.size); + mAccessFile.writeInt(sample.flags); + mAccessFile.writeLong(sample.timeUs); + sample.data.position(0).limit(sample.size); + mAccessFile.getChannel().position(mWriteOffset + SAMPLE_HEADER_LENGTH).write(sample.data); + mWriteOffset += sample.size + SAMPLE_HEADER_LENGTH; + state.mCurrentOffset = mWriteOffset; + } + + private void release(boolean delete) { + mWriteFinished = true; + mIsReading = mIsWriting = false; + try { + if (mAccessFile != null) { + mAccessFile.close(); + } + } catch (IOException e) { + // Since the SampleChunk will not be reused, ignore exception. + } + if (delete) { + mFile.delete(); + mChunkCallback.onChunkDelete(this); + } + } + + /** + * Returns the start position. + */ + public long getStartPositionUs() { + return mStartPositionUs; + } + + /** + * Returns the creation time. + */ + public long getCreatedTimeMs() { + return mCreatedTimeMs; + } + + /** + * Returns the current size. + */ + public long getSize() { + return mWriteOffset; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java new file mode 100644 index 00000000..37ae4022 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java @@ -0,0 +1,406 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.media.MediaCodec; +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.util.Log; +import android.util.Pair; + +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.util.MimeTypes; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer.BufferReason; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Handles all {@link SampleChunk} I/O operations. + * An I/O dedicated thread handles all I/O operations for synchronization. + */ +public class SampleChunkIoHelper implements Handler.Callback { + private static final String TAG = "SampleChunkIoHelper"; + + private static final int MAX_READ_BUFFER_SAMPLES = 3; + private static final int READ_RESCHEDULING_DELAY_MS = 10; + + private static final int MSG_OPEN_READ = 1; + private static final int MSG_OPEN_WRITE = 2; + private static final int MSG_CLOSE_WRITE = 3; + private static final int MSG_READ = 4; + private static final int MSG_WRITE = 5; + private static final int MSG_RELEASE = 6; + + private final int mTrackCount; + private final List<String> mIds; + private final List<MediaFormat> mMediaFormats; + private final @BufferReason int mBufferReason; + private final BufferManager mBufferManager; + private final SamplePool mSamplePool; + private final IoCallback mIoCallback; + + private Handler mIoHandler; + private final ConcurrentLinkedQueue<SampleHolder> mReadSampleBuffers[]; + private final ConcurrentLinkedQueue<SampleHolder> mHandlerReadSampleBuffers[]; + private final long[] mWriteEndPositionUs; + private final SampleChunk.IoState[] mReadIoStates; + private final SampleChunk.IoState[] mWriteIoStates; + private long mBufferDurationUs = 0; + private boolean mWriteEnded; + private boolean mErrorNotified; + private boolean mFinished; + + /** + * A Callback for I/O events. + */ + public static abstract class IoCallback { + + /** + * Called when there is no sample to read. + */ + public void onIoReachedEos() { + } + + /** + * Called when there is an irrecoverable error during I/O. + */ + public void onIoError() { + } + } + + private class IoParams { + private final int index; + private final long positionUs; + private final SampleHolder sample; + private final ConditionVariable conditionVariable; + private final ConcurrentLinkedQueue<SampleHolder> readSampleBuffer; + + private IoParams(int index, long positionUs, SampleHolder sample, + ConditionVariable conditionVariable, + ConcurrentLinkedQueue<SampleHolder> readSampleBuffer) { + this.index = index; + this.positionUs = positionUs; + this.sample = sample; + this.conditionVariable = conditionVariable; + this.readSampleBuffer = readSampleBuffer; + } + } + + /** + * Creates {@link SampleChunk} I/O handler. + * + * @param ids track names + * @param mediaFormats {@link android.media.MediaFormat} for each track + * @param bufferReason reason to be buffered + * @param bufferManager manager of {@link SampleChunk} collections + * @param samplePool allocator for a sample + * @param ioCallback listeners for I/O events + */ + public SampleChunkIoHelper(List<String> ids, List<MediaFormat> mediaFormats, + @BufferReason int bufferReason, BufferManager bufferManager, SamplePool samplePool, + IoCallback ioCallback) { + mTrackCount = ids.size(); + mIds = ids; + mMediaFormats = mediaFormats; + mBufferReason = bufferReason; + mBufferManager = bufferManager; + mSamplePool = samplePool; + mIoCallback = ioCallback; + + mReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount]; + mHandlerReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount]; + mWriteEndPositionUs = new long[mTrackCount]; + mReadIoStates = new SampleChunk.IoState[mTrackCount]; + mWriteIoStates = new SampleChunk.IoState[mTrackCount]; + for (int i = 0; i < mTrackCount; ++i) { + mWriteEndPositionUs[i] = RecordingSampleBuffer.CHUNK_DURATION_US; + mReadIoStates[i] = new SampleChunk.IoState(); + mWriteIoStates[i] = new SampleChunk.IoState(); + } + } + + /** + * Prepares and initializes for I/O operations. + * + * @throws IOException + */ + public void init() throws IOException { + HandlerThread handlerThread = new HandlerThread(TAG); + handlerThread.start(); + mIoHandler = new Handler(handlerThread.getLooper(), this); + if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK) { + for (int i = 0; i < mTrackCount; ++i) { + mBufferManager.loadTrackFromStorage(mIds.get(i), mSamplePool); + } + mWriteEnded = true; + } else { + for (int i = 0; i < mTrackCount; ++i) { + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_WRITE, i)); + } + } + } + + /** + * Reads a sample if it is available. + * + * @param index track index + * @return {@code null} if a sample is not available, otherwise returns a sample + */ + public SampleHolder readSample(int index) { + SampleHolder sample = mReadSampleBuffers[index].poll(); + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index)); + return sample; + } + + /** + * Writes a sample. + * + * @param index track index + * @param sample to write + * @param conditionVariable which will be wait until the write is finished + * @throws IOException + */ + public void writeSample(int index, SampleHolder sample, + ConditionVariable conditionVariable) throws IOException { + if (mErrorNotified) { + throw new IOException("Storage I/O error happened"); + } + conditionVariable.close(); + IoParams params = new IoParams(index, 0, sample, conditionVariable, null); + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_WRITE, params)); + } + + /** + * Starts read from the specified position. + * + * @param index track index + * @param positionUs the specified position + */ + public void openRead(int index, long positionUs) { + // Old mReadSampleBuffers may have a pending read. + mReadSampleBuffers[index] = new ConcurrentLinkedQueue<>(); + IoParams params = new IoParams(index, positionUs, null, null, mReadSampleBuffers[index]); + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_READ, params)); + } + + /** + * Notifies writes are finished. + */ + public void closeWrite() { + mIoHandler.sendEmptyMessage(MSG_CLOSE_WRITE); + } + + /** + * Finishes I/O operations and releases all the resources. + * @throws IOException + */ + public void release() throws IOException { + if (mIoHandler == null) { + return; + } + // Finishes all I/O operations. + ConditionVariable conditionVariable = new ConditionVariable(); + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_RELEASE, conditionVariable)); + conditionVariable.block(); + + for (int i = 0; i < mTrackCount; ++i) { + mBufferManager.unregisterChunkEvictedListener(mIds.get(i)); + } + try { + if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDING && mTrackCount > 0) { + // Saves meta information for recording. + Pair<String, android.media.MediaFormat> audio = null, video = null; + for (int i = 0; i < mTrackCount; ++i) { + android.media.MediaFormat format = + mMediaFormats.get(i).getFrameworkMediaFormatV16(); + format.setLong(android.media.MediaFormat.KEY_DURATION, mBufferDurationUs); + if (audio == null && MimeTypes.isAudio(mMediaFormats.get(i).mimeType)) { + audio = new Pair<>(mIds.get(i), format); + } else if (video == null && MimeTypes.isVideo(mMediaFormats.get(i).mimeType)) { + video = new Pair<>(mIds.get(i), format); + } + if (audio != null && video != null) { + break; + } + } + mBufferManager.writeMetaFiles(audio, video); + } + } finally { + mBufferManager.release(); + mIoHandler.getLooper().quitSafely(); + } + } + + @Override + public boolean handleMessage(Message message) { + if (mFinished) { + return true; + } + releaseEvictedChunks(); + try { + switch (message.what) { + case MSG_OPEN_READ: + doOpenRead((IoParams) message.obj); + return true; + case MSG_OPEN_WRITE: + doOpenWrite((int) message.obj); + return true; + case MSG_CLOSE_WRITE: + doCloseWrite(); + return true; + case MSG_READ: + doRead((int) message.obj); + return true; + case MSG_WRITE: + doWrite((IoParams) message.obj); + // Since only write will increase storage, eviction will be handled here. + return true; + case MSG_RELEASE: + doRelease((ConditionVariable) message.obj); + return true; + } + } catch (IOException e) { + mIoCallback.onIoError(); + mErrorNotified = true; + Log.e(TAG, "IoException happened", e); + return true; + } + return false; + } + + private void doOpenRead(IoParams params) throws IOException { + int index = params.index; + mIoHandler.removeMessages(MSG_READ, index); + SampleChunk chunk = mBufferManager.getReadFile(mIds.get(index), params.positionUs); + if (chunk == null) { + String errorMessage = "Chunk ID:" + mIds.get(index) + " pos:" + params.positionUs + + "is not found"; + SoftPreconditions.checkNotNull(chunk, TAG, errorMessage); + throw new IOException(errorMessage); + } + mReadIoStates[index].openRead(chunk); + if (mHandlerReadSampleBuffers[index] != null) { + SampleHolder sample; + while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) { + mSamplePool.releaseSample(sample); + } + } + mHandlerReadSampleBuffers[index] = params.readSampleBuffer; + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index)); + } + + private void doOpenWrite(int index) throws IOException { + SampleChunk chunk = mBufferManager.createNewWriteFile(mIds.get(index), 0, mSamplePool); + mWriteIoStates[index].openWrite(chunk); + } + + private void doRead(int index) throws IOException { + mIoHandler.removeMessages(MSG_READ, index); + if (mHandlerReadSampleBuffers[index].size() >= MAX_READ_BUFFER_SAMPLES) { + // If enough samples are buffered, try again few moments later hoping that + // buffered samples are consumed. + mIoHandler.sendMessageDelayed( + mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS); + } else { + if (mReadIoStates[index].isReadFinished()) { + for (int i = 0; i < mTrackCount; ++i) { + if (!mReadIoStates[i].isReadFinished()) { + return; + } + } + mIoCallback.onIoReachedEos(); + return; + } + SampleHolder sample = mReadIoStates[index].read(); + if (sample != null) { + mHandlerReadSampleBuffers[index].offer(sample); + } else { + // Read reached write but write is not finished yet --- wait a few moments to + // see if another sample is written. + mIoHandler.sendMessageDelayed( + mIoHandler.obtainMessage(MSG_READ, index), + READ_RESCHEDULING_DELAY_MS); + } + } + } + + private void doWrite(IoParams params) throws IOException { + try { + if (mWriteEnded) { + SoftPreconditions.checkState(false); + return; + } + int index = params.index; + SampleHolder sample = params.sample; + SampleChunk nextChunk = null; + if ((sample.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + if (sample.timeUs > mBufferDurationUs) { + mBufferDurationUs = sample.timeUs; + } + + if (sample.timeUs >= mWriteEndPositionUs[index]) { + nextChunk = mBufferManager.createNewWriteFile(mIds.get(index), + mWriteEndPositionUs[index], mSamplePool); + mWriteEndPositionUs[index] = + ((sample.timeUs / RecordingSampleBuffer.CHUNK_DURATION_US) + 1) * + RecordingSampleBuffer.CHUNK_DURATION_US; + } + } + mWriteIoStates[params.index].write(params.sample, nextChunk); + } finally { + params.conditionVariable.open(); + } + } + + private void doCloseWrite() throws IOException { + if (mWriteEnded) { + return; + } + mWriteEnded = true; + boolean readFinished = true; + for (int i = 0; i < mTrackCount; ++i) { + readFinished = readFinished && mReadIoStates[i].isReadFinished(); + mWriteIoStates[i].closeWrite(); + } + if (readFinished) { + mIoCallback.onIoReachedEos(); + } + } + + private void doRelease(ConditionVariable conditionVariable) { + mIoHandler.removeCallbacksAndMessages(null); + mFinished = true; + conditionVariable.open(); + } + + private void releaseEvictedChunks() { + if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK) { + return; + } + for (int i = 0; i < mTrackCount; ++i) { + long evictEndPositionUs = Math.min(mBufferManager.getStartPositionUs(mIds.get(i)), + mReadIoStates[i].getStartPositionUs()); + mBufferManager.evictChunks(mIds.get(i), evictEndPositionUs); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java b/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java new file mode 100644 index 00000000..bb048e85 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java @@ -0,0 +1,71 @@ +/* + * 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.tuner.exoplayer.buffer; + +import com.google.android.exoplayer.SampleHolder; + +import java.util.LinkedList; + +/** + * Pool of samples to recycle ByteBuffers as much as possible. + */ +public class SamplePool { + private final LinkedList<SampleHolder> mSamplePool = new LinkedList<>(); + + /** + * Acquires a sample with a buffer larger than size from the pool. Allocate new one or resize + * an existing buffer if necessary. + */ + public synchronized SampleHolder acquireSample(int size) { + if (mSamplePool.isEmpty()) { + SampleHolder sample = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + sample.ensureSpaceForWrite(size); + return sample; + } + SampleHolder smallestSufficientSample = null; + SampleHolder maxSample = mSamplePool.getFirst(); + for (SampleHolder sample : mSamplePool) { + // Grab the smallest sufficient sample. + if (sample.data.capacity() >= size && (smallestSufficientSample == null + || smallestSufficientSample.data.capacity() > sample.data.capacity())) { + smallestSufficientSample = sample; + } + + // Grab the max size sample. + if (maxSample.data.capacity() < sample.data.capacity()) { + maxSample = sample; + } + } + SampleHolder sampleFromPool = smallestSufficientSample; + + // If there's no sufficient sample, grab the maximum sample and resize it to size. + if (sampleFromPool == null) { + sampleFromPool = maxSample; + sampleFromPool.ensureSpaceForWrite(size); + } + mSamplePool.remove(sampleFromPool); + return sampleFromPool; + } + + /** + * Releases the sample back to the pool. + */ + public synchronized void releaseSample(SampleHolder sample) { + sample.clearData(); + mSamplePool.offerLast(sample); + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java new file mode 100644 index 00000000..7b098f40 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java @@ -0,0 +1,74 @@ +/* + * 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.tuner.exoplayer.buffer; + +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; + +import java.util.LinkedList; + +/** + * A sample queue which reads from the buffer and passes to player pipeline. + */ +public class SampleQueue { + private final LinkedList<SampleHolder> mQueue = new LinkedList<>(); + private final SamplePool mSamplePool; + private Long mLastQueuedPositionUs = null; + + public SampleQueue(SamplePool samplePool) { + mSamplePool = samplePool; + } + + public void queueSample(SampleHolder sample) { + mQueue.offer(sample); + mLastQueuedPositionUs = sample.timeUs; + } + + public int dequeueSample(SampleHolder sample) { + SampleHolder sampleFromQueue = mQueue.poll(); + if (sampleFromQueue == null) { + return SampleSource.NOTHING_READ; + } + sample.size = sampleFromQueue.size; + sample.flags = sampleFromQueue.flags; + sample.timeUs = sampleFromQueue.timeUs; + sample.clearData(); + sampleFromQueue.data.position(0).limit(sample.size); + sample.data.put(sampleFromQueue.data); + mSamplePool.releaseSample(sampleFromQueue); + return SampleSource.SAMPLE_READ; + } + + public void clear() { + while (!mQueue.isEmpty()) { + mSamplePool.releaseSample(mQueue.poll()); + } + mLastQueuedPositionUs = null; + } + + public Long getLastQueuedPositionUs() { + return mLastQueuedPositionUs; + } + + public boolean isDurationGreaterThan(long durationUs) { + return !mQueue.isEmpty() && mQueue.getLast().timeUs - mQueue.getFirst().timeUs > durationUs; + } + + public boolean isEmpty() { + return mQueue.isEmpty(); + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java new file mode 100644 index 00000000..40c4ef95 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java @@ -0,0 +1,180 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.os.ConditionVariable; + +import android.support.annotation.NonNull; +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; +import com.google.android.exoplayer.SampleSource; +import com.android.tv.tuner.tvinput.PlaybackBufferListener; +import com.android.tv.tuner.exoplayer.SampleExtractor; + +import java.io.IOException; +import java.util.List; + +import junit.framework.Assert; + +/** + * Handles I/O for {@link SampleExtractor} when + * physical storage based buffer is not used. Trickplay is disabled. + */ +public class SimpleSampleBuffer implements BufferManager.SampleBuffer { + private final SamplePool mSamplePool = new SamplePool(); + private SampleQueue[] mPlayingSampleQueues; + private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US; + + private volatile boolean mEos; + + public SimpleSampleBuffer(PlaybackBufferListener bufferListener) { + if (bufferListener != null) { + // Disables trickplay. + bufferListener.onBufferStateChanged(false); + } + } + + @Override + public synchronized void init(@NonNull List<String> ids, + @NonNull List<MediaFormat> mediaFormats) { + int trackCount = ids.size(); + mPlayingSampleQueues = new SampleQueue[trackCount]; + for (int i = 0; i < trackCount; i++) { + mPlayingSampleQueues[i] = null; + } + } + + @Override + public void setEos() { + mEos = true; + } + + private boolean reachedEos() { + return mEos; + } + + @Override + public void selectTrack(int index) { + synchronized (this) { + if (mPlayingSampleQueues[index] == null) { + mPlayingSampleQueues[index] = new SampleQueue(mSamplePool); + } else { + mPlayingSampleQueues[index].clear(); + } + } + } + + @Override + public void deselectTrack(int index) { + synchronized (this) { + if (mPlayingSampleQueues[index] != null) { + mPlayingSampleQueues[index].clear(); + mPlayingSampleQueues[index] = null; + } + } + } + + @Override + public synchronized long getBufferedPositionUs() { + Long result = null; + for (SampleQueue queue : mPlayingSampleQueues) { + if (queue == null) { + continue; + } + Long lastQueuedSamplePositionUs = queue.getLastQueuedPositionUs(); + if (lastQueuedSamplePositionUs == null) { + // No sample has been queued. + result = mLastBufferedPositionUs; + continue; + } + if (result == null || result > lastQueuedSamplePositionUs) { + result = lastQueuedSamplePositionUs; + } + } + if (result == null) { + return mLastBufferedPositionUs; + } + return (mLastBufferedPositionUs = result); + } + + @Override + public synchronized int readSample(int track, SampleHolder sampleHolder) { + SampleQueue queue = mPlayingSampleQueues[track]; + Assert.assertNotNull(queue); + int result = queue.dequeueSample(sampleHolder); + if (result != SampleSource.SAMPLE_READ && reachedEos()) { + return SampleSource.END_OF_STREAM; + } + return result; + } + + @Override + public void writeSample(int index, SampleHolder sample, + ConditionVariable conditionVariable) throws IOException { + sample.data.position(0).limit(sample.size); + SampleHolder sampleToQueue = mSamplePool.acquireSample(sample.size); + sampleToQueue.size = sample.size; + sampleToQueue.clearData(); + sampleToQueue.data.put(sample.data); + sampleToQueue.timeUs = sample.timeUs; + sampleToQueue.flags = sample.flags; + + synchronized (this) { + if (mPlayingSampleQueues[index] != null) { + mPlayingSampleQueues[index].queueSample(sampleToQueue); + } + } + } + + @Override + public boolean isWriteSpeedSlow(int sampleSize, long durationNs) { + // Since SimpleSampleBuffer write samples only to memory (not to physical storage), + // write speed is always fine. + return false; + } + + @Override + public void handleWriteSpeedSlow() { + // no-op + } + + @Override + public synchronized boolean continueBuffering(long positionUs) { + for (SampleQueue queue : mPlayingSampleQueues) { + if (queue == null) { + continue; + } + if (queue.getLastQueuedPositionUs() == null + || positionUs > queue.getLastQueuedPositionUs()) { + // No more buffered data. + return false; + } + } + return true; + } + + @Override + public void seekTo(long positionUs) { + // Not used. + } + + @Override + public void release() { + // Not used. + } +} diff --git a/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java new file mode 100644 index 00000000..258a5cd0 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java @@ -0,0 +1,128 @@ +/* + * 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.tuner.exoplayer.buffer; + +import android.content.Context; +import android.media.MediaFormat; +import android.os.AsyncTask; +import android.os.Looper; +import android.provider.Settings; +import android.util.Pair; + +import java.io.File; +import java.util.ArrayList; +import java.util.SortedMap; + +/** + * Manages Trickplay storage. + */ +public class TrickplayStorageManager implements BufferManager.StorageManager { + private static final String BUFFER_DIR = "timeshift"; + + // Copied from android.provider.Settings.Global (hidden fields) + private static final String + SYS_STORAGE_THRESHOLD_PERCENTAGE = "sys_storage_threshold_percentage"; + private static final String + SYS_STORAGE_THRESHOLD_MAX_BYTES = "sys_storage_threshold_max_bytes"; + + // Copied from android.os.StorageManager + private static final int DEFAULT_THRESHOLD_PERCENTAGE = 10; + private static final long DEFAULT_THRESHOLD_MAX_BYTES = 500L * 1024 * 1024; + + private final File mBufferDir; + private final long mMaxBufferSize; + private final long mStorageBufferBytes; + + private static long getStorageBufferBytes(Context context, File path) { + long lowPercentage = Settings.Global.getInt(context.getContentResolver(), + SYS_STORAGE_THRESHOLD_PERCENTAGE, DEFAULT_THRESHOLD_PERCENTAGE); + long lowBytes = path.getTotalSpace() * lowPercentage / 100; + long maxLowBytes = Settings.Global.getLong(context.getContentResolver(), + SYS_STORAGE_THRESHOLD_MAX_BYTES, DEFAULT_THRESHOLD_MAX_BYTES); + return Math.min(lowBytes, maxLowBytes); + } + + public TrickplayStorageManager(Context context, File baseDir, long maxBufferSize) { + mBufferDir = new File(baseDir, BUFFER_DIR); + mBufferDir.mkdirs(); + mMaxBufferSize = maxBufferSize; + clearStorage(); + mStorageBufferBytes = getStorageBufferBytes(context, mBufferDir); + } + + @Override + public void clearStorage() { + File files[] = mBufferDir.listFiles(); + if (files == null || files.length == 0) { + return; + } + if (Looper.myLooper() == Looper.getMainLooper()) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + for (File file : files) { + file.delete(); + } + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + for (File file : files) { + file.delete(); + } + } + } + + @Override + public File getBufferDir() { + return mBufferDir; + } + + @Override + public boolean isPersistent() { + return false; + } + + @Override + public boolean reachedStorageMax(long bufferSize, long pendingDelete) { + return bufferSize - pendingDelete > mMaxBufferSize; + } + + @Override + public boolean hasEnoughBuffer(long pendingDelete) { + return mBufferDir.getUsableSpace() + pendingDelete >= mStorageBufferBytes; + } + + @Override + public Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) { + return null; + } + + @Override + public ArrayList<Long> readIndexFile(String trackId) { + return null; + } + + @Override + public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) { + } + + @Override + public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) { + } + +} diff --git a/src/com/android/tv/tuner/layout/ScaledLayout.java b/src/com/android/tv/tuner/layout/ScaledLayout.java new file mode 100644 index 00000000..379ea70e --- /dev/null +++ b/src/com/android/tv/tuner/layout/ScaledLayout.java @@ -0,0 +1,274 @@ +/* + * 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.tuner.layout; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Point; +import android.graphics.Rect; +import android.hardware.display.DisplayManager; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Display; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.tuner.R; + +import java.util.Arrays; +import java.util.Comparator; + +/** + * A layout that scales its children using the given percentage value. + */ +public class ScaledLayout extends ViewGroup { + private static final String TAG = "ScaledLayout"; + private static final boolean DEBUG = false; + private static final Comparator<Rect> mRectTopLeftSorter = new Comparator<Rect>() { + @Override + public int compare(Rect lhs, Rect rhs) { + if (lhs.top != rhs.top) { + return lhs.top - rhs.top; + } else { + return lhs.left - rhs.left; + } + } + }; + + private Rect[] mRectArray; + private final int mMaxWidth; + private final int mMaxHeight; + + public ScaledLayout(Context context) { + this(context, null); + } + + public ScaledLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ScaledLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + Point size = new Point(); + DisplayManager displayManager = (DisplayManager) getContext() + .getSystemService(Context.DISPLAY_SERVICE); + Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + display.getRealSize(size); + mMaxWidth = size.x; + mMaxHeight = size.y; + } + + /** + * ScaledLayoutParams stores the four scale factors. + * <br> + * Vertical coordinate system: ({@code scaleStartRow} * 100) % ~ ({@code scaleEndRow} * 100) % + * Horizontal coordinate system: ({@code scaleStartCol} * 100) % ~ ({@code scaleEndCol} * 100) % + * <br> + * In XML, for example, + * <pre> + * {@code + * <View + * app:layout_scaleStartRow="0.1" + * app:layout_scaleEndRow="0.5" + * app:layout_scaleStartCol="0.4" + * app:layout_scaleEndCol="1" /> + * } + * </pre> + */ + public static class ScaledLayoutParams extends ViewGroup.LayoutParams { + public static final float SCALE_UNSPECIFIED = -1; + public final float scaleStartRow; + public final float scaleEndRow; + public final float scaleStartCol; + public final float scaleEndCol; + + public ScaledLayoutParams(float scaleStartRow, float scaleEndRow, + float scaleStartCol, float scaleEndCol) { + super(MATCH_PARENT, MATCH_PARENT); + this.scaleStartRow = scaleStartRow; + this.scaleEndRow = scaleEndRow; + this.scaleStartCol = scaleStartCol; + this.scaleEndCol = scaleEndCol; + } + + public ScaledLayoutParams(Context context, AttributeSet attrs) { + super(MATCH_PARENT, MATCH_PARENT); + TypedArray array = + context.obtainStyledAttributes(attrs, R.styleable.utScaledLayout); + scaleStartRow = + array.getFloat(R.styleable.utScaledLayout_layout_scaleStartRow, SCALE_UNSPECIFIED); + scaleEndRow = + array.getFloat(R.styleable.utScaledLayout_layout_scaleEndRow, SCALE_UNSPECIFIED); + scaleStartCol = + array.getFloat(R.styleable.utScaledLayout_layout_scaleStartCol, SCALE_UNSPECIFIED); + scaleEndCol = + array.getFloat(R.styleable.utScaledLayout_layout_scaleEndCol, SCALE_UNSPECIFIED); + array.recycle(); + } + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new ScaledLayoutParams(getContext(), attrs); + } + + @Override + protected boolean checkLayoutParams(LayoutParams p) { + return (p instanceof ScaledLayoutParams); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); + int width = widthSpecSize - getPaddingLeft() - getPaddingRight(); + int height = heightSpecSize - getPaddingTop() - getPaddingBottom(); + if (DEBUG) { + Log.d(TAG, String.format("onMeasure width: %d, height: %d", width, height)); + } + int count = getChildCount(); + mRectArray = new Rect[count]; + for (int i = 0; i < count; ++i) { + View child = getChildAt(i); + ViewGroup.LayoutParams params = child.getLayoutParams(); + float scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol; + if (!(params instanceof ScaledLayoutParams)) { + throw new RuntimeException( + "A child of ScaledLayout cannot have the UNSPECIFIED scale factors"); + } + scaleStartRow = ((ScaledLayoutParams) params).scaleStartRow; + scaleEndRow = ((ScaledLayoutParams) params).scaleEndRow; + scaleStartCol = ((ScaledLayoutParams) params).scaleStartCol; + scaleEndCol = ((ScaledLayoutParams) params).scaleEndCol; + if (scaleStartRow < 0 || scaleStartRow > 1) { + throw new RuntimeException("A child of ScaledLayout should have a range of " + + "scaleStartRow between 0 and 1"); + } + if (scaleEndRow < scaleStartRow || scaleStartRow > 1) { + throw new RuntimeException("A child of ScaledLayout should have a range of " + + "scaleEndRow between scaleStartRow and 1"); + } + if (scaleEndCol < 0 || scaleEndCol > 1) { + throw new RuntimeException("A child of ScaledLayout should have a range of " + + "scaleStartCol between 0 and 1"); + } + if (scaleEndCol < scaleStartCol || scaleEndCol > 1) { + throw new RuntimeException("A child of ScaledLayout should have a range of " + + "scaleEndCol between scaleStartCol and 1"); + } + if (DEBUG) { + Log.d(TAG, String.format("onMeasure child scaleStartRow: %f scaleEndRow: %f " + + "scaleStartCol: %f scaleEndCol: %f", + scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol)); + } + mRectArray[i] = new Rect((int) (scaleStartCol * width), (int) (scaleStartRow * height), + (int) (scaleEndCol * width), (int) (scaleEndRow * height)); + int scaleWidth = (int) (width * (scaleEndCol - scaleStartCol)); + int childWidthSpec = MeasureSpec.makeMeasureSpec( + scaleWidth > mMaxWidth ? mMaxWidth : scaleWidth, MeasureSpec.EXACTLY); + int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + child.measure(childWidthSpec, childHeightSpec); + + // If the height of the measured child view is bigger than the height of the calculated + // region by the given ScaleLayoutParams, the height of the region should be increased + // to fit the size of the child view. + if (child.getMeasuredHeight() > mRectArray[i].height()) { + int overflowedHeight = child.getMeasuredHeight() - mRectArray[i].height(); + overflowedHeight = (overflowedHeight + 1) / 2; + mRectArray[i].bottom += overflowedHeight; + mRectArray[i].top -= overflowedHeight; + if (mRectArray[i].top < 0) { + mRectArray[i].bottom -= mRectArray[i].top; + mRectArray[i].top = 0; + } + if (mRectArray[i].bottom > height) { + mRectArray[i].top -= mRectArray[i].bottom - height; + mRectArray[i].bottom = height; + } + } + int scaleHeight = (int) (height * (scaleEndRow - scaleStartRow)); + childHeightSpec = MeasureSpec.makeMeasureSpec( + scaleHeight > mMaxHeight ? mMaxHeight : scaleHeight, MeasureSpec.EXACTLY); + child.measure(childWidthSpec, childHeightSpec); + } + + // Avoid overlapping rectangles. + // Step 1. Sort rectangles by position (top-left). + int visibleRectCount = 0; + int[] visibleRectGroup = new int[count]; + Rect[] visibleRectArray = new Rect[count]; + for (int i = 0; i < count; ++i) { + if (getChildAt(i).getVisibility() == View.VISIBLE) { + visibleRectGroup[visibleRectCount] = visibleRectCount; + visibleRectArray[visibleRectCount] = mRectArray[i]; + ++visibleRectCount; + } + } + Arrays.sort(visibleRectArray, 0, visibleRectCount, mRectTopLeftSorter); + + // Step 2. Move down if there are overlapping rectangles. + for (int i = 0; i < visibleRectCount - 1; ++i) { + for (int j = i + 1; j < visibleRectCount; ++j) { + if (Rect.intersects(visibleRectArray[i], visibleRectArray[j])) { + visibleRectGroup[j] = visibleRectGroup[i]; + visibleRectArray[j].set(visibleRectArray[j].left, + visibleRectArray[i].bottom, + visibleRectArray[j].right, + visibleRectArray[i].bottom + visibleRectArray[j].height()); + } + } + } + + // Step 3. Move up if there is any overflowed rectangle. + for (int i = visibleRectCount - 1; i >= 0; --i) { + if (visibleRectArray[i].bottom > height) { + int overflowedHeight = visibleRectArray[i].bottom - height; + for (int j = 0; j <= i; ++j) { + if (visibleRectGroup[i] == visibleRectGroup[j]) { + visibleRectArray[j].set(visibleRectArray[j].left, + visibleRectArray[j].top - overflowedHeight, + visibleRectArray[j].right, + visibleRectArray[j].bottom - overflowedHeight); + } + } + } + } + setMeasuredDimension(widthSpecSize, heightSpecSize); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + int paddingLeft = getPaddingLeft(); + int paddingTop = getPaddingTop(); + int count = getChildCount(); + for (int i = 0; i < count; ++i) { + View child = getChildAt(i); + if (child.getVisibility() != GONE) { + int childLeft = paddingLeft + mRectArray[i].left; + int childTop = paddingTop + mRectArray[i].top; + int childBottom = paddingLeft + mRectArray[i].bottom; + int childRight = paddingTop + mRectArray[i].right; + if (DEBUG) { + Log.d(TAG, String.format("layoutChild bottom: %d left: %d right: %d top: %d", + childBottom, childLeft, + childRight, childTop)); + } + child.layout(childLeft, childTop, childRight, childBottom); + } + } + } +} diff --git a/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java new file mode 100644 index 00000000..97d9ece3 --- /dev/null +++ b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java @@ -0,0 +1,82 @@ +/* + * 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.tuner.setup; + +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.common.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.R; + +import java.util.List; +import java.util.TimeZone; + +/** + * A fragment for connection type selection. + */ +public class ConnectionTypeFragment extends SetupMultiPaneFragment { + public static final String ACTION_CATEGORY = + "com.android.tv.tuner.setup.ConnectionTypeFragment"; + + @Override + protected SetupGuidedStepFragment onCreateContentFragment() { + return new ContentFragment(); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return false; + } + + public static class ContentFragment extends SetupGuidedStepFragment { + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + return new Guidance(getString(R.string.ut_connection_title), + getString(R.string.ut_connection_description), + getString(R.string.ut_setup_breadcrumb), null); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, + Bundle savedInstanceState) { + String[] choices = getResources().getStringArray(R.array.ut_connection_choices); + int length = choices.length - 1; + int startOffset = 0; + for (int i = 0; i < length; ++i) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(startOffset + i) + .title(choices[i]) + .build()); + } + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + } +} diff --git a/src/com/android/tv/tuner/setup/ScanFragment.java b/src/com/android/tv/tuner/setup/ScanFragment.java new file mode 100644 index 00000000..4b3ffe40 --- /dev/null +++ b/src/com/android/tv/tuner/setup/ScanFragment.java @@ -0,0 +1,505 @@ +/* + * 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.tuner.setup; + +import android.animation.LayoutTransition; +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.ConditionVariable; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.android.tv.common.AutoCloseableUtils; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.ui.setup.SetupFragment; +import com.android.tv.tuner.ChannelScanFileParser; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.data.Channel; +import com.android.tv.tuner.data.PsipData; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.source.FileTsStreamer; +import com.android.tv.tuner.source.TsDataSource; +import com.android.tv.tuner.source.TsStreamer; +import com.android.tv.tuner.source.TunerTsStreamer; +import com.android.tv.tuner.tvinput.ChannelDataManager; +import com.android.tv.tuner.tvinput.EventDetector; +import com.android.tv.tuner.util.TunerInputInfoUtils; + +import junit.framework.Assert; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * A fragment for scanning channels. + */ +public class ScanFragment extends SetupFragment { + private static final String TAG = "ScanFragment"; + private static final boolean DEBUG = false; + // In the fake mode, the connection to antenna or cable is not necessary. + // Instead dummy channels are added. + private static final boolean FAKE_MODE = false; + + private static final String VCTLESS_CHANNEL_NAME_FORMAT = "RF%d-%d"; + + public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.ScanFragment"; + public static final int ACTION_CANCEL = 1; + public static final int ACTION_FINISH = 2; + + public static final String EXTRA_FOR_CHANNEL_SCAN_FILE = "scan_file_choice"; + + private static final long CHANNEL_SCAN_SHOW_DELAY_MS = 10000; + private static final long CHANNEL_SCAN_PERIOD_MS = 4000; + private static final long SHOW_PROGRESS_DIALOG_DELAY_MS = 300; + + // Build channels out of the locally stored TS streams. + private static final boolean SCAN_LOCAL_STREAMS = true; + + private ChannelDataManager mChannelDataManager; + private ChannelScanTask mChannelScanTask; + private ProgressBar mProgressBar; + private TextView mScanningMessage; + private View mChannelHolder; + private ChannelAdapter mAdapter; + private volatile boolean mChannelListVisible; + private Button mCancelButton; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + mChannelDataManager = new ChannelDataManager(getActivity()); + mChannelDataManager.checkDataVersion(getActivity()); + mAdapter = new ChannelAdapter(); + mProgressBar = (ProgressBar) view.findViewById(R.id.tune_progress); + mScanningMessage = (TextView) view.findViewById(R.id.tune_description); + ListView channelList = (ListView) view.findViewById(R.id.channel_list); + channelList.setAdapter(mAdapter); + channelList.setOnItemClickListener(null); + ViewGroup progressHolder = (ViewGroup) view.findViewById(R.id.progress_holder); + LayoutTransition transition = new LayoutTransition(); + transition.enableTransitionType(LayoutTransition.CHANGING); + progressHolder.setLayoutTransition(transition); + mChannelHolder = view.findViewById(R.id.channel_holder); + mCancelButton = (Button) view.findViewById(R.id.tune_cancel); + mCancelButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + finishScan(false); + } + }); + Bundle args = getArguments(); + // TODO: Handle the case when the fragment is restored. + startScan(args == null ? 0 : args.getInt(EXTRA_FOR_CHANNEL_SCAN_FILE, 0)); + TextView scanTitleView = (TextView) view.findViewById(R.id.tune_title); + if (TunerInputInfoUtils.isBuiltInTuner(getActivity())){ + scanTitleView.setText(R.string.bt_channel_scan); + } else { + scanTitleView.setText(R.string.ut_channel_scan); + } + return view; + } + + @Override + protected int getLayoutResourceId() { + return R.layout.ut_channel_scan; + } + + @Override + protected int[] getParentIdsForDelay() { + return new int[] {R.id.progress_holder}; + } + + private void startScan(int channelMapId) { + mChannelScanTask = new ChannelScanTask(channelMapId); + mChannelScanTask.execute(); + } + + @Override + public void onDetach() { + if (mChannelScanTask != null) { + // Ensure scan task will stop. + mChannelScanTask.stopScan(); + } + super.onDetach(); + } + + /** + * Finishes the current scan thread. This fragment will be popped after the scan thread ends. + * + * @param cancel a flag which indicates the scan is canceled or not. + */ + public void finishScan(boolean cancel) { + if (mChannelScanTask != null) { + mChannelScanTask.cancelScan(cancel); + + // Notifies a user of waiting to finish the scanning process. + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + mChannelScanTask.showFinishingProgressDialog(); + } + }, SHOW_PROGRESS_DIALOG_DELAY_MS); + + // Hides the cancel button. + mCancelButton.setEnabled(false); + } + } + + private class ChannelAdapter extends BaseAdapter { + private final ArrayList<TunerChannel> mChannels; + + public ChannelAdapter() { + mChannels = new ArrayList<>(); + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int pos) { + return false; + } + + @Override + public int getCount() { + return mChannels.size(); + } + + @Override + public Object getItem(int pos) { + return pos; + } + + @Override + public long getItemId(int pos) { + return pos; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final Context context = parent.getContext(); + + if (convertView == null) { + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView = inflater.inflate(R.layout.ut_channel_list, parent, false); + } + + TextView channelNum = (TextView) convertView.findViewById(R.id.channel_num); + channelNum.setText(mChannels.get(position).getDisplayNumber()); + + TextView channelName = (TextView) convertView.findViewById(R.id.channel_name); + channelName.setText(mChannels.get(position).getName()); + return convertView; + } + + public void add(TunerChannel channel) { + mChannels.add(channel); + notifyDataSetChanged(); + } + } + + private class ChannelScanTask extends AsyncTask<Void, Integer, Void> + implements EventDetector.EventListener, ChannelDataManager.ChannelScanListener { + private static final int MAX_PROGRESS = 100; + + private final Activity mActivity; + private final int mChannelMapId; + private final TsStreamer mScanTsStreamer; + private final TsStreamer mFileTsStreamer; + private final ConditionVariable mConditionStopped; + + private final List<ChannelScanFileParser.ScanChannel> mScanChannelList = new ArrayList<>(); + private boolean mIsCanceled; + private boolean mIsFinished; + private ProgressDialog mFinishingProgressDialog; + private CountDownLatch mLatch; + + public ChannelScanTask(int channelMapId) { + mActivity = getActivity(); + mChannelMapId = channelMapId; + if (FAKE_MODE) { + mScanTsStreamer = new FakeTsStreamer(this); + } else { + TunerHal hal = TunerHal.createInstance(mActivity.getApplicationContext()); + if (hal == null) { + throw new RuntimeException("Failed to open a DVB device"); + } + mScanTsStreamer = new TunerTsStreamer(hal, this); + } + mFileTsStreamer = SCAN_LOCAL_STREAMS ? new FileTsStreamer(this) : null; + mConditionStopped = new ConditionVariable(); + mChannelDataManager.setChannelScanListener(this, new Handler()); + } + + private void maybeSetChannelListVisible() { + mActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + int channelsFound = mAdapter.getCount(); + if (!mChannelListVisible && channelsFound > 0) { + String format = getResources().getQuantityString( + R.plurals.ut_channel_scan_message, channelsFound, channelsFound); + mScanningMessage.setText(String.format(format, channelsFound)); + mChannelHolder.setVisibility(View.VISIBLE); + mChannelListVisible = true; + } + } + }); + } + + private void addChannel(final TunerChannel channel) { + mActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + mAdapter.add(channel); + if (mChannelListVisible) { + int channelsFound = mAdapter.getCount(); + String format = getResources().getQuantityString( + R.plurals.ut_channel_scan_message, channelsFound, channelsFound); + mScanningMessage.setText(String.format(format, channelsFound)); + } + } + }); + } + + @Override + protected Void doInBackground(Void... params) { + mScanChannelList.clear(); + if (SCAN_LOCAL_STREAMS) { + FileTsStreamer.addLocalStreamFiles(mScanChannelList); + } + mScanChannelList.addAll(ChannelScanFileParser.parseScanFile( + getResources().openRawResource(mChannelMapId))); + scanChannels(); + return null; + } + + @Override + protected void onCancelled() { + SoftPreconditions.checkState(false, TAG, "call cancelScan instead of cancel"); + } + + @Override + protected void onProgressUpdate(Integer... values) { + mProgressBar.setProgress(values[0]); + } + + private void stopScan() { + mConditionStopped.open(); + } + + private void cancelScan(boolean cancel) { + mIsCanceled = cancel; + stopScan(); + } + + private void scanChannels() { + if (DEBUG) Log.i(TAG, "Channel scan starting"); + mChannelDataManager.notifyScanStarted(); + + long startMs = System.currentTimeMillis(); + int i = 1; + for (ChannelScanFileParser.ScanChannel scanChannel : mScanChannelList) { + int frequency = scanChannel.frequency; + String modulation = scanChannel.modulation; + Log.i(TAG, "Tuning to " + frequency + " " + modulation); + + TsStreamer streamer = getStreamer(scanChannel.type); + Assert.assertNotNull(streamer); + if (streamer.startStream(scanChannel)) { + mLatch = new CountDownLatch(1); + try { + mLatch.await(CHANNEL_SCAN_PERIOD_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Log.e(TAG, "The current thread is interrupted during scanChannels(). " + + "The TS stream is stopped earlier than expected.", e); + } + streamer.stopStream(); + + addChannelsWithoutVct(scanChannel); + if (System.currentTimeMillis() > startMs + CHANNEL_SCAN_SHOW_DELAY_MS + && !mChannelListVisible) { + maybeSetChannelListVisible(); + } + } + if (mConditionStopped.block(-1)) { + break; + } + onProgressUpdate(MAX_PROGRESS * i++ / mScanChannelList.size()); + } + if (mScanTsStreamer instanceof TunerTsStreamer) { + AutoCloseableUtils.closeQuietly( + ((TunerTsStreamer) mScanTsStreamer).getTunerHal()); + } + mChannelDataManager.notifyScanCompleted(); + if (!mConditionStopped.block(-1)) { + publishProgress(MAX_PROGRESS); + } + if (DEBUG) Log.i(TAG, "Channel scan ended"); + } + + + private void addChannelsWithoutVct(ChannelScanFileParser.ScanChannel scanChannel) { + if (scanChannel.radioFrequencyNumber == null + || !(mScanTsStreamer instanceof TunerTsStreamer)) { + return; + } + for (TunerChannel tunerChannel + : ((TunerTsStreamer) mScanTsStreamer).getMalFormedChannels()) { + if ((tunerChannel.getVideoPid() != TunerChannel.INVALID_PID) + && (tunerChannel.getAudioPid() != TunerChannel.INVALID_PID)) { + tunerChannel.setFrequency(scanChannel.frequency); + tunerChannel.setModulation(scanChannel.modulation); + tunerChannel.setShortName(String.format(Locale.US, VCTLESS_CHANNEL_NAME_FORMAT, + scanChannel.radioFrequencyNumber, + tunerChannel.getProgramNumber())); + tunerChannel.setVirtualMajor(scanChannel.radioFrequencyNumber); + tunerChannel.setVirtualMinor(tunerChannel.getProgramNumber()); + onChannelDetected(tunerChannel, true); + } + } + } + + private TsStreamer getStreamer(int type) { + switch (type) { + case Channel.TYPE_TUNER: + return mScanTsStreamer; + case Channel.TYPE_FILE: + return mFileTsStreamer; + default: + return null; + } + } + + @Override + public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) { + mChannelDataManager.notifyEventDetected(channel, items); + } + + @Override + public void onChannelScanDone() { + if (mLatch != null) { + mLatch.countDown(); + } + } + + @Override + public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + if (channelArrivedAtFirstTime) { + Log.i(TAG, "Found channel " + channel); + } + if (channelArrivedAtFirstTime && channel.hasAudio()) { + // Playbacks with video-only stream have not been tested yet. + // No video-only channel has been found. + addChannel(channel); + } + mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); + } + + public void showFinishingProgressDialog() { + // Show a progress dialog to wait for the scanning process if it's not done yet. + if (!mIsFinished && mFinishingProgressDialog == null) { + mFinishingProgressDialog = ProgressDialog.show(mActivity, "", + getString(R.string.ut_setup_cancel), true, false); + } + } + + @Override + public void onChannelHandlingDone() { + mChannelDataManager.setCurrentVersion(mActivity); + mChannelDataManager.releaseSafely(); + mIsFinished = true; + TunerPreferences.setScannedChannelCount(mActivity.getApplicationContext(), + mChannelDataManager.getScannedChannelCount()); + // Cancel a previously shown recommendation card. + TunerSetupActivity.cancelRecommendationCard(mActivity.getApplicationContext()); + // Mark scan as done + TunerPreferences.setScanDone(mActivity.getApplicationContext()); + // finishing will be done manually. + if (mFinishingProgressDialog != null) { + mFinishingProgressDialog.dismiss(); + } + onActionClick(ACTION_CATEGORY, mIsCanceled ? ACTION_CANCEL : ACTION_FINISH); + mChannelScanTask = null; + } + } + + private static class FakeTsStreamer implements TsStreamer { + private final EventDetector.EventListener mEventListener; + private int mProgramNumber = 0; + + FakeTsStreamer(EventDetector.EventListener eventListener) { + mEventListener = eventListener; + } + + @Override + public boolean startStream(ChannelScanFileParser.ScanChannel channel) { + if (++mProgramNumber % 2 == 1) { + return true; + } + final String displayNumber = Integer.toString(mProgramNumber); + final String name = "Channel-" + mProgramNumber; + mEventListener.onChannelDetected(new TunerChannel(mProgramNumber, new ArrayList<>()) { + @Override + public String getDisplayNumber() { + return displayNumber; + } + + @Override + public String getName() { + return name; + } + }, true); + return true; + } + + @Override + public boolean startStream(TunerChannel channel) { + return false; + } + + @Override + public void stopStream() { + } + + @Override + public TsDataSource createDataSource() { + return null; + } + } +} diff --git a/src/com/android/tv/tuner/setup/ScanResultFragment.java b/src/com/android/tv/tuner/setup/ScanResultFragment.java new file mode 100644 index 00000000..068543cd --- /dev/null +++ b/src/com/android/tv/tuner/setup/ScanResultFragment.java @@ -0,0 +1,118 @@ +/* + * 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.tuner.setup; + +import android.content.Context; +import android.content.res.Resources; +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.common.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.util.TunerInputInfoUtils; + +import java.util.List; + +/** + * A fragment for initial screen. + */ +public class ScanResultFragment extends SetupMultiPaneFragment { + public static final String ACTION_CATEGORY = + "com.android.tv.tuner.setup.ScanResultFragment"; + + @Override + protected SetupGuidedStepFragment onCreateContentFragment() { + return new ContentFragment(); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return false; + } + + public static class ContentFragment extends SetupGuidedStepFragment { + private int mChannelCountOnPreference; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mChannelCountOnPreference = TunerPreferences.getScannedChannelCount(context); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title; + String description; + String breadcrumb; + if (mChannelCountOnPreference > 0) { + Resources res = getResources(); + title = res.getQuantityString(R.plurals.ut_result_found_title, + mChannelCountOnPreference, mChannelCountOnPreference); + description = res.getQuantityString(R.plurals.ut_result_found_description, + mChannelCountOnPreference, mChannelCountOnPreference); + breadcrumb = null; + } else { + title = getString(R.string.ut_result_not_found_title); + if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) { + description = getString(R.string.bt_result_not_found_description); + } else { + description = getString(R.string.ut_result_not_found_description); + } + breadcrumb = getString(R.string.ut_setup_breadcrumb); + } + return new Guidance(title, description, breadcrumb, null); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, + Bundle savedInstanceState) { + String[] choices; + int doneActionIndex; + if (mChannelCountOnPreference > 0) { + choices = getResources().getStringArray(R.array.ut_result_found_choices); + doneActionIndex = 0; + } else { + choices = getResources().getStringArray(R.array.ut_result_not_found_choices); + doneActionIndex = 1; + } + for (int i = 0; i < choices.length; ++i) { + if (i == doneActionIndex) { + actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DONE) + .title(choices[i]).build()); + } else { + actions.add(new GuidedAction.Builder(getActivity()).id(i).title(choices[i]) + .build()); + } + } + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + } +} diff --git a/src/com/android/tv/tuner/setup/TunerSetupActivity.java b/src/com/android/tv/tuner/setup/TunerSetupActivity.java new file mode 100644 index 00000000..78121bc5 --- /dev/null +++ b/src/com/android/tv/tuner/setup/TunerSetupActivity.java @@ -0,0 +1,282 @@ +/* + * 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.tuner.setup; + +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.tv.TvContract; +import android.os.Bundle; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.view.KeyEvent; +import android.widget.Toast; + +import com.android.tv.TvApplication; +import com.android.tv.common.TvCommonConstants; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.common.ui.setup.SetupActivity; +import com.android.tv.common.ui.setup.SetupFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.TunerInputInfoUtils; + +/** + * An activity that serves tuner setup process. + */ +public class TunerSetupActivity extends SetupActivity { + private final String TAG = "TunerSetupActivity"; + // For the recommendation card + private static final String TV_ACTIVITY_CLASS_NAME = "com.android.tv.TvActivity"; + private static final String NOTIFY_TAG = "TunerSetup"; + private static final int NOTIFY_ID = 1000; + private static final String TAG_DRAWABLE = "drawable"; + private static final String TAG_ICON = "ic_launcher_s"; + + private static final int CHANNEL_MAP_SCAN_FILE[] = { + R.raw.ut_us_atsc_center_frequencies_8vsb, + R.raw.ut_us_cable_standard_center_frequencies_qam256, + R.raw.ut_us_all, + R.raw.ut_kr_atsc_center_frequencies_8vsb, + R.raw.ut_kr_cable_standard_center_frequencies_qam256, + R.raw.ut_kr_all, + R.raw.ut_kr_dev_cj_cable_center_frequencies_qam256}; + + private ScanFragment mLastScanFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + TvApplication.setCurrentRunningProcess(this, false); + super.onCreate(savedInstanceState); + // TODO: check {@link shouldShowRequestPermissionRationale}. + if (checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + // No need to check the request result. + requestPermissions(new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION}, + 0); + } + } + + @Override + protected Fragment onCreateInitialFragment() { + SetupFragment fragment = new WelcomeFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION + | SetupFragment.FRAGMENT_REENTER_TRANSITION); + return fragment; + } + + @Override + protected boolean executeAction(String category, int actionId, Bundle params) { + switch (category) { + case WelcomeFragment.ACTION_CATEGORY: + switch (actionId) { + case SetupMultiPaneFragment.ACTION_DONE: + // If the scan was performed, then the result should be OK. + setResult(mLastScanFragment == null ? RESULT_CANCELED : RESULT_OK); + finish(); + break; + default: { + SetupFragment fragment = new ConnectionTypeFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION + | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + break; + } + } + return true; + case ConnectionTypeFragment.ACTION_CATEGORY: + TunerHal hal = TunerHal.createInstance(getApplicationContext()); + if (hal == null) { + finish(); + Toast.makeText(getApplicationContext(), + R.string.ut_channel_scan_tuner_unavailable,Toast.LENGTH_LONG).show(); + return true; + } + try { + hal.close(); + } catch (Exception e) { + Log.e(TAG, "Tuner hal close failed", e); + return true; + } + mLastScanFragment = new ScanFragment(); + Bundle args = new Bundle(); + args.putInt(ScanFragment.EXTRA_FOR_CHANNEL_SCAN_FILE, + CHANNEL_MAP_SCAN_FILE[actionId]); + mLastScanFragment.setArguments(args); + showFragment(mLastScanFragment, true); + return true; + case ScanFragment.ACTION_CATEGORY: + switch (actionId) { + case ScanFragment.ACTION_CANCEL: + getFragmentManager().popBackStack(); + return true; + case ScanFragment.ACTION_FINISH: + SetupFragment fragment = new ScanResultFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION + | SetupFragment.FRAGMENT_REENTER_TRANSITION); + showFragment(fragment, true); + return true; + } + break; + case ScanResultFragment.ACTION_CATEGORY: + switch (actionId) { + case SetupMultiPaneFragment.ACTION_DONE: + setResult(RESULT_OK); + finish(); + break; + default: + SetupFragment fragment = new ConnectionTypeFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION + | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + break; + } + return true; + } + return false; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + FragmentManager manager = getFragmentManager(); + int count = manager.getBackStackEntryCount(); + if (count > 0) { + String lastTag = manager.getBackStackEntryAt(count - 1).getName(); + if (ScanResultFragment.class.getCanonicalName().equals(lastTag) && count >= 2) { + // Pops fragment including ScanFragment. + manager.popBackStack(manager.getBackStackEntryAt(count - 2).getName(), + FragmentManager.POP_BACK_STACK_INCLUSIVE); + return true; + } else if (ScanFragment.class.getCanonicalName().equals(lastTag)) { + mLastScanFragment.finishScan(true); + return true; + } + } + } + return super.onKeyUp(keyCode, event); + } + + /** + * A callback to be invoked when the TvInputService is enabled or disabled. + * + * @param context a {@link Context} instance + * @param enabled {@code true} for the {@link TunerTvInputService} to be enabled; + * otherwise {@code false} + */ + public static void onTvInputEnabled(Context context, boolean enabled) { + // Send a recommendation card for tuner setup if there's no channels and the tuner TV input + // setup has been not done. + boolean channelScanDoneOnPreference = TunerPreferences.isScanDone(context); + int channelCountOnPreference = TunerPreferences.getScannedChannelCount(context); + if (enabled && !channelScanDoneOnPreference && channelCountOnPreference == 0) { + TunerPreferences.setShouldShowSetupActivity(context, true); + sendRecommendationCard(context); + } else { + TunerPreferences.setShouldShowSetupActivity(context, false); + cancelRecommendationCard(context); + } + } + + /** + * Returns a {@link Intent} to launch the tuner TV input service. + * + * @param context a {@link Context} instance + */ + public static Intent createSetupActivity(Context context) { + String inputId = TvContract.buildInputId(new ComponentName(context.getPackageName(), + TunerTvInputService.class.getName())); + + // Make an intent to launch the setup activity of USB tuner TV input. + Intent intent = TvCommonUtils.createSetupIntent( + new Intent(context, TunerSetupActivity.class), inputId); + intent.putExtra(TvCommonConstants.EXTRA_INPUT_ID, inputId); + Intent tvActivityIntent = new Intent(); + tvActivityIntent.setComponent(new ComponentName(context, TV_ACTIVITY_CLASS_NAME)); + intent.putExtra(TvCommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION, tvActivityIntent); + return intent; + } + + /** + * Returns a {@link PendingIntent} to launch the tuner TV input service. + * + * @param context a {@link Context} instance + */ + private static PendingIntent createPendingIntentForSetupActivity(Context context) { + return PendingIntent.getActivity(context, 0, createSetupActivity(context), + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Sends the recommendation card to start the tuner TV input setup activity. + * + * @param context a {@link Context} instance + */ + private static void sendRecommendationCard(Context context) { + Resources resources = context.getResources(); + String focusedTitle = resources.getString( + R.string.ut_setup_recommendation_card_focused_title); + String title; + if (TunerInputInfoUtils.isBuiltInTuner(context)) { + title = resources.getString(R.string.bt_setup_recommendation_card_title); + } else { + title = resources.getString(R.string.ut_setup_recommendation_card_title); + } + Bitmap largeIcon = BitmapFactory.decodeResource(resources, + R.drawable.recommendation_antenna); + + // Build and send the notification. + Notification notification = new NotificationCompat.BigPictureStyle( + new NotificationCompat.Builder(context) + .setAutoCancel(false) + .setContentTitle(focusedTitle) + .setContentText(title) + .setContentInfo(title) + .setCategory(Notification.CATEGORY_RECOMMENDATION) + .setLargeIcon(largeIcon) + .setSmallIcon(resources.getIdentifier( + TAG_ICON, TAG_DRAWABLE, context.getPackageName())) + .setContentIntent(createPendingIntentForSetupActivity(context))) + .build(); + NotificationManager notificationManager = (NotificationManager) context + .getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification); + } + + /** + * Cancels the previously shown recommendation card. + * + * @param context a {@link Context} instance + */ + public static void cancelRecommendationCard(Context context) { + NotificationManager notificationManager = (NotificationManager) context + .getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NOTIFY_TAG, NOTIFY_ID); + } +} diff --git a/src/com/android/tv/tuner/setup/WelcomeFragment.java b/src/com/android/tv/tuner/setup/WelcomeFragment.java new file mode 100644 index 00000000..7e809411 --- /dev/null +++ b/src/com/android/tv/tuner/setup/WelcomeFragment.java @@ -0,0 +1,110 @@ +/* + * 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.tuner.setup; + +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.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.common.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.util.TunerInputInfoUtils; + +import java.util.List; + +/** + * A fragment for initial screen. + */ +public class WelcomeFragment extends SetupMultiPaneFragment { + public static final String ACTION_CATEGORY = + "com.android.tv.tuner.setup.WelcomeFragment"; + + @Override + protected SetupGuidedStepFragment onCreateContentFragment() { + return new ContentFragment(); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return false; + } + + public static class ContentFragment extends SetupGuidedStepFragment { + private int mChannelCountOnPreference; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + mChannelCountOnPreference = TunerPreferences + .getScannedChannelCount(getActivity().getApplicationContext()); + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title; + String description; + if (mChannelCountOnPreference == 0) { + if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) { + title = getString(R.string.bt_setup_new_title); + description = getString(R.string.bt_setup_new_description); + } else { + title = getString(R.string.ut_setup_new_title); + description = getString(R.string.ut_setup_new_description); + } + } else { + title = getString(R.string.bt_setup_again_title); + if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) { + description = getString(R.string.bt_setup_again_description); + } else { + description = getString(R.string.ut_setup_again_description); + } + } + return new Guidance(title, description, null, null); + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, + Bundle savedInstanceState) { + String[] choices = getResources().getStringArray(mChannelCountOnPreference == 0 + ? R.array.ut_setup_new_choices : R.array.ut_setup_again_choices); + for (int i = 0; i < choices.length - 1; ++i) { + actions.add(new GuidedAction.Builder(getActivity()).id(i).title(choices[i]) + .build()); + } + actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DONE) + .title(choices[choices.length - 1]).build()); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + } +} diff --git a/src/com/android/tv/tuner/source/FileTsStreamer.java b/src/com/android/tv/tuner/source/FileTsStreamer.java new file mode 100644 index 00000000..14997ee4 --- /dev/null +++ b/src/com/android/tv/tuner/source/FileTsStreamer.java @@ -0,0 +1,470 @@ +/* + * 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.tuner.source; + +import android.os.Environment; +import android.util.Log; +import android.util.SparseBooleanArray; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.upstream.DataSpec; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.ChannelScanFileParser.ScanChannel; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.ts.TsParser; +import com.android.tv.tuner.tvinput.EventDetector; +import com.android.tv.tuner.tvinput.FileSourceEventDetector; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Provides MPEG-2 TS stream sources for both channel scanning and channel playing from a local file + * generated by capturing TV signal. + */ +public class FileTsStreamer implements TsStreamer { + private static final String TAG = "FileTsStreamer"; + + private static final int TS_PACKET_SIZE = 188; + private static final int TS_SYNC_BYTE = 0x47; + private static final int MIN_READ_UNIT = TS_PACKET_SIZE * 10; + private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~20KB + private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 4000; // ~ 8MB + private static final int PADDING_SIZE = MIN_READ_UNIT * 1000; // ~2MB + private static final int READ_TIMEOUT_MS = 10000; // 10 secs. + private static final int BUFFER_UNDERRUN_SLEEP_MS = 10; + private static final String FILE_DIR = + new File(Environment.getExternalStorageDirectory(), "Streams").getAbsolutePath(); + + // Virtual frequency base used for file-based source + public static final int FREQ_BASE = 100; + + private final Object mCircularBufferMonitor = new Object(); + private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE]; + private final FileSourceEventDetector mEventDetector; + + private long mBytesFetched; + private long mLastReadPosition; + private boolean mStreaming; + + private Thread mStreamingThread; + private StreamProvider mSource; + + public static class FileDataSource extends TsDataSource { + private final FileTsStreamer mTsStreamer; + private final AtomicLong mLastReadPosition = new AtomicLong(0); + private long mStartBufferedPosition; + + private FileDataSource(FileTsStreamer tsStreamer) { + mTsStreamer = tsStreamer; + mStartBufferedPosition = tsStreamer.getBufferedPosition(); + } + + @Override + public long getBufferedPosition() { + return mTsStreamer.getBufferedPosition() - mStartBufferedPosition; + } + + @Override + public long getLastReadPosition() { + return mLastReadPosition.get(); + } + + @Override + public void shiftStartPosition(long offset) { + SoftPreconditions.checkState(mLastReadPosition.get() == 0); + SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition()); + mStartBufferedPosition += offset; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + mLastReadPosition.set(0); + return C.LENGTH_UNBOUNDED; + } + + @Override + public void close() { + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + int ret = mTsStreamer.readAt(mStartBufferedPosition + mLastReadPosition.get(), buffer, + offset, readLength); + if (ret > 0) { + mLastReadPosition.addAndGet(ret); + } + return ret; + } + } + + /** + * Creates {@link TsStreamer} for scanning & playing MPEG-2 TS file. + * @param eventListener the listener for channel & program information + */ + public FileTsStreamer(EventDetector.EventListener eventListener) { + mEventDetector = new FileSourceEventDetector(eventListener); + } + + @Override + public boolean startStream(ScanChannel channel) { + String filepath = new File(FILE_DIR, channel.filename).getAbsolutePath(); + mSource = new StreamProvider(filepath); + if (!mSource.isReady()) { + return false; + } + mEventDetector.start(mSource, FileSourceEventDetector.ALL_PROGRAM_NUMBERS); + mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); + mSource.addPidFilter(TsParser.PAT_PID); + synchronized (mCircularBufferMonitor) { + if (mStreaming) { + return true; + } + mStreaming = true; + } + + mStreamingThread = new StreamingThread(); + mStreamingThread.start(); + Log.i(TAG, "Streaming started"); + return true; + } + + @Override + public boolean startStream(TunerChannel channel) { + Log.i(TAG, "tuneToChannel with: " + channel.getFilepath()); + mSource = new StreamProvider(channel.getFilepath()); + if (!mSource.isReady()) { + return false; + } + mEventDetector.start(mSource, channel.getProgramNumber()); + mSource.addPidFilter(channel.getVideoPid()); + for (Integer i : channel.getAudioPids()) { + mSource.addPidFilter(i); + } + mSource.addPidFilter(channel.getPcrPid()); + mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); + mSource.addPidFilter(TsParser.PAT_PID); + synchronized (mCircularBufferMonitor) { + if (mStreaming) { + return true; + } + mStreaming = true; + } + + mStreamingThread = new StreamingThread(); + mStreamingThread.start(); + Log.i(TAG, "Streaming started"); + return true; + } + + /** + * Blocks the current thread until the streaming thread stops. In rare cases when the tuner + * device is overloaded this can take a while, but usually it returns pretty quickly. + */ + @Override + public void stopStream() { + synchronized (mCircularBufferMonitor) { + mStreaming = false; + mCircularBufferMonitor.notify(); + } + + try { + if (mStreamingThread != null) { + mStreamingThread.join(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public TsDataSource createDataSource() { + return new FileDataSource(this); + } + + /** + * Returns the current buffered position from the file. + * @return the current buffered position + */ + public long getBufferedPosition() { + synchronized (mCircularBufferMonitor) { + return mBytesFetched; + } + } + + /** + * Provides MPEG-2 transport stream from a local file. Stream can be filtered by PID. + */ + public static class StreamProvider { + private final String mFilepath; + private final SparseBooleanArray mPids = new SparseBooleanArray(); + private final byte[] mPreBuffer = new byte[READ_BUFFER_SIZE]; + + private BufferedInputStream mInputStream; + + private StreamProvider(String filepath) { + mFilepath = filepath; + open(filepath); + } + + private void open(String filepath) { + try { + mInputStream = new BufferedInputStream(new FileInputStream(filepath)); + } catch (IOException e) { + Log.e(TAG, "Error opening input stream", e); + mInputStream = null; + } + } + + private boolean isReady() { + return mInputStream != null; + } + + /** + * Returns the file path of the MPEG-2 TS file. + */ + public String getFilepath() { + return mFilepath; + } + + /** + * Adds a pid for filtering from the MPEG-2 TS file. + */ + public void addPidFilter(int pid) { + mPids.put(pid, true); + } + + /** + * Returns whether the current pid filter is empty or not. + */ + public boolean isFilterEmpty() { + return mPids.size() > 0; + } + + /** + * Clears the current pid filter. + */ + public void clearPidFilter() { + mPids.clear(); + } + + /** + * Returns whether a pid is in the pid filter or not. + * @param pid the pid to check + */ + public boolean isInFilter(int pid) { + return mPids.get(pid); + } + + /** + * Reads from the MPEG-2 TS file to buffer. + * + * @param inputBuffer to read + * @return the number of read bytes + */ + private int read(byte[] inputBuffer) { + int readSize = readInternal(); + if (readSize <= 0) { + // Reached the end of stream. Restart from the beginning. + close(); + open(mFilepath); + if (mInputStream == null) { + return -1; + } + readSize = readInternal(); + } + + if (mPreBuffer[0] != TS_SYNC_BYTE) { + Log.e(TAG, "Error reading input stream - no TS sync found"); + return -1; + } + int filteredSize = 0; + for (int i = 0, destPos = 0; i < readSize; i += TS_PACKET_SIZE) { + if (mPreBuffer[i] == TS_SYNC_BYTE) { + int pid = ((mPreBuffer[i + 1] & 0x1f) << 8) + (mPreBuffer[i + 2] & 0xff); + if (mPids.get(pid)) { + System.arraycopy(mPreBuffer, i, inputBuffer, destPos, TS_PACKET_SIZE); + destPos += TS_PACKET_SIZE; + filteredSize += TS_PACKET_SIZE; + } + } + } + return filteredSize; + } + + private int readInternal() { + int readSize; + try { + readSize = mInputStream.read(mPreBuffer, 0, mPreBuffer.length); + } catch (IOException e) { + Log.e(TAG, "Error reading input stream", e); + return -1; + } + return readSize; + } + + private void close() { + try { + mInputStream.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing input stream:", e); + } + mInputStream = null; + } + } + + /** + * Reads data from internal buffer. + * @param pos the position to read from + * @param buffer to read + * @param offset start position of the read buffer + * @param amount number of bytes to read + * @return number of read bytes when successful, {@code -1} otherwise + * @throws IOException + */ + public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException { + synchronized (mCircularBufferMonitor) { + long initialBytesFetched = mBytesFetched; + while (mBytesFetched < pos + amount && mStreaming) { + try { + mCircularBufferMonitor.wait(READ_TIMEOUT_MS); + } catch (InterruptedException e) { + // Wait again. + Thread.currentThread().interrupt(); + } + if (initialBytesFetched == mBytesFetched) { + Log.w(TAG, "No data update for " + READ_TIMEOUT_MS + "ms. returning -1."); + + // Returning -1 will make demux report EOS so that the input service can retry + // the playback. + return -1; + } + } + if (!mStreaming) { + Log.w(TAG, "Stream is already stopped."); + return -1; + } + if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) { + Log.e(TAG, "Demux is requesting the data which is already overwritten."); + return -1; + } + int posInBuffer = (int) (pos % CIRCULAR_BUFFER_SIZE); + int bytesToCopyInFirstPass = amount; + if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) { + bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer; + } + System.arraycopy(mCircularBuffer, posInBuffer, buffer, offset, bytesToCopyInFirstPass); + if (bytesToCopyInFirstPass < amount) { + System.arraycopy(mCircularBuffer, 0, buffer, offset + bytesToCopyInFirstPass, + amount - bytesToCopyInFirstPass); + } + mLastReadPosition = pos + amount; + mCircularBufferMonitor.notify(); + return amount; + } + } + + /** + * Adds {@link ScanChannel} instance for local files. + * + * @param output a list of channels where the results will be placed in + */ + public static void addLocalStreamFiles(List<ScanChannel> output) { + File dir = new File(FILE_DIR); + if (!dir.exists()) return; + + File[] tsFiles = dir.listFiles(); + if (tsFiles == null) return; + int freq = FileTsStreamer.FREQ_BASE; + for (File file : tsFiles) { + if (!file.isFile()) continue; + output.add(ScanChannel.forFile(freq, file.getName())); + freq += 100; + } + } + + /** + * A thread managing a circular buffer that holds stream data to be consumed by player. + * Keeps reading data in from a {@link StreamProvider} to hold enough amount for buffering. + * Started and stopped by {@link #startStream()} and {@link #stopStream()}, respectively. + */ + private class StreamingThread extends Thread { + @Override + public void run() { + byte[] dataBuffer = new byte[READ_BUFFER_SIZE]; + + synchronized (mCircularBufferMonitor) { + mBytesFetched = 0; + mLastReadPosition = 0; + } + + while (true) { + synchronized (mCircularBufferMonitor) { + while ((mBytesFetched - mLastReadPosition + PADDING_SIZE) > CIRCULAR_BUFFER_SIZE + && mStreaming) { + try { + mCircularBufferMonitor.wait(); + } catch (InterruptedException e) { + // Wait again. + Thread.currentThread().interrupt(); + } + } + if (!mStreaming) { + break; + } + } + + int bytesWritten = mSource.read(dataBuffer); + if (bytesWritten <= 0) { + try { + // When buffer is underrun, we sleep for short time to prevent + // unnecessary CPU draining. + sleep(BUFFER_UNDERRUN_SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + continue; + } + + mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten); + + synchronized (mCircularBufferMonitor) { + int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE); + int bytesToCopyInFirstPass = bytesWritten; + if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) { + bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer; + } + System.arraycopy(dataBuffer, 0, mCircularBuffer, posInBuffer, + bytesToCopyInFirstPass); + if (bytesToCopyInFirstPass < bytesWritten) { + System.arraycopy(dataBuffer, bytesToCopyInFirstPass, mCircularBuffer, 0, + bytesWritten - bytesToCopyInFirstPass); + } + mBytesFetched += bytesWritten; + mCircularBufferMonitor.notify(); + } + } + + Log.i(TAG, "Streaming stopped"); + mSource.close(); + } + } +} diff --git a/src/com/android/tv/tuner/source/TsDataSource.java b/src/com/android/tv/tuner/source/TsDataSource.java new file mode 100644 index 00000000..2ce3e670 --- /dev/null +++ b/src/com/android/tv/tuner/source/TsDataSource.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.tuner.source; + +import com.google.android.exoplayer.upstream.DataSource; + +/** + * {@link DataSource} for MPEG-TS stream, which will be used by {@link TsExtractor}. + */ +public abstract class TsDataSource implements DataSource { + + /** + * Returns the number of bytes being buffered by {@link TsStreamer} so far. + * + * @return the buffered position + */ + public long getBufferedPosition() { + return 0; + } + + /** + * Returns the offset position where the last {@link DataSource#read} read. + * + * @return the last read position + */ + public long getLastReadPosition() { + return 0; + } + + /** + * Shifts start position by the specified offset. + * Do not call this method when the class already provided MPEG-TS stream to the extractor. + * @param offset 0 <= offset <= buffered position + */ + public void shiftStartPosition(long offset) { } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/source/TsDataSourceManager.java b/src/com/android/tv/tuner/source/TsDataSourceManager.java new file mode 100644 index 00000000..7286cd8c --- /dev/null +++ b/src/com/android/tv/tuner/source/TsDataSourceManager.java @@ -0,0 +1,135 @@ +/* + * 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.tuner.source; + +import android.content.Context; + +import com.android.tv.tuner.data.Channel; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.tvinput.EventDetector; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages {@link DataSource} for playback and recording. + * The class hides handling of {@link TunerHal} and {@link TsStreamer} from other classes. + * One TsDataSourceManager should be created for per session. + */ +public class TsDataSourceManager { + private static String TAG = "TsDataSourceManager"; + + private static final Object sLock = new Object(); + private static final Map<TsDataSource, TsStreamer> sTsStreamers = + new ConcurrentHashMap<>(); + + private static int sSequenceId; + + private final int mId; + private final boolean mIsRecording; + private final TunerTsStreamerManager mTunerStreamerManager = + TunerTsStreamerManager.getInstance(); + + private boolean mKeepTuneStatus; + + /** + * Creates TsDataSourceManager to create and release {@link DataSource} which will be + * used for playing and recording. + * @param isRecording {@code true} when for recording, {@code false} otherwise + * @return {@link TsDataSourceManager} + */ + public static TsDataSourceManager createSourceManager(boolean isRecording) { + int id; + synchronized (sLock) { + id = ++sSequenceId; + } + return new TsDataSourceManager(id, isRecording); + } + + private TsDataSourceManager(int id, boolean isRecording) { + mId = id; + mIsRecording = isRecording; + mKeepTuneStatus = true; + } + + /** + * Creates or retrieves {@link TsDataSource} for playing or recording + * @param context a {@link Context} instance + * @param channel to play or record + * @param eventListener for program information which will be scanned from MPEG2-TS stream + * @return {@link TsDataSource} which will provide the specified channel stream + */ + public TsDataSource createDataSource(Context context, TunerChannel channel, + EventDetector.EventListener eventListener) { + if (channel.getType() == Channel.TYPE_FILE) { + // MPEG2 TS captured stream file recording is not supported. + if (mIsRecording) { + return null; + } + FileTsStreamer streamer = new FileTsStreamer(eventListener); + if (streamer.startStream(channel)) { + TsDataSource source = streamer.createDataSource(); + sTsStreamers.put(source, streamer); + return source; + } + return null; + } + return mTunerStreamerManager.createDataSource(context, channel, eventListener, + mId, !mIsRecording && mKeepTuneStatus); + } + + /** + * Releases the specified {@link TsDataSource} and underlying {@link TunerHal}. + * @param source to release + */ + public void releaseDataSource(TsDataSource source) { + if (source instanceof TunerTsStreamer.TunerDataSource) { + mTunerStreamerManager.releaseDataSource( + source, mId, !mIsRecording && mKeepTuneStatus); + } else if (source instanceof FileTsStreamer.FileDataSource) { + FileTsStreamer streamer = (FileTsStreamer) sTsStreamers.get(source); + if (streamer != null) { + sTsStreamers.remove(source); + streamer.stopStream(); + } + } + } + + /** + * Indicates that the current session has pending tunes. + */ + public void setHasPendingTune() { + mTunerStreamerManager.setHasPendingTune(mId); + } + + /** + * Indicates whether the underlying {@link TunerHal} should be kept or not when data source + * is being released. + * TODO: If b/30750953 is fixed, we can remove this function. + * @param keepTuneStatus underlying {@link TunerHal} will be reused when data source releasing. + */ + public void setKeepTuneStatus(boolean keepTuneStatus) { + mKeepTuneStatus = keepTuneStatus; + } + + /** + * Releases persistent resources. + */ + public void release() { + mTunerStreamerManager.release(mId); + } +} diff --git a/src/com/android/tv/tuner/source/TsStreamWriter.java b/src/com/android/tv/tuner/source/TsStreamWriter.java new file mode 100644 index 00000000..30650555 --- /dev/null +++ b/src/com/android/tv/tuner/source/TsStreamWriter.java @@ -0,0 +1,237 @@ +/* + * 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.tuner.source; + +import android.content.Context; +import android.util.Log; +import com.android.tv.tuner.data.TunerChannel; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * Stores TS files to the disk for debugging. + */ +public class TsStreamWriter { + private static final String TAG = "TsStreamWriter"; + private static final boolean DEBUG = false; + + private static final long TIME_LIMIT_MS = 10000; // 10s + private static final int NO_INSTANCE_ID = 0; + private static final int MAX_GET_ID_RETRY_COUNT = 5; + private static final int MAX_INSTANCE_ID = 10000; + private static final String SEPARATOR = "_"; + + private FileOutputStream mFileOutputStream; + private long mFileStartTimeMs; + private String mFileName = null; + private final String mDirectoryPath; + private final File mDirectory; + private final int mInstanceId; + private TunerChannel mChannel; + + public TsStreamWriter(Context context) { + File externalFilesDir = context.getExternalFilesDir(null); + if (externalFilesDir == null || !externalFilesDir.isDirectory()) { + mDirectoryPath = null; + mDirectory = null; + mInstanceId = NO_INSTANCE_ID; + if (DEBUG) { + Log.w(TAG, "Fail to get external files dir!"); + } + } else { + mDirectoryPath = externalFilesDir.getPath() + "/EngTsStream"; + mDirectory = new File(mDirectoryPath); + if (!mDirectory.exists()) { + boolean madeDir = mDirectory.mkdir(); + if (!madeDir) { + Log.w(TAG, "Error. Fail to create folder!"); + } + } + mInstanceId = generateInstanceId(); + } + } + + /** + * Sets the current channel. + * + * @param channel curren channel of the stream + */ + public void setChannel(TunerChannel channel) { + mChannel = channel; + } + + /** + * Opens a file to store TS data. + */ + public void openFile() { + if (mChannel == null || mDirectoryPath == null) { + return; + } + mFileStartTimeMs = System.currentTimeMillis(); + mFileName = mChannel.getDisplayNumber() + SEPARATOR + mFileStartTimeMs + SEPARATOR + + mInstanceId + ".ts"; + String filePath = mDirectoryPath + "/" + mFileName; + try { + mFileOutputStream = new FileOutputStream(filePath, false); + } catch (FileNotFoundException e) { + Log.w(TAG, "Cannot open file: " + filePath, e); + } + } + + /** + * Closes the file and stops storing TS data. + * + * @param calledWhenStopStream {@code true} if this method is called when the stream is stopped + * {@code false} otherwise + */ + public void closeFile(boolean calledWhenStopStream) { + if (mFileOutputStream == null) { + return; + } + try { + mFileOutputStream.close(); + deleteOutdatedFiles(calledWhenStopStream); + mFileName = null; + mFileOutputStream = null; + } catch (IOException e) { + Log.w(TAG, "Error on closing file.", e); + } + } + + /** + * Writes the data to the file. + * + * @param buffer the data to be written + * @param bytesWritten number of bytes written + */ + public void writeToFile(byte[] buffer, int bytesWritten) { + if (mFileOutputStream == null) { + return; + } + if (System.currentTimeMillis() - mFileStartTimeMs > TIME_LIMIT_MS) { + closeFile(false); + openFile(); + } + try { + mFileOutputStream.write(buffer, 0, bytesWritten); + } catch (IOException e) { + Log.w(TAG, "Error on writing TS stream.", e); + } + } + + /** + * Deletes outdated files to save storage. + * + * @param deleteAll {@code true} if all the files with the relative ID should be deleted + * {@code false} if the most recent file should not be deleted + */ + private void deleteOutdatedFiles(boolean deleteAll) { + if (mFileName == null) { + return; + } + if (mDirectory == null || !mDirectory.isDirectory()) { + Log.e(TAG, "Error. The folder doesn't exist!"); + return; + } + if (mFileName == null) { + Log.e(TAG, "Error. The current file name is null!"); + return; + } + for (File file : mDirectory.listFiles()) { + if (file.isFile() && getFileId(file) == mInstanceId + && (deleteAll || !mFileName.equals(file.getName()))) { + boolean deleted = file.delete(); + if (DEBUG && !deleted) { + Log.w(TAG, "Failed to delete " + file.getName()); + } + } + } + } + + /** + * Generates a unique instance ID. + * + * @return a unique instance ID + */ + private int generateInstanceId() { + if (mDirectory == null) { + return NO_INSTANCE_ID; + } + Set<Integer> idSet = getExistingIds(); + if (idSet == null) { + return NO_INSTANCE_ID; + } + for (int i = 0; i < MAX_GET_ID_RETRY_COUNT; i++) { + // Range [1, MAX_INSTANCE_ID] + int id = (int)Math.floor(Math.random() * MAX_INSTANCE_ID) + 1; + if (!idSet.contains(id)) { + return id; + } + } + return NO_INSTANCE_ID; + } + + /** + * Gets all existing instance IDs. + * + * @return a set of all existing instance IDs + */ + private Set<Integer> getExistingIds() { + if (mDirectory == null || !mDirectory.isDirectory()) { + return null; + } + + Set<Integer> idSet = new HashSet<>(); + for (File file : mDirectory.listFiles()) { + int id = getFileId(file); + if(id != NO_INSTANCE_ID) { + idSet.add(id); + } + } + return idSet; + } + + /** + * Gets the instance ID of a given file. + * + * @param file the file whose TsStreamWriter ID is returned + * @return the TsStreamWriter ID of the file or NO_INSTANCE_ID if not available + */ + private static int getFileId(File file) { + if (file == null || !file.isFile()) { + return NO_INSTANCE_ID; + } + String fileName = file.getName(); + int lastSeparator = fileName.lastIndexOf(SEPARATOR); + if (!fileName.endsWith(".ts") || lastSeparator == -1) { + return NO_INSTANCE_ID; + } + try { + return Integer.parseInt(fileName.substring(lastSeparator + 1, fileName.length() - 3)); + } catch (NumberFormatException e) { + if (DEBUG) { + Log.e(TAG, fileName + " is not a valid file name."); + } + } + return NO_INSTANCE_ID; + } +} diff --git a/src/com/android/tv/tuner/source/TsStreamer.java b/src/com/android/tv/tuner/source/TsStreamer.java new file mode 100644 index 00000000..1ac950bb --- /dev/null +++ b/src/com/android/tv/tuner/source/TsStreamer.java @@ -0,0 +1,56 @@ +/* + * 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.tuner.source; + +import com.android.tv.tuner.ChannelScanFileParser; +import com.android.tv.tuner.data.TunerChannel; + +/** + * Interface definition for a stream generator. The interface will provide streams + * for scanning channels and/or playback. + */ +public interface TsStreamer { + /** + * Starts streaming the data for channel scanning process. + * + * @param channel {@link ChannelScanFileParser.ScanChannel} to be scanned + * @return {@code true} if ready to stream, otherwise {@code false} + */ + boolean startStream(ChannelScanFileParser.ScanChannel channel); + + /** + * Starts streaming the data for channel playing or recording. + * + * @param channel {@link TunerChannel} to tune + * @return {@code true} if ready to stream, otherwise {@code false} + */ + boolean startStream(TunerChannel channel); + + /** + * Stops streaming the data. + */ + void stopStream(); + + /** + * Creates {@link TsDataSource} which will provide MPEG-2 TS stream for + * {@link android.media.MediaExtractor}. The source will start from the position + * where it is created. + * + * @return {@link TsDataSource} + */ + TsDataSource createDataSource(); +} diff --git a/src/com/android/tv/tuner/source/TunerTsStreamer.java b/src/com/android/tv/tuner/source/TunerTsStreamer.java new file mode 100644 index 00000000..b24048e6 --- /dev/null +++ b/src/com/android/tv/tuner/source/TunerTsStreamer.java @@ -0,0 +1,363 @@ +/* + * 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.tuner.source; + +import android.content.Context; +import android.util.Log; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.upstream.DataSpec; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.ChannelScanFileParser; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.tvinput.EventDetector; +import com.android.tv.tuner.tvinput.EventDetector.EventListener; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Provides MPEG-2 TS stream sources for channel playing from an underlying tuner device. + */ +public class TunerTsStreamer implements TsStreamer { + private static final String TAG = "TunerTsStreamer"; + + private static final int MIN_READ_UNIT = 1500; + private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~15KB + private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 20000; // ~ 30MB + + private static final int READ_TIMEOUT_MS = 5000; // 5 secs. + private static final int BUFFER_UNDERRUN_SLEEP_MS = 10; + + private final Object mCircularBufferMonitor = new Object(); + private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE]; + private long mBytesFetched; + private final AtomicLong mLastReadPosition = new AtomicLong(); + private boolean mEndOfStreamSent; + private boolean mStreaming; + + private final TunerHal mTunerHal; + private TunerChannel mChannel; + private Thread mStreamingThread; + private final EventDetector mEventDetector; + + private final TsStreamWriter mTsStreamWriter; + + public static class TunerDataSource extends TsDataSource { + private final TunerTsStreamer mTsStreamer; + private final AtomicLong mLastReadPosition = new AtomicLong(0); + private long mStartBufferedPosition; + + private TunerDataSource(TunerTsStreamer tsStreamer) { + mTsStreamer = tsStreamer; + mStartBufferedPosition = tsStreamer.getBufferedPosition(); + } + + @Override + public long getBufferedPosition() { + return mTsStreamer.getBufferedPosition() - mStartBufferedPosition; + } + + @Override + public long getLastReadPosition() { + return mLastReadPosition.get(); + } + + @Override + public void shiftStartPosition(long offset) { + SoftPreconditions.checkState(mLastReadPosition.get() == 0); + SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition()); + mStartBufferedPosition += offset; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + mLastReadPosition.set(0); + return C.LENGTH_UNBOUNDED; + } + + @Override + public void close() { + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + int ret = mTsStreamer.readAt(mStartBufferedPosition + mLastReadPosition.get(), buffer, + offset, readLength); + if (ret > 0) { + mLastReadPosition.addAndGet(ret); + } + return ret; + } + } + /** + * Creates {@link TsStreamer} for playing or recording the specified channel. + * @param tunerHal the HAL for tuner device + * @param eventListener the listener for channel & program information + */ + public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener, Context context) { + mTunerHal = tunerHal; + mEventDetector = new EventDetector(mTunerHal, eventListener); + mTsStreamWriter = context != null && TunerPreferences.getStoreTsStream(context) ? + new TsStreamWriter(context) : null; + } + + public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener) { + this(tunerHal, eventListener, null); + } + + @Override + public boolean startStream(TunerChannel channel) { + if (mTunerHal.tune(channel.getFrequency(), channel.getModulation())) { + if (channel.hasVideo()) { + mTunerHal.addPidFilter(channel.getVideoPid(), + TunerHal.FILTER_TYPE_VIDEO); + } + boolean audioFilterSet = false; + for (Integer audioPid : channel.getAudioPids()) { + if (!audioFilterSet) { + mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_AUDIO); + audioFilterSet = true; + } else { + // FILTER_TYPE_AUDIO overrides the previous filter for audio. We use + // FILTER_TYPE_OTHER from the secondary one to get the all audio tracks. + mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_OTHER); + } + } + mTunerHal.addPidFilter(channel.getPcrPid(), + TunerHal.FILTER_TYPE_PCR); + if (mEventDetector != null) { + mEventDetector.startDetecting(channel.getFrequency(), channel.getModulation(), + channel.getProgramNumber()); + } + mChannel = channel; + synchronized (mCircularBufferMonitor) { + if (mStreaming) { + Log.w(TAG, "Streaming should be stopped before start streaming"); + return true; + } + mStreaming = true; + mBytesFetched = 0; + mLastReadPosition.set(0L); + mEndOfStreamSent = false; + } + if (mTsStreamWriter != null) { + mTsStreamWriter.setChannel(mChannel); + mTsStreamWriter.openFile(); + } + mStreamingThread = new StreamingThread(); + mStreamingThread.start(); + Log.i(TAG, "Streaming started"); + return true; + } + return false; + } + + @Override + public boolean startStream(ChannelScanFileParser.ScanChannel channel) { + if (mTunerHal.tune(channel.frequency, channel.modulation)) { + mEventDetector.startDetecting( + channel.frequency, channel.modulation, EventDetector.ALL_PROGRAM_NUMBERS); + synchronized (mCircularBufferMonitor) { + if (mStreaming) { + Log.w(TAG, "Streaming should be stopped before start streaming"); + return true; + } + mStreaming = true; + mBytesFetched = 0; + mLastReadPosition.set(0L); + mEndOfStreamSent = false; + } + mStreamingThread = new StreamingThread(); + mStreamingThread.start(); + Log.i(TAG, "Streaming started"); + return true; + } + return false; + } + + /** + * Blocks the current thread until the streaming thread stops. In rare cases when the tuner + * device is overloaded this can take a while, but usually it returns pretty quickly. + */ + @Override + public void stopStream() { + mChannel = null; + synchronized (mCircularBufferMonitor) { + mStreaming = false; + mCircularBufferMonitor.notifyAll(); + } + + try { + if (mStreamingThread != null) { + mStreamingThread.join(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (mTsStreamWriter != null) { + mTsStreamWriter.closeFile(true); + mTsStreamWriter.setChannel(null); + } + } + + @Override + public TsDataSource createDataSource() { + return new TunerDataSource(this); + } + + /** + * Returns incomplete channel lists which was scanned so far. Incomplete channel means + * the channel whose channel information is not complete or is not well-formed. + * @return {@link List} of {@link TunerChannel} + */ + public List<TunerChannel> getMalFormedChannels() { + return mEventDetector.getMalFormedChannels(); + } + + /** + * Returns the current {@link TunerHal} which provides MPEG-TS stream for TunerTsStreamer. + * @return {@link TunerHal} + */ + public TunerHal getTunerHal() { + return mTunerHal; + } + + /** + * Returns the current tuned channel for TunerTsStreamer. + * @return {@link TunerChannel} + */ + public TunerChannel getChannel() { + return mChannel; + } + + /** + * Returns the current buffered position from tuner. + * @return the current buffered position + */ + public long getBufferedPosition() { + synchronized (mCircularBufferMonitor) { + return mBytesFetched; + } + } + + private class StreamingThread extends Thread { + @Override + public void run() { + // Buffers for streaming data from the tuner and the internal buffer. + byte[] dataBuffer = new byte[READ_BUFFER_SIZE]; + + while (true) { + synchronized (mCircularBufferMonitor) { + if (!mStreaming) { + break; + } + } + + int bytesWritten = mTunerHal.readTsStream(dataBuffer, dataBuffer.length); + if (bytesWritten <= 0) { + try { + // When buffer is underrun, we sleep for short time to prevent + // unnecessary CPU draining. + sleep(BUFFER_UNDERRUN_SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + continue; + } + + if (mTsStreamWriter != null) { + mTsStreamWriter.writeToFile(dataBuffer, bytesWritten); + } + + if (mEventDetector != null) { + mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten); + } + synchronized (mCircularBufferMonitor) { + int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE); + int bytesToCopyInFirstPass = bytesWritten; + if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) { + bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer; + } + System.arraycopy(dataBuffer, 0, mCircularBuffer, posInBuffer, + bytesToCopyInFirstPass); + if (bytesToCopyInFirstPass < bytesWritten) { + System.arraycopy(dataBuffer, bytesToCopyInFirstPass, mCircularBuffer, 0, + bytesWritten - bytesToCopyInFirstPass); + } + mBytesFetched += bytesWritten; + mCircularBufferMonitor.notifyAll(); + } + } + + Log.i(TAG, "Streaming stopped"); + } + } + + /** + * Reads data from internal buffer. + * @param pos the position to read from + * @param buffer to read + * @param offset start position of the read buffer + * @param amount number of bytes to read + * @return number of read bytes when successful, {@code -1} otherwise + * @throws IOException + */ + public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException { + long readStartTime = System.currentTimeMillis(); + while (true) { + synchronized (mCircularBufferMonitor) { + if (mEndOfStreamSent || !mStreaming) { + return -1; + } + if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) { + Log.e(TAG, "Demux is requesting the data which is already overwritten."); + return -1; + } + if (System.currentTimeMillis() - readStartTime > READ_TIMEOUT_MS) { + // Nothing was received during READ_TIMEOUT_MS before. + mEndOfStreamSent = true; + mCircularBufferMonitor.notifyAll(); + return -1; + } + if (mBytesFetched < pos + amount) { + try { + mCircularBufferMonitor.wait(READ_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // Try again to prevent starvation. + // Give chances to read from other threads. + continue; + } + int startPos = (int) (pos % CIRCULAR_BUFFER_SIZE); + int endPos = (int) ((pos + amount) % CIRCULAR_BUFFER_SIZE); + int firstLength = (startPos > endPos ? CIRCULAR_BUFFER_SIZE : endPos) - startPos; + System.arraycopy(mCircularBuffer, startPos, buffer, offset, firstLength); + if (firstLength < amount) { + System.arraycopy(mCircularBuffer, 0, buffer, offset + firstLength, + amount - firstLength); + } + mCircularBufferMonitor.notifyAll(); + return amount; + } + } + } +} diff --git a/src/com/android/tv/tuner/source/TunerTsStreamerManager.java b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java new file mode 100644 index 00000000..cf1f6dcf --- /dev/null +++ b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java @@ -0,0 +1,287 @@ +/* + * 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.tuner.source; + +import android.content.Context; + +import com.android.tv.common.AutoCloseableUtils; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.tvinput.EventDetector; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** + * Manages {@link TunerTsStreamer} for playback and recording. + * The class hides handling of {@link TunerHal} from other classes. + * This class is used by {@link TsDataSourceManager}. Don't use this class directly. + */ +class TunerTsStreamerManager { + // The lock will protect mStreamerFinder, mSourceToStreamerMap and some part of TsStreamCreator + // to support timely {@link TunerTsStreamer} cancellation due to a new tune request from + // the same session. + private final Object mCancelLock = new Object(); + private final StreamerFinder mStreamerFinder = new StreamerFinder(); + private final Map<Integer, TsStreamerCreator> mCreators = new HashMap<>(); + private final Map<TsDataSource, TunerTsStreamer> mSourceToStreamerMap = new HashMap<>(); + private final TunerHalManager mTunerHalManager = new TunerHalManager(); + private static TunerTsStreamerManager sInstance; + + /** + * Returns the singleton instance for the class + * @return TunerTsStreamerManager + */ + static synchronized TunerTsStreamerManager getInstance() { + if (sInstance == null) { + sInstance = new TunerTsStreamerManager(); + } + return sInstance; + } + + private TunerTsStreamerManager() { } + + synchronized TsDataSource createDataSource( + Context context, TunerChannel channel, EventDetector.EventListener listener, + int sessionId, boolean reuse) { + TsStreamerCreator creator; + synchronized (mCancelLock) { + if (mStreamerFinder.containsLocked(channel)) { + mStreamerFinder.appendSessionLocked(channel, sessionId); + TunerTsStreamer streamer = mStreamerFinder.getStreamerLocked(channel); + TsDataSource source = streamer.createDataSource(); + mSourceToStreamerMap.put(source, streamer); + return source; + } + creator = new TsStreamerCreator(context, channel, listener); + mCreators.put(sessionId, creator); + } + TunerTsStreamer streamer = creator.create(sessionId, reuse); + synchronized (mCancelLock) { + mCreators.remove(sessionId); + if (streamer == null) { + return null; + } + if (!creator.isCancelledLocked()) { + mStreamerFinder.putLocked(channel, sessionId, streamer); + TsDataSource source = streamer.createDataSource(); + mSourceToStreamerMap.put(source, streamer); + return source; + } + } + // Created streamer was cancelled by a new tune request. + streamer.stopStream(); + TunerHal hal = streamer.getTunerHal(); + hal.setHasPendingTune(false); + mTunerHalManager.releaseTunerHal(hal, sessionId, reuse); + return null; + } + + synchronized void releaseDataSource(TsDataSource source, int sessionId, + boolean reuse) { + TunerTsStreamer streamer; + synchronized (mCancelLock) { + streamer = mSourceToStreamerMap.get(source); + mSourceToStreamerMap.remove(source); + if (streamer == null) { + return; + } + TunerChannel channel = streamer.getChannel(); + SoftPreconditions.checkState(channel != null); + mStreamerFinder.removeSessionLocked(channel, sessionId); + if (mStreamerFinder.containsLocked(channel)) { + return; + } + } + streamer.stopStream(); + TunerHal hal = streamer.getTunerHal(); + hal.setHasPendingTune(false); + mTunerHalManager.releaseTunerHal(hal, sessionId, reuse); + } + + void setHasPendingTune(int sessionId) { + synchronized (mCancelLock) { + if (mCreators.containsKey(sessionId)) { + mCreators.get(sessionId).cancelLocked(); + } + } + } + + synchronized void release(int sessionId) { + mTunerHalManager.releaseCachedHal(sessionId); + } + + private class StreamerFinder { + private final Map<TunerChannel, Set<Integer>> mSessions = new HashMap<>(); + private final Map<TunerChannel, TunerTsStreamer> mStreamers = new HashMap<>(); + + // @GuardedBy("mCancelLock") + private void putLocked(TunerChannel channel, int sessionId, TunerTsStreamer streamer) { + Set<Integer> sessions = new HashSet<>(); + sessions.add(sessionId); + mSessions.put(channel, sessions); + mStreamers.put(channel, streamer); + } + + // @GuardedBy("mCancelLock") + private void appendSessionLocked(TunerChannel channel, int sessionId) { + if (mSessions.containsKey(channel)) { + mSessions.get(channel).add(sessionId); + } + } + + // @GuardedBy("mCancelLock") + private void removeSessionLocked(TunerChannel channel, int sessionId) { + Set<Integer> sessions = mSessions.get(channel); + sessions.remove(sessionId); + if (sessions.size() == 0) { + mSessions.remove(channel); + mStreamers.remove(channel); + } + } + + // @GuardedBy("mCancelLock") + private boolean containsLocked(TunerChannel channel) { + return mSessions.containsKey(channel); + } + + // @GuardedBy("mCancelLock") + private TunerTsStreamer getStreamerLocked(TunerChannel channel) { + return mStreamers.containsKey(channel) ? mStreamers.get(channel) : null; + } + } + + /** + * {@link TunerTsStreamer} creation can be cancelled by a new tune request for the same + * session. The class supports the cancellation in creating new {@link TunerTsStreamer}. + */ + private class TsStreamerCreator { + private final Context mContext; + private final TunerChannel mChannel; + private final EventDetector.EventListener mEventListener; + // mCancelled will be {@code true} if a new tune request for the same session + // cancels create(). + private boolean mCancelled; + private TunerHal mTunerHal; + + private TsStreamerCreator(Context context, TunerChannel channel, + EventDetector.EventListener listener) { + mContext = context; + mChannel = channel; + mEventListener = listener; + } + + private TunerTsStreamer create(int sessionId, boolean reuse) { + TunerHal hal = mTunerHalManager.getOrCreateTunerHal(mContext, sessionId); + if (hal == null) { + return null; + } + boolean canceled = false; + synchronized (mCancelLock) { + if (!mCancelled) { + mTunerHal = hal; + } else { + canceled = true; + } + } + if (!canceled) { + TunerTsStreamer tsStreamer = new TunerTsStreamer(hal, mEventListener, mContext); + if (tsStreamer.startStream(mChannel)) { + return tsStreamer; + } + synchronized (mCancelLock) { + mTunerHal = null; + } + } + hal.setHasPendingTune(false); + // Since TunerTsStreamer is not properly created, closes TunerHal. + // And do not re-use TunerHal when it is not cancelled. + mTunerHalManager.releaseTunerHal(hal, sessionId, mCancelled && reuse); + return null; + } + + // @GuardedBy("mCancelLock") + private void cancelLocked() { + if (mCancelled) { + return; + } + mCancelled = true; + if (mTunerHal != null) { + mTunerHal.setHasPendingTune(true); + } + } + + // @GuardedBy("mCancelLock") + private boolean isCancelledLocked() { + return mCancelled; + } + } + + /** + * Supports sharing {@link TunerHal} among multiple sessions. + * The class also supports session affinity for {@link TunerHal} allocation. + */ + private class TunerHalManager { + private final Map<Integer, TunerHal> mTunerHals = new HashMap<>(); + + private TunerHal getOrCreateTunerHal(Context context, int sessionId) { + // Handles session affinity. + TunerHal hal = mTunerHals.get(sessionId); + if (hal != null) { + mTunerHals.remove(sessionId); + return hal; + } + // Finds a TunerHal which is cached for other sessions. + Iterator it = mTunerHals.keySet().iterator(); + if (it.hasNext()) { + Integer key = (Integer) it.next(); + hal = mTunerHals.get(key); + mTunerHals.remove(key); + return hal; + } + return TunerHal.createInstance(context); + } + + private void releaseTunerHal(TunerHal hal, int sessionId, boolean reuse) { + if (!reuse) { + AutoCloseableUtils.closeQuietly(hal); + return; + } + TunerHal cachedHal = mTunerHals.get(sessionId); + if (cachedHal != hal) { + mTunerHals.put(sessionId, hal); + } + if (cachedHal != null && cachedHal != hal) { + AutoCloseableUtils.closeQuietly(cachedHal); + } + } + + private void releaseCachedHal(int sessionId) { + TunerHal hal = mTunerHals.get(sessionId); + if (hal != null) { + mTunerHals.remove(sessionId); + } + if (hal != null) { + AutoCloseableUtils.closeQuietly(hal); + } + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/ts/SectionParser.java b/src/com/android/tv/tuner/ts/SectionParser.java new file mode 100644 index 00000000..5d3e728a --- /dev/null +++ b/src/com/android/tv/tuner/ts/SectionParser.java @@ -0,0 +1,1264 @@ +/* + * 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.tuner.ts; + +import android.media.tv.TvContentRating; +import android.media.tv.TvContract.Programs.Genres; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; + +import com.android.tv.tuner.data.Channel; +import com.android.tv.tuner.data.PsiData.PatItem; +import com.android.tv.tuner.data.PsiData.PmtItem; +import com.android.tv.tuner.data.PsipData.Ac3AudioDescriptor; +import com.android.tv.tuner.data.PsipData.CaptionServiceDescriptor; +import com.android.tv.tuner.data.PsipData.ContentAdvisoryDescriptor; +import com.android.tv.tuner.data.PsipData.EitItem; +import com.android.tv.tuner.data.PsipData.EttItem; +import com.android.tv.tuner.data.PsipData.ExtendedChannelNameDescriptor; +import com.android.tv.tuner.data.PsipData.GenreDescriptor; +import com.android.tv.tuner.data.PsipData.Iso639LanguageDescriptor; +import com.android.tv.tuner.data.PsipData.MgtItem; +import com.android.tv.tuner.data.PsipData.PsipSection; +import com.android.tv.tuner.data.PsipData.RatingRegion; +import com.android.tv.tuner.data.PsipData.RegionalRating; +import com.android.tv.tuner.data.PsipData.TsDescriptor; +import com.android.tv.tuner.data.PsipData.VctItem; +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.util.ByteArrayBuffer; + +import com.ibm.icu.text.UnicodeDecompressor; + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * Parses ATSC PSIP sections. + */ +public class SectionParser { + private static final String TAG = "SectionParser"; + private static final boolean DEBUG = false; + + private static final byte TABLE_ID_PAT = (byte) 0x00; + private static final byte TABLE_ID_PMT = (byte) 0x02; + private static final byte TABLE_ID_MGT = (byte) 0xc7; + private static final byte TABLE_ID_TVCT = (byte) 0xc8; + private static final byte TABLE_ID_CVCT = (byte) 0xc9; + private static final byte TABLE_ID_EIT = (byte) 0xcb; + private static final byte TABLE_ID_ETT = (byte) 0xcc; + + // For details of the structure for the tags of descriptors, see ATSC A/65 Table 6.25. + public static final int DESCRIPTOR_TAG_ISO639LANGUAGE = 0x0a; + public static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86; + public static final int DESCRIPTOR_TAG_CONTENT_ADVISORY = 0x87; + public static final int DESCRIPTOR_TAG_AC3_AUDIO_STREAM = 0x81; + public static final int DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME = 0xa0; + public static final int DESCRIPTOR_TAG_GENRE = 0xab; + + private static final byte COMPRESSION_TYPE_NO_COMPRESSION = (byte) 0x00; + private static final byte MODE_SELECTED_UNICODE_RANGE_1 = (byte) 0x00; // 0x0000 - 0x00ff + private static final byte MODE_UTF16 = (byte) 0x3f; + private static final byte MODE_SCSU = (byte) 0x3e; + private static final int MAX_SHORT_NAME_BYTES = 14; + + // See ANSI/CEA-766-C. + private static final int RATING_REGION_US_TV = 1; + private static final int RATING_REGION_KR_TV = 4; + + // The following values are defined in the live channels app. + // See https://developer.android.com/reference/android/media/tv/TvContentRating.html. + private static final String RATING_REGION_RATING_SYSTEM_US_TV = "US_TV"; + private static final String RATING_REGION_RATING_SYSTEM_KR_TV = "KR_TV"; + + private static final String[] RATING_REGION_TABLE_US_TV = { + "US_TV_Y", "US_TV_Y7", "US_TV_G", "US_TV_PG", "US_TV_14", "US_TV_MA" + }; + + private static final String[] RATING_REGION_TABLE_KR_TV = { + "KR_TV_ALL", "KR_TV_7", "KR_TV_12", "KR_TV_15", "KR_TV_19" + }; + + /* + * The following CRC table is from the code generated by the following command. + * $ python pycrc.py --model crc-32-mpeg --algorithm table-driven --generate c + * To see the details of pycrc, visit http://www.tty1.net/pycrc/index_en.html + */ + public static final int[] CRC_TABLE = { + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, + 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, + 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, + 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, + 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, + 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, + 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, + 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, + 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, + 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, + 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, + 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, + 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, + 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, + 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, + 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, + 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, + 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, + 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, + 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, + 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, + 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, + 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, + 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, + 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, + 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, + 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, + 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, + 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, + 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, + 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, + 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, + 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, + 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, + 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, + 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, + 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, + 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, + 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, + 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, + 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, + 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, + 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, + 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, + 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, + 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, + 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, + 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, + 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, + 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, + 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, + 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, + 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, + 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, + 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, + 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, + 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, + 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, + 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, + 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, + 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, + 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, + 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, + 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4 + }; + + // A table which maps ATSC genres to TIF genres. + // See ATSC/65 Table 6.20. + private static final String[] CANONICAL_GENRES_TABLE = { + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + Genres.EDUCATION, Genres.ENTERTAINMENT, Genres.MOVIES, Genres.NEWS, + Genres.LIFE_STYLE, Genres.SPORTS, null, Genres.MOVIES, + null, + Genres.FAMILY_KIDS, Genres.DRAMA, null, Genres.ENTERTAINMENT, Genres.SPORTS, + Genres.SPORTS, + null, null, + Genres.MUSIC, Genres.EDUCATION, + null, + Genres.COMEDY, + null, + Genres.MUSIC, + null, null, + Genres.MOVIES, Genres.ENTERTAINMENT, Genres.NEWS, Genres.DRAMA, + Genres.EDUCATION, Genres.MOVIES, Genres.SPORTS, Genres.MOVIES, + null, + Genres.LIFE_STYLE, Genres.ARTS, Genres.LIFE_STYLE, Genres.SPORTS, + null, null, + Genres.GAMING, Genres.LIFE_STYLE, Genres.SPORTS, + null, + Genres.LIFE_STYLE, Genres.EDUCATION, Genres.EDUCATION, Genres.LIFE_STYLE, + Genres.SPORTS, Genres.LIFE_STYLE, Genres.MOVIES, Genres.NEWS, + null, null, null, + Genres.EDUCATION, + null, null, null, + Genres.EDUCATION, + null, null, null, + Genres.DRAMA, Genres.MUSIC, Genres.MOVIES, + null, + Genres.ANIMAL_WILDLIFE, + null, null, + Genres.PREMIER, + null, null, null, null, + Genres.SPORTS, Genres.ARTS, + null, null, null, + Genres.MOVIES, Genres.TECH_SCIENCE, Genres.DRAMA, + null, + Genres.SHOPPING, Genres.DRAMA, + null, + Genres.MOVIES, Genres.ENTERTAINMENT, Genres.TECH_SCIENCE, Genres.SPORTS, + Genres.TRAVEL, Genres.ENTERTAINMENT, Genres.ARTS, Genres.NEWS, + null, + Genres.ARTS, Genres.SPORTS, Genres.SPORTS, Genres.NEWS, + Genres.SPORTS, Genres.SPORTS, Genres.SPORTS, Genres.FAMILY_KIDS, + Genres.FAMILY_KIDS, Genres.MOVIES, + null, + Genres.TECH_SCIENCE, Genres.MUSIC, + null, + Genres.SPORTS, Genres.FAMILY_KIDS, Genres.NEWS, Genres.SPORTS, + Genres.NEWS, Genres.SPORTS, Genres.ANIMAL_WILDLIFE, + null, + Genres.MUSIC, Genres.NEWS, Genres.SPORTS, + null, + Genres.NEWS, Genres.NEWS, Genres.NEWS, Genres.NEWS, + Genres.SPORTS, Genres.MOVIES, Genres.ARTS, Genres.ANIMAL_WILDLIFE, + Genres.MUSIC, Genres.MUSIC, Genres.MOVIES, Genres.EDUCATION, + Genres.DRAMA, Genres.SPORTS, Genres.SPORTS, Genres.SPORTS, + Genres.SPORTS, + null, + Genres.SPORTS, Genres.SPORTS, + }; + + // A table which contains ATSC categorical genre code assignments. + // See ATSC/65 Table 6.20. + private static final String[] BROADCAST_GENRES_TABLE = new String[] { + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + null, null, null, null, + "Education", "Entertainment", "Movie", "News", + "Religious", "Sports", "Other", "Action", + "Advertisement", "Animated", "Anthology", "Automobile", + "Awards", "Baseball", "Basketball", "Bulletin", + "Business", "Classical", "College", "Combat", + "Comedy", "Commentary", "Concert", "Consumer", + "Contemporary", "Crime", "Dance", "Documentary", + "Drama", "Elementary", "Erotica", "Exercise", + "Fantasy", "Farm", "Fashion", "Fiction", + "Food", "Football", "Foreign", "Fund Raiser", + "Game/Quiz", "Garden", "Golf", "Government", + "Health", "High School", "History", "Hobby", + "Hockey", "Home", "Horror", "Information", + "Instruction", "International", "Interview", "Language", + "Legal", "Live", "Local", "Math", + "Medical", "Meeting", "Military", "Miniseries", + "Music", "Mystery", "National", "Nature", + "Police", "Politics", "Premier", "Prerecorded", + "Product", "Professional", "Public", "Racing", + "Reading", "Repair", "Repeat", "Review", + "Romance", "Science", "Series", "Service", + "Shopping", "Soap Opera", "Special", "Suspense", + "Talk", "Technical", "Tennis", "Travel", + "Variety", "Video", "Weather", "Western", + "Art", "Auto Racing", "Aviation", "Biography", + "Boating", "Bowling", "Boxing", "Cartoon", + "Children", "Classic Film", "Community", "Computers", + "Country Music", "Court", "Extreme Sports", "Family", + "Financial", "Gymnastics", "Headlines", "Horse Racing", + "Hunting/Fishing/Outdoors", "Independent", "Jazz", "Magazine", + "Motorcycle Racing", "Music/Film/Books", "News-International", "News-Local", + "News-National", "News-Regional", "Olympics", "Original", + "Performing Arts", "Pets/Animals", "Pop", "Rock & Roll", + "Sci-Fi", "Self Improvement", "Sitcom", "Skating", + "Skiing", "Soccer", "Track/Field", "True", + "Volleyball", "Wrestling", + }; + + // Audio language code map from ISO 639-2/B to 639-2/T, in order to show correct audio language. + private static final HashMap<String, String> ISO_LANGUAGE_CODE_MAP; + static { + ISO_LANGUAGE_CODE_MAP = new HashMap<>(); + ISO_LANGUAGE_CODE_MAP.put("alb", "sqi"); + ISO_LANGUAGE_CODE_MAP.put("arm", "hye"); + ISO_LANGUAGE_CODE_MAP.put("baq", "eus"); + ISO_LANGUAGE_CODE_MAP.put("bur", "mya"); + ISO_LANGUAGE_CODE_MAP.put("chi", "zho"); + ISO_LANGUAGE_CODE_MAP.put("cze", "ces"); + ISO_LANGUAGE_CODE_MAP.put("dut", "nld"); + ISO_LANGUAGE_CODE_MAP.put("fre", "fra"); + ISO_LANGUAGE_CODE_MAP.put("geo", "kat"); + ISO_LANGUAGE_CODE_MAP.put("ger", "deu"); + ISO_LANGUAGE_CODE_MAP.put("gre", "ell"); + ISO_LANGUAGE_CODE_MAP.put("ice", "isl"); + ISO_LANGUAGE_CODE_MAP.put("mac", "mkd"); + ISO_LANGUAGE_CODE_MAP.put("mao", "mri"); + ISO_LANGUAGE_CODE_MAP.put("may", "msa"); + ISO_LANGUAGE_CODE_MAP.put("per", "fas"); + ISO_LANGUAGE_CODE_MAP.put("rum", "ron"); + ISO_LANGUAGE_CODE_MAP.put("slo", "slk"); + ISO_LANGUAGE_CODE_MAP.put("tib", "bod"); + ISO_LANGUAGE_CODE_MAP.put("wel", "cym"); + ISO_LANGUAGE_CODE_MAP.put("esl", "spa"); // Special entry for channel 9-1 KQED in bay area. + } + + // Containers to store the last version numbers of the PSIP sections. + private final HashMap<PsipSection, Integer> mSectionVersionMap = new HashMap<>(); + private final SparseArray<List<EttItem>> mParsedEttItems = new SparseArray<>(); + + public interface OutputListener { + void onPatParsed(List<PatItem> items); + void onPmtParsed(int programNumber, List<PmtItem> items); + void onMgtParsed(List<MgtItem> items); + void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber); + void onEitParsed(int sourceId, List<EitItem> items); + void onEttParsed(int sourceId, List<EttItem> descriptions); + } + + private final OutputListener mListener; + + public SectionParser(OutputListener listener) { + mListener = listener; + } + + public void parseSections(ByteArrayBuffer data) { + int pos = 0; + while (pos + 3 <= data.length()) { + if ((data.byteAt(pos) & 0xff) == 0xff) { + // Clear stuffing bytes according to H222.0 section 2.4.4. + data.setLength(0); + break; + } + int sectionLength = + (((data.byteAt(pos + 1) & 0x0f) << 8) | (data.byteAt(pos + 2) & 0xff)) + 3; + if (pos + sectionLength > data.length()) { + break; + } + if (DEBUG) { + Log.d(TAG, "parseSections 0x" + Integer.toHexString(data.byteAt(pos) & 0xff)); + } + parseSection(Arrays.copyOfRange(data.buffer(), pos, pos + sectionLength)); + pos += sectionLength; + } + if (mListener != null) { + for (int i = 0; i < mParsedEttItems.size(); ++i) { + int sourceId = mParsedEttItems.keyAt(i); + List<EttItem> descriptions = mParsedEttItems.valueAt(i); + mListener.onEttParsed(sourceId, descriptions); + } + } + mParsedEttItems.clear(); + } + + private void parseSection(byte[] data) { + if (!checkSanity(data)) { + Log.d(TAG, "Bad CRC!"); + return; + } + PsipSection section = PsipSection.create(data); + if (section == null) { + return; + } + + // The currentNextIndicator indicates that the section sent is currently applicable. + if (!section.getCurrentNextIndicator()) { + return; + } + int versionNumber = (data[5] & 0x3e) >> 1; + Integer oldVersionNumber = mSectionVersionMap.get(section); + + // The versionNumber shall be incremented when a change in the information carried within + // the section occurs. + if (oldVersionNumber != null && versionNumber == oldVersionNumber) { + return; + } + boolean result = false; + switch (data[0]) { + case TABLE_ID_PAT: + result = parsePAT(data); + break; + case TABLE_ID_PMT: + result = parsePMT(data); + break; + case TABLE_ID_MGT: + result = parseMGT(data); + break; + case TABLE_ID_TVCT: + case TABLE_ID_CVCT: + result = parseVCT(data); + break; + case TABLE_ID_EIT: + result = parseEIT(data); + break; + case TABLE_ID_ETT: + result = parseETT(data); + break; + default: + break; + } + if (result) { + mSectionVersionMap.put(section, versionNumber); + } + } + + private boolean parsePAT(byte[] data) { + if (DEBUG) { + Log.d(TAG, "PAT is discovered."); + } + int pos = 8; + + List<PatItem> results = new ArrayList<>(); + for (; pos < data.length - 4; pos = pos + 4) { + if (pos > data.length - 4 - 4) { + Log.e(TAG, "Broken PAT."); + return false; + } + int programNo = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff); + int pmtPid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff); + results.add(new PatItem(programNo, pmtPid)); + } + if (mListener != null) { + mListener.onPatParsed(results); + } + return true; + } + + private boolean parsePMT(byte[] data) { + int table_id_ext = ((data[3] & 0xff) << 8) | (data[4] & 0xff); + if (DEBUG) { + Log.d(TAG, "PMT is discovered. programNo = " + table_id_ext); + } + if (data.length <= 11) { + Log.e(TAG, "Broken PMT."); + return false; + } + int pcrPid = (data[8] & 0x1f) << 8 | data[9]; + int programInfoLen = (data[10] & 0x0f) << 8 | data[11]; + int pos = 12; + List<TsDescriptor> descriptors = parseDescriptors(data, pos, pos + programInfoLen); + pos += programInfoLen; + if (DEBUG) { + Log.d(TAG, "PMT descriptors size: " + descriptors.size()); + } + List<PmtItem> results = new ArrayList<>(); + for (; pos < data.length - 4;) { + if (pos < 0) { + Log.e(TAG, "Broken PMT."); + return false; + } + int streamType = data[pos] & 0xff; + int esPid = (data[pos + 1] & 0x1f) << 8 | (data[pos + 2] & 0xff); + int esInfoLen = (data[pos + 3] & 0xf) << 8 | (data[pos + 4] & 0xff); + if (data.length < pos + esInfoLen + 5) { + Log.e(TAG, "Broken PMT."); + return false; + } + descriptors = parseDescriptors(data, pos + 5, pos + 5 + esInfoLen); + List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors); + List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors); + PmtItem pmtItem = new PmtItem(streamType, esPid, audioTracks, captionTracks); + if (DEBUG) { + Log.d(TAG, "PMT " + pmtItem + " descriptors size: " + descriptors.size()); + } + results.add(pmtItem); + pos = pos + esInfoLen + 5; + } + results.add(new PmtItem(PmtItem.ES_PID_PCR, pcrPid, null, null)); + if (mListener != null) { + mListener.onPmtParsed(table_id_ext, results); + } + return true; + } + + private boolean parseMGT(byte[] data) { + // For details of the structure for MGT, see ATSC A/65 Table 6.2. + if (DEBUG) { + Log.d(TAG, "MGT is discovered."); + } + if (data.length <= 10) { + Log.e(TAG, "Broken MGT."); + return false; + } + int tablesDefined = ((data[9] & 0xff) << 8) | (data[10] & 0xff); + int pos = 11; + List<MgtItem> results = new ArrayList<>(); + for (int i = 0; i < tablesDefined; ++i) { + if (data.length <= pos + 10) { + Log.e(TAG, "Broken MGT."); + return false; + } + int tableType = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff); + int tableTypePid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff); + int descriptorsLength = ((data[pos + 9] & 0x0f) << 8) | (data[pos + 10] & 0xff); + pos += 11 + descriptorsLength; + results.add(new MgtItem(tableType, tableTypePid)); + } + if ((data[pos] & 0xf0) != 0xf0) { + Log.e(TAG, "Broken MGT."); + return false; + } + if (mListener != null) { + mListener.onMgtParsed(results); + } + return true; + } + + private boolean parseVCT(byte[] data) { + // For details of the structure for VCT, see ATSC A/65 Table 6.4 and 6.8. + if (DEBUG) { + Log.d(TAG, "VCT is discovered."); + } + if (data.length <= 9) { + Log.e(TAG, "Broken VCT."); + return false; + } + int numChannelsInSection = (data[9] & 0xff); + int sectionNumber = (data[6] & 0xff); + int lastSectionNumber = (data[7] & 0xff); + if (sectionNumber > lastSectionNumber) { + // According to section 6.3.1 of the spec ATSC A/65, + // last section number is the largest section number. + Log.w(TAG, "Invalid VCT. Section Number " + sectionNumber + " > Last Section Number " + + lastSectionNumber); + return false; + } + int pos = 10; + List<VctItem> results = new ArrayList<>(); + for (int i = 0; i < numChannelsInSection; ++i) { + if (data.length <= pos + 31) { + Log.e(TAG, "Broken VCT."); + return false; + } + String shortName = ""; + int shortNameSize = getShortNameSize(data, pos); + try { + shortName = new String( + Arrays.copyOfRange(data, pos, pos + shortNameSize), "UTF-16"); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Broken VCT.", e); + return false; + } + if ((data[pos + 14] & 0xf0) != 0xf0) { + Log.e(TAG, "Broken VCT."); + return false; + } + int majorNumber = ((data[pos + 14] & 0x0f) << 6) | ((data[pos + 15] & 0xff) >> 2); + int minorNumber = ((data[pos + 15] & 0x03) << 8) | (data[pos + 16] & 0xff); + if ((majorNumber & 0x3f0) == 0x3f0) { + // If the six MSBs are 111111, these indicate that there is only one-part channel + // number. To see details, refer A/65 Section 6.3.2. + majorNumber = ((majorNumber & 0xf) << 10) + minorNumber; + minorNumber = 0; + } + int channelTsid = ((data[pos + 22] & 0xff) << 8) | (data[pos + 23] & 0xff); + int programNumber = ((data[pos + 24] & 0xff) << 8) | (data[pos + 25] & 0xff); + boolean accessControlled = (data[pos + 26] & 0x20) != 0; + boolean hidden = (data[pos + 26] & 0x10) != 0; + int serviceType = (data[pos + 27] & 0x3f); + int sourceId = ((data[pos + 28] & 0xff) << 8) | (data[pos + 29] & 0xff); + int descriptorsPos = pos + 32; + int descriptorsLength = ((data[pos + 30] & 0x03) << 8) | (data[pos + 31] & 0xff); + pos += 32 + descriptorsLength; + if (data.length < pos) { + Log.e(TAG, "Broken VCT."); + return false; + } + List<TsDescriptor> descriptors = parseDescriptors( + data, descriptorsPos, descriptorsPos + descriptorsLength); + String longName = null; + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof ExtendedChannelNameDescriptor) { + ExtendedChannelNameDescriptor extendedChannelNameDescriptor = + (ExtendedChannelNameDescriptor) descriptor; + longName = extendedChannelNameDescriptor.getLongChannelName(); + break; + } + } + if (DEBUG) { + Log.d(TAG, String.format( + "Found channel [%s] %s - serviceType: %d tsid: 0x%x program: %d " + + "channel: %d-%d encrypted: %b hidden: %b, descriptors: %d", + shortName, longName, serviceType, channelTsid, programNumber, majorNumber, + minorNumber, accessControlled, hidden, descriptors.size())); + } + if (!accessControlled && !hidden && (serviceType == Channel.SERVICE_TYPE_ATSC_AUDIO || + serviceType == Channel.SERVICE_TYPE_ATSC_DIGITAL_TELEVISION || + serviceType == Channel.SERVICE_TYPE_UNASSOCIATED_SMALL_SCREEN_SERVICE)) { + // Hide hidden, encrypted, or unsupported ATSC service type channels + results.add(new VctItem(shortName, longName, serviceType, channelTsid, + programNumber, majorNumber, minorNumber, sourceId)); + } + } + // Skip the remaining descriptor part which we don't use. + + if (mListener != null) { + mListener.onVctParsed(results, sectionNumber, lastSectionNumber); + } + return true; + } + + private boolean parseEIT(byte[] data) { + // For details of the structure for EIT, see ATSC A/65 Table 6.11. + if (DEBUG) { + Log.d(TAG, "EIT is discovered."); + } + if (data.length <= 9) { + Log.e(TAG, "Broken EIT."); + return false; + } + int sourceId = ((data[3] & 0xff) << 8) | (data[4] & 0xff); + int numEventsInSection = (data[9] & 0xff); + + int pos = 10; + List<EitItem> results = new ArrayList<>(); + for (int i = 0; i < numEventsInSection; ++i) { + if (data.length <= pos + 9) { + Log.e(TAG, "Broken EIT."); + return false; + } + if ((data[pos] & 0xc0) != 0xc0) { + Log.e(TAG, "Broken EIT."); + return false; + } + int eventId = ((data[pos] & 0x3f) << 8) + (data[pos + 1] & 0xff); + long startTime = ((data[pos + 2] & (long) 0xff) << 24) | ((data[pos + 3] & 0xff) << 16) + | ((data[pos + 4] & 0xff) << 8) | (data[pos + 5] & 0xff); + int lengthInSecond = ((data[pos + 6] & 0x0f) << 16) + | ((data[pos + 7] & 0xff) << 8) | (data[pos + 8] & 0xff); + int titleLength = (data[pos + 9] & 0xff); + if (data.length <= pos + 10 + titleLength + 1) { + Log.e(TAG, "Broken EIT."); + return false; + } + String titleText = ""; + if (titleLength > 0) { + titleText = extractText(data, pos + 10); + } + if ((data[pos + 10 + titleLength] & 0xf0) != 0xf0) { + Log.e(TAG, "Broken EIT."); + return false; + } + int descriptorsLength = ((data[pos + 10 + titleLength] & 0x0f) << 8) + | (data[pos + 10 + titleLength + 1] & 0xff); + int descriptorsPos = pos + 10 + titleLength + 2; + if (data.length < descriptorsPos + descriptorsLength) { + Log.e(TAG, "Broken EIT."); + return false; + } + List<TsDescriptor> descriptors = parseDescriptors( + data, descriptorsPos, descriptorsPos + descriptorsLength); + if (DEBUG) { + Log.d(TAG, String.format("EIT descriptors size: %d", descriptors.size())); + } + String contentRating = generateContentRating(descriptors); + String broadcastGenre = generateBroadcastGenre(descriptors); + String canonicalGenre = generateCanonicalGenre(descriptors); + List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors); + List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors); + pos += 10 + titleLength + 2 + descriptorsLength; + results.add(new EitItem(EitItem.INVALID_PROGRAM_ID, eventId, titleText, + startTime, lengthInSecond, contentRating, audioTracks, captionTracks, + broadcastGenre, canonicalGenre, null)); + } + if (mListener != null) { + mListener.onEitParsed(sourceId, results); + } + return true; + } + + private boolean parseETT(byte[] data) { + // For details of the structure for ETT, see ATSC A/65 Table 6.13. + if (DEBUG) { + Log.d(TAG, "ETT is discovered."); + } + if (data.length <= 12) { + Log.e(TAG, "Broken ETT."); + return false; + } + int sourceId = ((data[9] & 0xff) << 8) | (data[10] & 0xff); + int eventId = (((data[11] & 0xff) << 8) | (data[12] & 0xff)) >> 2; + String text = extractText(data, 13); + List<EttItem> ettItems = mParsedEttItems.get(sourceId); + if (ettItems == null) { + ettItems = new ArrayList<>(); + mParsedEttItems.put(sourceId, ettItems); + } + ettItems.add(new EttItem(eventId, text)); + return true; + } + + private static List<AtscAudioTrack> generateAudioTracks(List<TsDescriptor> descriptors) { + // The list of audio tracks sent is located at both AC3 Audio descriptor and ISO 639 + // Language descriptor. + List<AtscAudioTrack> ac3Tracks = new ArrayList<>(); + List<AtscAudioTrack> iso639LanguageTracks = new ArrayList<>(); + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof Ac3AudioDescriptor) { + Ac3AudioDescriptor audioDescriptor = + (Ac3AudioDescriptor) descriptor; + AtscAudioTrack audioTrack = new AtscAudioTrack(); + if (audioDescriptor.getLanguage() != null) { + audioTrack.language = audioDescriptor.getLanguage(); + } + audioTrack.audioType = AtscAudioTrack.AUDIOTYPE_UNDEFINED; + audioTrack.channelCount = audioDescriptor.getNumChannels(); + audioTrack.sampleRate = audioDescriptor.getSampleRate(); + ac3Tracks.add(audioTrack); + } + } + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof Iso639LanguageDescriptor) { + Iso639LanguageDescriptor iso639LanguageDescriptor = + (Iso639LanguageDescriptor) descriptor; + iso639LanguageTracks.addAll(iso639LanguageDescriptor.getAudioTracks()); + } + } + + // An AC3 audio stream descriptor only has a audio channel count and a audio sample rate + // while a ISO 639 Language descriptor only has a audio type, which describes a main use + // case of its audio track. + // Some channels contain only AC3 audio stream descriptors with valid language values. + // Other channels contain both an AC3 audio stream descriptor and a ISO 639 Language + // descriptor per audio track, and those AC3 audio stream descriptors often have a null + // value of language field. + // Combines two descriptors into one in order to gather more audio track specific + // information as much as possible. + List<AtscAudioTrack> tracks = new ArrayList<>(); + if (!ac3Tracks.isEmpty() && !iso639LanguageTracks.isEmpty() + && ac3Tracks.size() != iso639LanguageTracks.size()) { + // This shouldn't be happen. In here, it handles two cases. The first case is that the + // only one type of descriptors arrives. The second case is that the two types of + // descriptors have the same number of tracks. + Log.e(TAG, "AC3 audio stream descriptors size != ISO 639 Language descriptors size"); + return tracks; + } + int size = Math.max(ac3Tracks.size(), iso639LanguageTracks.size()); + for (int i = 0; i < size; ++i) { + AtscAudioTrack audioTrack = null; + if (i < ac3Tracks.size()) { + audioTrack = ac3Tracks.get(i); + } + if (i < iso639LanguageTracks.size()) { + if (audioTrack == null) { + audioTrack = iso639LanguageTracks.get(i); + } else { + AtscAudioTrack iso639LanguageTrack = iso639LanguageTracks.get(i); + if (audioTrack.language == null || TextUtils.equals(audioTrack.language, "")) { + audioTrack.language = iso639LanguageTrack.language; + } + audioTrack.audioType = iso639LanguageTrack.audioType; + } + } + String language = ISO_LANGUAGE_CODE_MAP.get(audioTrack.language); + if (language != null) { + audioTrack.language = language; + } + tracks.add(audioTrack); + } + return tracks; + } + + private static List<AtscCaptionTrack> generateCaptionTracks(List<TsDescriptor> descriptors) { + List<AtscCaptionTrack> services = new ArrayList<>(); + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof CaptionServiceDescriptor) { + CaptionServiceDescriptor captionServiceDescriptor = + (CaptionServiceDescriptor) descriptor; + services.addAll(captionServiceDescriptor.getCaptionTracks()); + } + } + return services; + } + + private static String generateContentRating(List<TsDescriptor> descriptors) { + List<String> contentRatings = new ArrayList<>(); + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof ContentAdvisoryDescriptor) { + ContentAdvisoryDescriptor contentAdvisoryDescriptor = + (ContentAdvisoryDescriptor) descriptor; + for (RatingRegion ratingRegion : contentAdvisoryDescriptor.getRatingRegions()) { + for (RegionalRating index : ratingRegion.getRegionalRatings()) { + String ratingSystem = null; + String rating = null; + switch (ratingRegion.getName()) { + case RATING_REGION_US_TV: + ratingSystem = RATING_REGION_RATING_SYSTEM_US_TV; + if (index.getDimension() == 0 && index.getRating() >= 0 + && index.getRating() < RATING_REGION_TABLE_US_TV.length) { + rating = RATING_REGION_TABLE_US_TV[index.getRating()]; + } + break; + case RATING_REGION_KR_TV: + ratingSystem = RATING_REGION_RATING_SYSTEM_KR_TV; + if (index.getDimension() == 0 && index.getRating() >= 0 + && index.getRating() < RATING_REGION_TABLE_KR_TV.length) { + rating = RATING_REGION_TABLE_KR_TV[index.getRating()]; + } + break; + default: + break; + } + if (ratingSystem != null && rating != null) { + contentRatings.add(TvContentRating + .createRating("com.android.tv", ratingSystem, rating) + .flattenToString()); + } + } + } + } + } + return TextUtils.join(",", contentRatings); + } + + private static String generateBroadcastGenre(List<TsDescriptor> descriptors) { + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof GenreDescriptor) { + GenreDescriptor genreDescriptor = + (GenreDescriptor) descriptor; + return TextUtils.join(",", genreDescriptor.getBroadcastGenres()); + } + } + return null; + } + + private static String generateCanonicalGenre(List<TsDescriptor> descriptors) { + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof GenreDescriptor) { + GenreDescriptor genreDescriptor = + (GenreDescriptor) descriptor; + return Genres.encode(genreDescriptor.getCanonicalGenres()); + } + } + return null; + } + + private static List<TsDescriptor> parseDescriptors(byte[] data, int offset, int limit) { + // For details of the structure for descriptors, see ATSC A/65 Section 6.9. + List<TsDescriptor> descriptors = new ArrayList<>(); + if (data.length < limit) { + return descriptors; + } + int pos = offset; + while (pos + 1 < limit) { + int tag = data[pos] & 0xff; + int length = data[pos + 1] & 0xff; + if (length <= 0) { + break; + } + if (limit < pos + length + 2) { + break; + } + if (DEBUG) { + Log.d(TAG, String.format("Descriptor tag: %02x", tag)); + } + TsDescriptor descriptor = null; + switch (tag) { + case DESCRIPTOR_TAG_CONTENT_ADVISORY: + descriptor = parseContentAdvisory(data, pos, pos + length + 2); + break; + + case DESCRIPTOR_TAG_CAPTION_SERVICE: + descriptor = parseCaptionService(data, pos, pos + length + 2); + break; + + case DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME: + descriptor = parseLongChannelName(data, pos, pos + length + 2); + break; + + case DESCRIPTOR_TAG_GENRE: + descriptor = parseGenre(data, pos, pos + length + 2); + break; + + case DESCRIPTOR_TAG_AC3_AUDIO_STREAM: + descriptor = parseAc3AudioStream(data, pos, pos + length + 2); + break; + + case DESCRIPTOR_TAG_ISO639LANGUAGE: + descriptor = parseIso639Language(data, pos, pos + length + 2); + break; + + default: + } + if (descriptor != null) { + if (DEBUG) { + Log.d(TAG, "Descriptor parsed: " + descriptor); + } + descriptors.add(descriptor); + } + pos += length + 2; + } + return descriptors; + } + + private static Iso639LanguageDescriptor parseIso639Language(byte[] data, int pos, int limit) { + // For the details of the structure of ISO 639 language descriptor, + // see ISO13818-1 second edition Section 2.6.18. + pos += 2; + List<AtscAudioTrack> audioTracks = new ArrayList<>(); + while (pos + 4 <= limit) { + if (limit <= pos + 3) { + Log.e(TAG, "Broken Iso639Language."); + return null; + } + String language = new String(data, pos, 3); + int audioType = data[pos + 3] & 0xff; + AtscAudioTrack audioTrack = new AtscAudioTrack(); + audioTrack.language = language; + audioTrack.audioType = audioType; + audioTracks.add(audioTrack); + pos += 4; + } + return new Iso639LanguageDescriptor(audioTracks); + } + + private static CaptionServiceDescriptor parseCaptionService(byte[] data, int pos, int limit) { + // For the details of the structure of caption service descriptor, + // see ATSC A/65 Section 6.9.2. + if (limit <= pos + 2) { + Log.e(TAG, "Broken CaptionServiceDescriptor."); + return null; + } + List<AtscCaptionTrack> services = new ArrayList<>(); + pos += 2; + int numberServices = data[pos] & 0x1f; + ++pos; + if (limit < pos + numberServices * 6) { + Log.e(TAG, "Broken CaptionServiceDescriptor."); + return null; + } + for (int i = 0; i < numberServices; ++i) { + String language = new String(Arrays.copyOfRange(data, pos, pos + 3)); + pos += 3; + boolean ccType = (data[pos] & 0x80) != 0; + if (!ccType) { + continue; + } + int captionServiceNumber = data[pos] & 0x3f; + ++pos; + boolean easyReader = (data[pos] & 0x80) != 0; + boolean wideAspectRatio = (data[pos] & 0x40) != 0; + byte[] reserved = new byte[2]; + reserved[0] = (byte) (data[pos] << 2); + reserved[0] |= (byte) ((data[pos + 1] & 0xc0) >>> 6); + reserved[1] = (byte) ((data[pos + 1] & 0x3f) << 2); + pos += 2; + AtscCaptionTrack captionTrack = new AtscCaptionTrack(); + captionTrack.language = language; + captionTrack.serviceNumber = captionServiceNumber; + captionTrack.easyReader = easyReader; + captionTrack.wideAspectRatio = wideAspectRatio; + services.add(captionTrack); + } + return new CaptionServiceDescriptor(services); + } + + private static ContentAdvisoryDescriptor parseContentAdvisory(byte[] data, int pos, int limit) { + // For details of the structure for content advisory descriptor, see A/65 Table 6.27. + if (limit <= pos + 2) { + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + int count = data[pos + 2] & 0x3f; + pos += 3; + List<RatingRegion> ratingRegions = new ArrayList<>(); + for (int i = 0; i < count; ++i) { + if (limit <= pos + 1) { + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + List<RegionalRating> indices = new ArrayList<>(); + int ratingRegion = data[pos] & 0xff; + int dimensionCount = data[pos + 1] & 0xff; + pos += 2; + for (int j = 0; j < dimensionCount; ++j) { + if (limit <= pos + 1) { + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + int dimensionIndex = data[pos] & 0xff; + int ratingValue = data[pos + 1] & 0x0f; + pos += 2; + indices.add(new RegionalRating(dimensionIndex, ratingValue)); + } + if (limit <= pos) { + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + int ratingDescriptionLength = data[pos] & 0xff; + ++pos; + if (limit < pos + ratingDescriptionLength) { + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + String ratingDescription = extractText(data, pos); + pos += ratingDescriptionLength; + ratingRegions.add(new RatingRegion(ratingRegion, ratingDescription, indices)); + } + return new ContentAdvisoryDescriptor(ratingRegions); + } + + private static ExtendedChannelNameDescriptor parseLongChannelName(byte[] data, int pos, + int limit) { + if (limit <= pos + 2) { + Log.e(TAG, "Broken ExtendedChannelName."); + return null; + } + pos += 2; + String text = extractText(data, pos); + if (text == null) { + Log.e(TAG, "Broken ExtendedChannelName."); + return null; + } + return new ExtendedChannelNameDescriptor(text); + } + + private static GenreDescriptor parseGenre(byte[] data, int pos, int limit) { + pos += 2; + int attributeCount = data[pos] & 0x1f; + if (limit <= pos + attributeCount) { + Log.e(TAG, "Broken Genre."); + return null; + } + HashSet<String> broadcastGenreSet = new HashSet<>(); + HashSet<String> canonicalGenreSet = new HashSet<>(); + for (int i = 0; i < attributeCount; ++i) { + ++pos; + int genreCode = data[pos] & 0xff; + if (genreCode < BROADCAST_GENRES_TABLE.length) { + String broadcastGenre = BROADCAST_GENRES_TABLE[genreCode]; + if (broadcastGenre != null && !broadcastGenreSet.contains(broadcastGenre)) { + broadcastGenreSet.add(broadcastGenre); + } + } + if (genreCode < CANONICAL_GENRES_TABLE.length) { + String canonicalGenre = CANONICAL_GENRES_TABLE[genreCode]; + if (canonicalGenre != null && !canonicalGenreSet.contains(canonicalGenre)) { + canonicalGenreSet.add(canonicalGenre); + } + } + } + return new GenreDescriptor(broadcastGenreSet.toArray(new String[broadcastGenreSet.size()]), + canonicalGenreSet.toArray(new String[canonicalGenreSet.size()])); + } + + private static TsDescriptor parseAc3AudioStream(byte[] data, int pos, int limit) { + // For details of the AC3 audio stream descriptor, see A/52 Table A4.1. + if (limit <= pos + 5) { + Log.e(TAG, "Broken AC3 audio stream descriptor."); + return null; + } + pos += 2; + byte sampleRateCode = (byte) ((data[pos] & 0xe0) >> 5); + byte bsid = (byte) (data[pos] & 0x1f); + ++pos; + byte bitRateCode = (byte) ((data[pos] & 0xfc) >> 2); + byte surroundMode = (byte) (data[pos] & 0x03); + ++pos; + byte bsmod = (byte) ((data[pos] & 0xe0) >> 5); + int numChannels = (data[pos] & 0x1e) >> 1; + boolean fullSvc = (data[pos] & 0x01) != 0; + ++pos; + byte langCod = data[pos]; + byte langCod2 = 0; + if (numChannels == 0) { + if (limit <= pos) { + Log.e(TAG, "Broken AC3 audio stream descriptor."); + return null; + } + ++pos; + langCod2 = data[pos]; + } + if (limit <= pos + 1) { + Log.e(TAG, "Broken AC3 audio stream descriptor."); + return null; + } + byte mainId = 0; + byte priority = 0; + byte asvcflags = 0; + ++pos; + if (bsmod < 2) { + mainId = (byte) ((data[pos] & 0xe0) >> 5); + priority = (byte) ((data[pos] & 0x18) >> 3); + if ((data[pos] & 0x07) != 0x07) { + Log.e(TAG, "Broken AC3 audio stream descriptor reserved failed"); + return null; + } + } else { + asvcflags = data[pos]; + } + + // See A/52B Table A3.6 num_channels. + int numEncodedChannels; + switch (numChannels) { + case 1: + case 8: + numEncodedChannels = 1; + break; + case 2: + case 9: + numEncodedChannels = 2; + break; + case 3: + case 4: + case 10: + numEncodedChannels = 3; + break; + case 5: + case 6: + case 11: + numEncodedChannels = 4; + break; + case 7: + case 12: + numEncodedChannels = 5; + break; + case 13: + numEncodedChannels = 6; + break; + default: + numEncodedChannels = 0; + break; + } + + if (limit <= pos + 1) { + Log.w(TAG, "Missing text and language fields on AC3 audio stream descriptor."); + return new Ac3AudioDescriptor(sampleRateCode, bsid, bitRateCode, surroundMode, bsmod, + numEncodedChannels, fullSvc, langCod, langCod2, mainId, priority, asvcflags, + null, null, null); + } + ++pos; + int textLen = (data[pos] & 0xfe) >> 1; + boolean textCode = (data[pos] & 0x01) != 0; + ++pos; + String text = ""; + if (textLen > 0) { + if (limit < pos + textLen) { + Log.e(TAG, "Broken AC3 audio stream descriptor"); + return null; + } + if (textCode) { + text = new String(data, pos, textLen); + } else { + text = new String(data, pos, textLen, Charset.forName("UTF-16")); + } + pos += textLen; + } + String language = null; + String language2 = null; + if (pos < limit) { + // Many AC3 audio stream descriptors skip the language fields. + boolean languageFlag1 = (data[pos] & 0x80) != 0; + boolean languageFlag2 = (data[pos] & 0x40) != 0; + if ((data[pos] & 0x3f) != 0x3f) { + Log.e(TAG, "Broken AC3 audio stream descriptor"); + return null; + } + if (pos + (languageFlag1 ? 3 : 0) + (languageFlag2 ? 3 : 0) > limit) { + Log.e(TAG, "Broken AC3 audio stream descriptor"); + return null; + } + ++pos; + if (languageFlag1) { + language = new String(data, pos, 3); + pos += 3; + } + if (languageFlag2) { + language2 = new String(data, pos, 3); + } + } + + return new Ac3AudioDescriptor(sampleRateCode, bsid, bitRateCode, surroundMode, bsmod, + numEncodedChannels, fullSvc, langCod, langCod2, mainId, priority, asvcflags, text, + language, language2); + } + + private static int getShortNameSize(byte[] data, int offset) { + for (int i = 0; i < MAX_SHORT_NAME_BYTES; i += 2) { + if (data[offset + i] == 0 && data[offset + i + 1] == 0) { + return i; + } + } + return MAX_SHORT_NAME_BYTES; + } + + private static String extractText(byte[] data, int pos) { + if (data.length < pos) { + return null; + } + int numStrings = data[pos] & 0xff; + pos++; + for (int i = 0; i < numStrings; ++i) { + if (data.length <= pos + 3) { + Log.e(TAG, "Broken text."); + return null; + } + int numSegments = data[pos + 3] & 0xff; + pos += 4; + for (int j = 0; j < numSegments; ++j) { + if (data.length <= pos + 2) { + Log.e(TAG, "Broken text."); + return null; + } + int compressionType = data[pos] & 0xff; + int mode = data[pos + 1] & 0xff; + int numBytes = data[pos + 2] & 0xff; + if (data.length < pos + 3 + numBytes) { + Log.e(TAG, "Broken text."); + return null; + } + byte[] bytes = Arrays.copyOfRange(data, pos + 3, pos + 3 + numBytes); + if (compressionType == COMPRESSION_TYPE_NO_COMPRESSION) { + try { + switch (mode) { + case MODE_SELECTED_UNICODE_RANGE_1: + return new String(bytes, "ISO-8859-1"); + case MODE_SCSU: + return UnicodeDecompressor.decompress(bytes); + case MODE_UTF16: + return new String(bytes, "UTF-16"); + } + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Unsupported text format.", e); + } + } + pos += 3 + numBytes; + } + } + return null; + } + + private static boolean checkSanity(byte[] data) { + if (data.length <= 1) { + return false; + } + boolean hasCRC = (data[1] & 0x80) != 0; // section_syntax_indicator + if (hasCRC) { + int crc = 0xffffffff; + for(byte b : data) { + int index = ((crc >> 24) ^ (b & 0xff)) & 0xff; + crc = CRC_TABLE[index] ^ (crc << 8); + } + if(crc != 0){ + return false; + } + } + return true; + } +} diff --git a/src/com/android/tv/tuner/ts/TsParser.java b/src/com/android/tv/tuner/ts/TsParser.java new file mode 100644 index 00000000..c24c2a21 --- /dev/null +++ b/src/com/android/tv/tuner/ts/TsParser.java @@ -0,0 +1,454 @@ +/* + * 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.tuner.ts; + +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseBooleanArray; + +import com.android.tv.tuner.data.PsiData.PatItem; +import com.android.tv.tuner.data.PsiData.PmtItem; +import com.android.tv.tuner.data.PsipData.EitItem; +import com.android.tv.tuner.data.PsipData.EttItem; +import com.android.tv.tuner.data.PsipData.MgtItem; +import com.android.tv.tuner.data.PsipData.VctItem; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.ts.SectionParser.OutputListener; +import com.android.tv.tuner.util.ByteArrayBuffer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +/** + * Parses MPEG-2 TS packets. + */ +public class TsParser { + private static final String TAG = "TsParser"; + private static final boolean DEBUG = false; + + public static final int ATSC_SI_BASE_PID = 0x1ffb; + public static final int PAT_PID = 0x0000; + private static final int TS_PACKET_START_CODE = 0x47; + private static final int TS_PACKET_TEI_MASK = 0x80; + private static final int TS_PACKET_SIZE = 188; + + /* + * Using a SparseArray removes the need to auto box the int key for mStreamMap + * in feedTdPacket which is called 100 times a second. This greatly reduces the + * number of objects created and the frequency of garbage collection. + * Other maps might be suitable for a SparseArray, but the performance + * trade offs must be considered carefully. + * mStreamMap is the only one called at such a high rate. + */ + private final SparseArray<Stream> mStreamMap = new SparseArray<>(); + private final Map<Integer, VctItem> mSourceIdToVctItemMap = new HashMap<>(); + private final Map<Integer, String> mSourceIdToVctItemDescriptionMap = new HashMap<>(); + private final Map<Integer, VctItem> mProgramNumberToVctItemMap = new HashMap<>(); + private final Map<Integer, List<PmtItem>> mProgramNumberToPMTMap = new HashMap<>(); + private final Map<Integer, List<EitItem>> mSourceIdToEitMap = new HashMap<>(); + private final Map<EventSourceEntry, List<EitItem>> mEitMap = new HashMap<>(); + private final Map<EventSourceEntry, List<EttItem>> mETTMap = new HashMap<>(); + private final TreeSet<Integer> mEITPids = new TreeSet<>(); + private final TreeSet<Integer> mETTPids = new TreeSet<>(); + private final SparseBooleanArray mProgramNumberHandledStatus = new SparseBooleanArray(); + private final SparseBooleanArray mVctItemHandledStatus = new SparseBooleanArray(); + private final TsOutputListener mListener; + + private int mVctItemCount; + private int mHandledVctItemCount; + private int mVctSectionParsedCount; + private boolean[] mVctSectionParsed; + + public interface TsOutputListener { + void onPatDetected(List<PatItem> items); + void onEitPidDetected(int pid); + void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems); + void onEitItemParsed(VctItem channel, List<EitItem> items); + void onEttPidDetected(int pid); + void onAllVctItemsParsed(); + } + + private abstract class Stream { + private static final int INVALID_CONTINUITY_COUNTER = -1; + private static final int NUM_CONTINUITY_COUNTER = 16; + + protected int mContinuityCounter = INVALID_CONTINUITY_COUNTER; + protected final ByteArrayBuffer mPacket = new ByteArrayBuffer(TS_PACKET_SIZE); + + public void feedData(byte[] data, int continuityCounter, boolean startIndicator) { + if ((mContinuityCounter + 1) % NUM_CONTINUITY_COUNTER != continuityCounter) { + mPacket.setLength(0); + } + mContinuityCounter = continuityCounter; + handleData(data, startIndicator); + } + + protected abstract void handleData(byte[] data, boolean startIndicator); + } + + private class SectionStream extends Stream { + private final SectionParser mSectionParser; + private final int mPid; + + public SectionStream(int pid) { + mPid = pid; + mSectionParser = new SectionParser(mSectionListener); + } + + @Override + protected void handleData(byte[] data, boolean startIndicator) { + int startPos = 0; + if (mPacket.length() == 0) { + if (startIndicator) { + startPos = (data[0] & 0xff) + 1; + } else { + // Don't know where the section starts yet. Wait until start indicator is on. + return; + } + } else { + if (startIndicator) { + startPos = 1; + } + } + + // When a broken packet is encountered, parsing will stop and return right away. + if (startPos >= data.length) { + mPacket.setLength(0); + return; + } + mPacket.append(data, startPos, data.length - startPos); + mSectionParser.parseSections(mPacket); + } + + private final OutputListener mSectionListener = new OutputListener() { + @Override + public void onPatParsed(List<PatItem> items) { + for (PatItem i : items) { + startListening(i.getPmtPid()); + } + if (mListener != null) { + mListener.onPatDetected(items); + } + } + + @Override + public void onPmtParsed(int programNumber, List<PmtItem> items) { + mProgramNumberToPMTMap.put(programNumber, items); + if (DEBUG) { + Log.d(TAG, "onPMTParsed, programNo " + programNumber + " handledStatus is " + + mProgramNumberHandledStatus.get(programNumber, false)); + } + int statusIndex = mProgramNumberHandledStatus.indexOfKey(programNumber); + if (statusIndex < 0) { + mProgramNumberHandledStatus.put(programNumber, false); + } + if (!mProgramNumberHandledStatus.get(programNumber)) { + VctItem vctItem = mProgramNumberToVctItemMap.get(programNumber); + if (vctItem != null) { + // When PMT is parsed later than VCT. + mProgramNumberHandledStatus.put(programNumber, true); + handleVctItem(vctItem, items); + mHandledVctItemCount++; + if (mHandledVctItemCount >= mVctItemCount + && mVctSectionParsedCount >= mVctSectionParsed.length + && mListener != null) { + mListener.onAllVctItemsParsed(); + } + } + } + } + + @Override + public void onMgtParsed(List<MgtItem> items) { + for (MgtItem i : items) { + if (mStreamMap.get(i.getTableTypePid()) != null) { + continue; + } + if (i.getTableType() >= MgtItem.TABLE_TYPE_EIT_RANGE_START + && i.getTableType() <= MgtItem.TABLE_TYPE_EIT_RANGE_END) { + startListening(i.getTableTypePid()); + mEITPids.add(i.getTableTypePid()); + if (mListener != null) { + mListener.onEitPidDetected(i.getTableTypePid()); + } + } else if (i.getTableType() == MgtItem.TABLE_TYPE_CHANNEL_ETT || + (i.getTableType() >= MgtItem.TABLE_TYPE_ETT_RANGE_START + && i.getTableType() <= MgtItem.TABLE_TYPE_ETT_RANGE_END)) { + startListening(i.getTableTypePid()); + mETTPids.add(i.getTableTypePid()); + if (mListener != null) { + mListener.onEttPidDetected(i.getTableTypePid()); + } + } + } + } + + @Override + public void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber) { + if (mVctSectionParsed == null) { + mVctSectionParsed = new boolean[lastSectionNumber + 1]; + } else if (mVctSectionParsed[sectionNumber]) { + // The current section was handled before. + if (DEBUG) { + Log.d(TAG, "Duplicate VCT section found."); + } + return; + } + mVctSectionParsed[sectionNumber] = true; + mVctSectionParsedCount++; + mVctItemCount += items.size(); + for (VctItem i : items) { + if (DEBUG) Log.d(TAG, "onVCTParsed " + i); + if (i.getSourceId() != 0) { + mSourceIdToVctItemMap.put(i.getSourceId(), i); + i.setDescription(mSourceIdToVctItemDescriptionMap.get(i.getSourceId())); + } + int programNumber = i.getProgramNumber(); + mProgramNumberToVctItemMap.put(programNumber, i); + List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber); + if (pmtList != null) { + mProgramNumberHandledStatus.put(programNumber, true); + handleVctItem(i, pmtList); + mHandledVctItemCount++; + if (mHandledVctItemCount >= mVctItemCount + && mVctSectionParsedCount >= mVctSectionParsed.length + && mListener != null) { + mListener.onAllVctItemsParsed(); + } + } else { + mProgramNumberHandledStatus.put(programNumber, false); + Log.i(TAG, "onVCTParsed, but PMT for programNo " + programNumber + + " is not found yet."); + } + } + } + + @Override + public void onEitParsed(int sourceId, List<EitItem> items) { + if (DEBUG) Log.d(TAG, "onEITParsed " + sourceId); + EventSourceEntry entry = new EventSourceEntry(mPid, sourceId); + mEitMap.put(entry, items); + handleEvents(sourceId); + } + + @Override + public void onEttParsed(int sourceId, List<EttItem> descriptions) { + if (DEBUG) { + Log.d(TAG, String.format("onETTParsed sourceId: %d, descriptions.size(): %d", + sourceId, descriptions.size())); + } + for (EttItem item : descriptions) { + if (item.eventId == 0) { + // Channel description + mSourceIdToVctItemDescriptionMap.put(sourceId, item.text); + VctItem vctItem = mSourceIdToVctItemMap.get(sourceId); + if (vctItem != null) { + vctItem.setDescription(item.text); + List<PmtItem> pmtItems = + mProgramNumberToPMTMap.get(vctItem.getProgramNumber()); + if (pmtItems != null) { + handleVctItem(vctItem, pmtItems); + } + } + } + } + + // Event Information description + EventSourceEntry entry = new EventSourceEntry(mPid, sourceId); + mETTMap.put(entry, descriptions); + handleEvents(sourceId); + } + }; + } + + private static class EventSourceEntry { + public final int pid; + public final int sourceId; + + public EventSourceEntry(int pid, int sourceId) { + this.pid = pid; + this.sourceId = sourceId; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + pid; + result = 31 * result + sourceId; + return result; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof EventSourceEntry) { + EventSourceEntry another = (EventSourceEntry) obj; + return pid == another.pid && sourceId == another.sourceId; + } + return false; + } + } + + private void handleVctItem(VctItem channel, List<PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "handleVctItem " + channel); + } + if (mListener != null) { + mListener.onVctItemParsed(channel, pmtItems); + } + int sourceId = channel.getSourceId(); + int statusIndex = mVctItemHandledStatus.indexOfKey(sourceId); + if (statusIndex < 0) { + mVctItemHandledStatus.put(sourceId, false); + return; + } + if (!mVctItemHandledStatus.valueAt(statusIndex)) { + List<EitItem> eitItems = mSourceIdToEitMap.get(sourceId); + if (eitItems != null) { + // When VCT is parsed later than EIT. + mVctItemHandledStatus.put(sourceId, true); + handleEitItems(channel, eitItems); + } + } + } + + private void handleEitItems(VctItem channel, List<EitItem> items) { + if (mListener != null) { + mListener.onEitItemParsed(channel, items); + } + } + + private void handleEvents(int sourceId) { + Map<Integer, EitItem> itemSet = new HashMap<>(); + for (int pid : mEITPids) { + List<EitItem> eitItems = mEitMap.get(new EventSourceEntry(pid, sourceId)); + if (eitItems != null) { + for (EitItem item : eitItems) { + item.setDescription(null); + itemSet.put(item.getEventId(), item); + } + } + } + for (int pid : mETTPids) { + List<EttItem> ettItems = mETTMap.get(new EventSourceEntry(pid, sourceId)); + if (ettItems != null) { + for (EttItem ettItem : ettItems) { + if (ettItem.eventId != 0) { + EitItem item = itemSet.get(ettItem.eventId); + if (item != null) { + item.setDescription(ettItem.text); + } + } + } + } + } + List<EitItem> items = new ArrayList<>(itemSet.values()); + mSourceIdToEitMap.put(sourceId, items); + VctItem channel = mSourceIdToVctItemMap.get(sourceId); + if (channel != null && mProgramNumberHandledStatus.get(channel.getProgramNumber())) { + mVctItemHandledStatus.put(sourceId, true); + handleEitItems(channel, items); + } else { + mVctItemHandledStatus.put(sourceId, false); + Log.i(TAG, "onEITParsed, but VCT for sourceId " + sourceId + " is not found yet."); + } + } + + /** + * Creates MPEG-2 TS parser. + * @param listener TsOutputListener + */ + public TsParser(TsOutputListener listener) { + startListening(ATSC_SI_BASE_PID); + startListening(PAT_PID); + mListener = listener; + } + + private void startListening(int pid) { + mStreamMap.put(pid, new SectionStream(pid)); + } + + private boolean feedTSPacket(byte[] tsData, int pos) { + if (tsData.length < pos + TS_PACKET_SIZE) { + if (DEBUG) Log.d(TAG, "Data should include a single TS packet."); + return false; + } + if (tsData[pos] != TS_PACKET_START_CODE) { + if (DEBUG) Log.d(TAG, "Invalid ts packet."); + return false; + } + if ((tsData[pos + 1] & TS_PACKET_TEI_MASK) != 0) { + if (DEBUG) Log.d(TAG, "Erroneous ts packet."); + return false; + } + + // For details for the structure of TS packet, see H.222.0 Table 2-2. + int pid = ((tsData[pos + 1] & 0x1f) << 8) | (tsData[pos + 2] & 0xff); + boolean hasAdaptation = (tsData[pos + 3] & 0x20) != 0; + boolean hasPayload = (tsData[pos + 3] & 0x10) != 0; + boolean payloadStartIndicator = (tsData[pos + 1] & 0x40) != 0; + int continuityCounter = tsData[pos + 3] & 0x0f; + Stream stream = mStreamMap.get(pid); + int payloadPos = pos; + payloadPos += hasAdaptation ? 5 + (tsData[pos + 4] & 0xff) : 4; + if (!hasPayload || stream == null) { + // We are not interested in this packet. + return false; + } + if (payloadPos > pos + TS_PACKET_SIZE) { + if (DEBUG) Log.d(TAG, "Payload should be included in a single TS packet."); + return false; + } + stream.feedData(Arrays.copyOfRange(tsData, payloadPos, pos + TS_PACKET_SIZE), + continuityCounter, payloadStartIndicator); + return true; + } + + /** + * Feeds MPEG-2 TS data to parse. + * @param tsData buffer for ATSC TS stream + * @param pos the offset where buffer starts + * @param length The length of available data + */ + public void feedTSData(byte[] tsData, int pos, int length) { + for (; pos <= length - TS_PACKET_SIZE; pos += TS_PACKET_SIZE) { + feedTSPacket(tsData, pos); + } + } + + /** + * Retrieves the channel information regardless of being well-formed. + * @return {@link List} of {@link TunerChannel} + */ + public List<TunerChannel> getMalFormedChannels() { + List<TunerChannel> incompleteChannels = new ArrayList<>(); + for (int i = 0; i < mProgramNumberHandledStatus.size(); i++) { + if (!mProgramNumberHandledStatus.valueAt(i)) { + int programNumber = mProgramNumberHandledStatus.keyAt(i); + List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber); + if (pmtList != null) { + TunerChannel tunerChannel = new TunerChannel(programNumber, pmtList); + incompleteChannels.add(tunerChannel); + } + } + } + return incompleteChannels; + } +} diff --git a/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java new file mode 100644 index 00000000..a16bc522 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java @@ -0,0 +1,706 @@ +/* + * 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.tuner.tvinput; + +import android.content.ComponentName; +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.RemoteException; +import android.support.annotation.Nullable; +import android.text.format.DateUtils; +import android.util.Log; + +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.data.PsipData.EitItem; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.util.ConvertUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Manages the channel info and EPG data through {@link TvInputManager}. + */ +public class ChannelDataManager implements Handler.Callback { + private static final String TAG = "ChannelDataManager"; + + private static final String[] ALL_PROGRAMS_SELECTION_ARGS = new String[] { + TvContract.Programs._ID, + TvContract.Programs.COLUMN_TITLE, + TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_CONTENT_RATING, + TvContract.Programs.COLUMN_BROADCAST_GENRE, + TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + TvContract.Programs.COLUMN_VERSION_NUMBER }; + private static final String[] CHANNEL_DATA_SELECTION_ARGS = new String[] { + TvContract.Channels._ID, + TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, + TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1}; + + private static final int MSG_HANDLE_EVENTS = 1; + private static final int MSG_HANDLE_CHANNEL = 2; + private static final int MSG_BUILD_CHANNEL_MAP = 3; + private static final int MSG_REQUEST_PROGRAMS = 4; + private static final int MSG_CLEAR_CHANNELS = 6; + private static final int MSG_CHECK_VERSION = 7; + + // Throttle the batch operations to avoid TransactionTooLargeException. + private static final int BATCH_OPERATION_COUNT = 100; + // At most 16 days of program information is delivered through an EIT, + // according to the Chapter 6.4 of ATSC Recommended Practice A/69. + private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(16); + + /** + * A version number to enforce consistency of the channel data. + * + * WARNING: If a change in the database serialization lead to breaking the backward + * compatibility, you must increment this value so that the old data are purged, + * and the user is requested to perform the auto-scan again to generate the new data set. + */ + private static final int VERSION = 6; + + private final Context mContext; + private final String mInputId; + private ProgramInfoListener mListener; + private ChannelScanListener mChannelScanListener; + private Handler mChannelScanHandler; + private final HandlerThread mHandlerThread; + private final Handler mHandler; + private final ConcurrentHashMap<Long, TunerChannel> mTunerChannelMap; + private final ConcurrentSkipListMap<TunerChannel, Long> mTunerChannelIdMap; + private final Uri mChannelsUri; + + // Used for scanning + private final ConcurrentSkipListSet<TunerChannel> mScannedChannels; + private final ConcurrentSkipListSet<TunerChannel> mPreviousScannedChannels; + private final AtomicBoolean mIsScanning; + private final AtomicBoolean scanCompleted = new AtomicBoolean(); + + public interface ProgramInfoListener { + + /** + * Invoked when a request for getting programs of a channel has been processed and passes + * the requested channel and the programs retrieved from database to the listener. + */ + void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs); + + /** + * Invoked when programs of a channel have been arrived and passes the arrived channel and + * programs to the listener. + */ + void onProgramsArrived(TunerChannel channel, List<EitItem> programs); + + /** + * Invoked when a channel has been arrived and passes the arrived channel to the listener. + */ + void onChannelArrived(TunerChannel channel); + + /** + * Invoked when the database schema has been changed and the old-format channels have been + * deleted. A receiver should notify to a user that re-scanning channels is necessary. + */ + void onRescanNeeded(); + } + + public interface ChannelScanListener { + /** + * Invoked when all pending channels have been handled. + */ + void onChannelHandlingDone(); + } + + public ChannelDataManager(Context context) { + mContext = context; + mInputId = TvContract.buildInputId(new ComponentName(mContext.getPackageName(), + TunerTvInputService.class.getName())); + mChannelsUri = TvContract.buildChannelsUriForInput(mInputId); + mTunerChannelMap = new ConcurrentHashMap<>(); + mTunerChannelIdMap = new ConcurrentSkipListMap<>(); + mHandlerThread = new HandlerThread("TvInputServiceBackgroundThread"); + mHandlerThread.start(); + mHandler = new Handler(mHandlerThread.getLooper(), this); + mIsScanning = new AtomicBoolean(); + mScannedChannels = new ConcurrentSkipListSet<>(); + mPreviousScannedChannels = new ConcurrentSkipListSet<>(); + } + + // Public methods + public void checkDataVersion(Context context) { + int version = TunerPreferences.getChannelDataVersion(context); + Log.d(TAG, "ChannelDataManager.VERSION=" + VERSION + " (current=" + version + ")"); + if (version == VERSION) { + // Everything is awesome. Return and continue. + return; + } + setCurrentVersion(context); + + if (version == TunerPreferences.CHANNEL_DATA_VERSION_NOT_SET) { + mHandler.sendEmptyMessage(MSG_CHECK_VERSION); + } else { + // The stored channel data seem outdated. Delete them all. + mHandler.sendEmptyMessage(MSG_CLEAR_CHANNELS); + } + } + + public void setCurrentVersion(Context context) { + TunerPreferences.setChannelDataVersion(context, VERSION); + } + + public void setListener(ProgramInfoListener listener) { + mListener = listener; + } + + public void setChannelScanListener(ChannelScanListener listener, Handler handler) { + mChannelScanListener = listener; + mChannelScanHandler = handler; + } + + public void release() { + mHandler.removeCallbacksAndMessages(null); + mHandlerThread.quitSafely(); + } + + public void releaseSafely() { + mHandlerThread.quitSafely(); + } + + public TunerChannel getChannel(long channelId) { + TunerChannel channel = mTunerChannelMap.get(channelId); + if (channel != null) { + return channel; + } + mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP); + byte[] data = null; + try (Cursor cursor = mContext.getContentResolver().query(TvContract.buildChannelUri( + channelId), CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + data = cursor.getBlob(1); + } + } + if (data == null) { + return null; + } + channel = TunerChannel.parseFrom(data); + if (channel == null) { + return null; + } + channel.setChannelId(channelId); + return channel; + } + + public void requestProgramsData(TunerChannel channel) { + mHandler.removeMessages(MSG_REQUEST_PROGRAMS); + mHandler.obtainMessage(MSG_REQUEST_PROGRAMS, channel).sendToTarget(); + } + + public void notifyEventDetected(TunerChannel channel, List<EitItem> items) { + mHandler.obtainMessage(MSG_HANDLE_EVENTS, new ChannelEvent(channel, items)).sendToTarget(); + } + + public void notifyChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + if (mIsScanning.get()) { + // During scanning, channels should be handle first to improve scan time. + // EIT items can be handled in background after channel scan. + mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel)); + } else { + mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel).sendToTarget(); + } + } + + // For scanning process + /** + * Invoked when starting a scanning mode. This method gets the previous channels to detect the + * obsolete channels after scanning and initializes the variables used for scanning. + */ + public void notifyScanStarted() { + mScannedChannels.clear(); + mPreviousScannedChannels.clear(); + try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri, + CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long channelId = cursor.getLong(0); + byte[] data = cursor.getBlob(1); + TunerChannel channel = TunerChannel.parseFrom(data); + if (channel != null) { + channel.setChannelId(channelId); + mPreviousScannedChannels.add(channel); + } + } while (cursor.moveToNext()); + } + } + mIsScanning.set(true); + } + + /** + * Invoked when completing the scanning mode. Passes {@code MSG_SCAN_COMPLETED} to the handler + * in order to wait for finishing the remaining messages in the handler queue. Then removes the + * obsolete channels, which are previously scanned but are not in the current scanned result. + */ + public void notifyScanCompleted() { + // Send a dummy message to check whether there is any MSG_HANDLE_CHANNEL in queue + // and avoid race conditions. + scanCompleted.set(true); + mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, null)); + } + + public void scannedChannelHandlingCompleted() { + mIsScanning.set(false); + if (!mPreviousScannedChannels.isEmpty()) { + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (TunerChannel channel : mPreviousScannedChannels) { + ops.add(ContentProviderOperation.newDelete( + TvContract.buildChannelUri(channel.getChannelId())).build()); + } + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Error deleting obsolete channels", e); + } + } + if (mChannelScanListener != null && mChannelScanHandler != null) { + mChannelScanHandler.post(new Runnable() { + @Override + public void run() { + mChannelScanListener.onChannelHandlingDone(); + } + }); + } else { + Log.e(TAG, "Error. mChannelScanListener is null."); + } + } + + /** + * Returns the number of scanned channels in the scanning mode. + */ + public int getScannedChannelCount() { + return mScannedChannels.size(); + } + + /** + * Removes all callbacks and messages in handler to avoid previous messages from last channel. + */ + public void removeAllCallbacksAndMessages() { + mHandler.removeCallbacksAndMessages(null); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_HANDLE_EVENTS: { + ChannelEvent event = (ChannelEvent) msg.obj; + handleEvents(event.channel, event.eitItems); + return true; + } + case MSG_HANDLE_CHANNEL: { + TunerChannel channel = (TunerChannel) msg.obj; + if (channel != null) { + handleChannel(channel); + } + if (scanCompleted.get() && mIsScanning.get() + && !mHandler.hasMessages(MSG_HANDLE_CHANNEL)) { + // Complete the scan when all found channels have already been handled. + scannedChannelHandlingCompleted(); + } + return true; + } + case MSG_BUILD_CHANNEL_MAP: { + mHandler.removeMessages(MSG_BUILD_CHANNEL_MAP); + buildChannelMap(); + return true; + } + case MSG_REQUEST_PROGRAMS: { + if (mHandler.hasMessages(MSG_REQUEST_PROGRAMS)) { + return true; + } + TunerChannel channel = (TunerChannel) msg.obj; + if (mListener != null) { + mListener.onRequestProgramsResponse(channel, getAllProgramsForChannel(channel)); + } + return true; + } + case MSG_CLEAR_CHANNELS: { + clearChannels(); + return true; + } + case MSG_CHECK_VERSION: { + checkVersion(); + return true; + } + } + return false; + } + + // Private methods + private void handleEvents(TunerChannel channel, List<EitItem> items) { + long channelId = getChannelId(channel); + if (channelId <= 0) { + return; + } + channel.setChannelId(channelId); + + // Schedule the audio and caption tracks of the current program and the programs being + // listed after the current one into TIS. + if (mListener != null) { + mListener.onProgramsArrived(channel, items); + } + + long currentTime = System.currentTimeMillis(); + List<EitItem> oldItems = getAllProgramsForChannel(channel, currentTime, + currentTime + PROGRAM_QUERY_DURATION); + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + // TODO: Find a right way to check if the programs are added outside. + boolean addedOutside = false; + for (EitItem item : oldItems) { + if (item.getEventId() == 0) { + // The event has been added outside TV tuner. + addedOutside = true; + break; + } + } + + // Inserting programs only when there is no overlapping with existing data assuming that: + // 1. external EPG is more accurate and rich and + // 2. the data we add here will be updated when we apply external EPG. + if (addedOutside) { + // oldItemCount cannot be 0 if addedOutside is true. + int oldItemCount = oldItems.size(); + for (EitItem newItem : items) { + if (newItem.getEndTimeUtcMillis() < currentTime) { + continue; + } + long newItemStartTime = newItem.getStartTimeUtcMillis(); + long newItemEndTime = newItem.getEndTimeUtcMillis(); + if (newItemStartTime < oldItems.get(0).getStartTimeUtcMillis()) { + // Start time smaller than that of any old items. Insert if no overlap. + if (newItemEndTime > oldItems.get(0).getStartTimeUtcMillis()) continue; + } else if (newItemStartTime + > oldItems.get(oldItemCount - 1).getStartTimeUtcMillis()) { + // Start time larger than that of any old item. Insert if no overlap. + if (newItemStartTime + < oldItems.get(oldItemCount - 1).getEndTimeUtcMillis()) continue; + } else { + int pos = Collections.binarySearch(oldItems, newItem, + new Comparator<EitItem>() { + @Override + public int compare(EitItem lhs, EitItem rhs) { + return Long.compare(lhs.getStartTimeUtcMillis(), + rhs.getStartTimeUtcMillis()); + } + }); + if (pos >= 0) { + // Same start Time found. Overlapped. + continue; + } + int insertPoint = -1 - pos; + // Check the two adjacent items. + if (newItemStartTime < oldItems.get(insertPoint - 1).getEndTimeUtcMillis() + || newItemEndTime > oldItems.get(insertPoint).getStartTimeUtcMillis()) { + continue; + } + } + ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert( + TvContract.Programs.CONTENT_URI), newItem, channel.getChannelId())); + if (ops.size() >= BATCH_OPERATION_COUNT) { + applyBatch(channel.getName(), ops); + ops.clear(); + } + } + applyBatch(channel.getName(), ops); + return; + } + + List<EitItem> outdatedOldItems = new ArrayList<>(); + Map<Integer, EitItem> newEitItemMap = new HashMap<>(); + for (EitItem item : items) { + newEitItemMap.put(item.getEventId(), item); + } + for (EitItem oldItem : oldItems) { + EitItem item = newEitItemMap.get(oldItem.getEventId()); + if (item == null) { + outdatedOldItems.add(oldItem); + continue; + } + + // Since program descriptions arrive at different time, the older one may have the + // correct program description while the newer one has no clue what value is. + if (oldItem.getDescription() != null && item.getDescription() == null + && oldItem.getEventId() == item.getEventId() + && oldItem.getStartTime() == item.getStartTime() + && oldItem.getLengthInSecond() == item.getLengthInSecond() + && Objects.equals(oldItem.getContentRating(), item.getContentRating()) + && Objects.equals(oldItem.getBroadcastGenre(), item.getBroadcastGenre()) + && Objects.equals(oldItem.getCanonicalGenre(), item.getCanonicalGenre())) { + item.setDescription(oldItem.getDescription()); + } + if (item.compareTo(oldItem) != 0) { + ops.add(buildContentProviderOperation(ContentProviderOperation.newUpdate( + TvContract.buildProgramUri(oldItem.getProgramId())), item, null)); + if (ops.size() >= BATCH_OPERATION_COUNT) { + applyBatch(channel.getName(), ops); + ops.clear(); + } + } + newEitItemMap.remove(item.getEventId()); + } + for (EitItem unverifiedOldItems : outdatedOldItems) { + if (unverifiedOldItems.getStartTimeUtcMillis() > currentTime) { + // The given new EIT item list covers partial time span of EPG. Here, we delete old + // item only when it has an overlapping with the new EIT item list. + long startTime = unverifiedOldItems.getStartTimeUtcMillis(); + long endTime = unverifiedOldItems.getEndTimeUtcMillis(); + for (EitItem item : newEitItemMap.values()) { + long newItemStartTime = item.getStartTimeUtcMillis(); + long newItemEndTime = item.getEndTimeUtcMillis(); + if ((startTime >= newItemStartTime && startTime < newItemEndTime) + || (endTime > newItemStartTime && endTime <= newItemEndTime)) { + ops.add(ContentProviderOperation.newDelete(TvContract.buildProgramUri( + unverifiedOldItems.getProgramId())).build()); + if (ops.size() >= BATCH_OPERATION_COUNT) { + applyBatch(channel.getName(), ops); + ops.clear(); + } + break; + } + } + } + } + for (EitItem item : newEitItemMap.values()) { + if (item.getEndTimeUtcMillis() < currentTime) { + continue; + } + ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert( + TvContract.Programs.CONTENT_URI), item, channel.getChannelId())); + if (ops.size() >= BATCH_OPERATION_COUNT) { + applyBatch(channel.getName(), ops); + ops.clear(); + } + } + + applyBatch(channel.getName(), ops); + } + + private ContentProviderOperation buildContentProviderOperation( + ContentProviderOperation.Builder builder, EitItem item, Long channelId) { + if (channelId != null) { + builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channelId); + } + if (item != null) { + builder.withValue(TvContract.Programs.COLUMN_TITLE, item.getTitleText()) + .withValue(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + item.getStartTimeUtcMillis()) + .withValue(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, + item.getEndTimeUtcMillis()) + .withValue(TvContract.Programs.COLUMN_CONTENT_RATING, + item.getContentRating()) + .withValue(TvContract.Programs.COLUMN_AUDIO_LANGUAGE, + item.getAudioLanguage()) + .withValue(TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + item.getDescription()) + .withValue(TvContract.Programs.COLUMN_VERSION_NUMBER, + item.getEventId()); + } + return builder.build(); + } + + private void applyBatch(String channelName, ArrayList<ContentProviderOperation> operations) { + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, operations); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Error updating EPG " + channelName, e); + } + } + + private void handleChannel(TunerChannel channel) { + long channelId = getChannelId(channel); + ContentValues values = new ContentValues(); + values.put(TvContract.Channels.COLUMN_NETWORK_AFFILIATION, channel.getShortName()); + values.put(TvContract.Channels.COLUMN_SERVICE_TYPE, channel.getServiceTypeName()); + values.put(TvContract.Channels.COLUMN_TRANSPORT_STREAM_ID, channel.getTsid()); + values.put(TvContract.Channels.COLUMN_DISPLAY_NUMBER, channel.getDisplayNumber()); + values.put(TvContract.Channels.COLUMN_DISPLAY_NAME, channel.getName()); + values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, channel.toByteArray()); + values.put(TvContract.Channels.COLUMN_DESCRIPTION, channel.getDescription()); + values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, VERSION); + + if (channelId <= 0) { + values.put(TvContract.Channels.COLUMN_INPUT_ID, mInputId); + values.put(TvContract.Channels.COLUMN_TYPE, "QAM256".equals(channel.getModulation()) + ? TvContract.Channels.TYPE_ATSC_C : TvContract.Channels.TYPE_ATSC_T); + values.put(TvContract.Channels.COLUMN_SERVICE_ID, channel.getProgramNumber()); + + // ATSC doesn't have original_network_id + values.put(TvContract.Channels.COLUMN_ORIGINAL_NETWORK_ID, channel.getFrequency()); + + Uri channelUri = mContext.getContentResolver().insert(TvContract.Channels.CONTENT_URI, + values); + channelId = ContentUris.parseId(channelUri); + } else { + mContext.getContentResolver().update( + TvContract.buildChannelUri(channelId), values, null, null); + } + channel.setChannelId(channelId); + mTunerChannelMap.put(channelId, channel); + mTunerChannelIdMap.put(channel, channelId); + if (mIsScanning.get()) { + mScannedChannels.add(channel); + mPreviousScannedChannels.remove(channel); + } + if (mListener != null) { + mListener.onChannelArrived(channel); + } + } + + private void clearChannels() { + int count = mContext.getContentResolver().delete(mChannelsUri, null, null); + if (count > 0) { + // We have just deleted obsolete data. Now tell the user that he or she needs + // to perform the auto-scan again. + if (mListener != null) { + mListener.onRescanNeeded(); + } + } + } + + private void checkVersion() { + String selection = TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1 + "<>?"; + try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri, + CHANNEL_DATA_SELECTION_ARGS, selection, + new String[] {Integer.toString(VERSION)}, null)) { + if (cursor != null && cursor.moveToFirst()) { + // The stored channel data seem outdated. Delete them all. + clearChannels(); + } + } + } + + private long getChannelId(TunerChannel channel) { + Long channelId = mTunerChannelIdMap.get(channel); + if (channelId != null) { + return channelId; + } + mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP); + try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri, + CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + channelId = cursor.getLong(0); + byte[] providerData = cursor.getBlob(1); + TunerChannel tunerChannel = TunerChannel.parseFrom(providerData); + if (tunerChannel != null && tunerChannel.compareTo(channel) == 0) { + channel.setChannelId(channelId); + mTunerChannelIdMap.put(channel, channelId); + mTunerChannelMap.put(channelId, channel); + return channelId; + } + } while (cursor.moveToNext()); + } + } + return -1; + } + + private List<EitItem> getAllProgramsForChannel(TunerChannel channel) { + return getAllProgramsForChannel(channel, null, null); + } + + private List<EitItem> getAllProgramsForChannel(TunerChannel channel, @Nullable Long startTimeMs, + @Nullable Long endTimeMs) { + List<EitItem> items = new ArrayList<>(); + long channelId = channel.getChannelId(); + Uri programsUri = (startTimeMs == null || endTimeMs == null) ? + TvContract.buildProgramsUriForChannel(channelId) : + TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs); + try (Cursor cursor = mContext.getContentResolver().query(programsUri, + ALL_PROGRAMS_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long id = cursor.getLong(0); + String titleText = cursor.getString(1); + long startTime = ConvertUtils.convertUnixEpochToGPSTime( + cursor.getLong(2) / DateUtils.SECOND_IN_MILLIS); + long endTime = ConvertUtils.convertUnixEpochToGPSTime( + cursor.getLong(3) / DateUtils.SECOND_IN_MILLIS); + int lengthInSecond = (int) (endTime - startTime); + String contentRating = cursor.getString(4); + String broadcastGenre = cursor.getString(5); + String canonicalGenre = cursor.getString(6); + String description = cursor.getString(7); + int eventId = cursor.getInt(8); + EitItem eitItem = new EitItem(id, eventId, titleText, startTime, lengthInSecond, + contentRating, null, null, broadcastGenre, canonicalGenre, description); + items.add(eitItem); + } while (cursor.moveToNext()); + } + } + return items; + } + + private void buildChannelMap() { + ArrayList<TunerChannel> channels = new ArrayList<>(); + try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri, + CHANNEL_DATA_SELECTION_ARGS, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + do { + long channelId = cursor.getLong(0); + byte[] data = cursor.getBlob(1); + TunerChannel channel = TunerChannel.parseFrom(data); + if (channel != null) { + channel.setChannelId(channelId); + channels.add(channel); + } + } while (cursor.moveToNext()); + } + } + mTunerChannelMap.clear(); + mTunerChannelIdMap.clear(); + for (TunerChannel channel : channels) { + mTunerChannelMap.put(channel.getChannelId(), channel); + mTunerChannelIdMap.put(channel, channel.getChannelId()); + } + } + + private static class ChannelEvent { + public final TunerChannel channel; + public final List<EitItem> eitItems; + + public ChannelEvent(TunerChannel channel, List<EitItem> eitItems) { + this.channel = channel; + this.eitItems = eitItems; + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/EventDetector.java b/src/com/android/tv/tuner/tvinput/EventDetector.java new file mode 100644 index 00000000..27bbb8c7 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/EventDetector.java @@ -0,0 +1,261 @@ +/* + * 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.tuner.tvinput; + +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseBooleanArray; + +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.ts.TsParser; +import com.android.tv.tuner.data.PsiData; +import com.android.tv.tuner.data.PsipData; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Detects channels and programs that are emerged or changed while parsing ATSC PSIP information. + */ +public class EventDetector { + private static final String TAG = "EventDetector"; + private static final boolean DEBUG = false; + public static final int ALL_PROGRAM_NUMBERS = -1; + + private final TunerHal mTunerHal; + + private TsParser mTsParser; + private final Set<Integer> mPidSet = new HashSet<>(); + + // To prevent channel duplication + private final Set<Integer> mVctProgramNumberSet = new HashSet<>(); + private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>(); + private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray(); + private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray(); + private final EventListener mEventListener; + private int mFrequency; + private String mModulation; + private int mProgramNumber = ALL_PROGRAM_NUMBERS; + + private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() { + @Override + public void onPatDetected(List<PsiData.PatItem> items) { + for (PsiData.PatItem i : items) { + if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) { + mTunerHal.addPidFilter(i.getPmtPid(), TunerHal.FILTER_TYPE_OTHER); + } + } + } + + @Override + public void onEitPidDetected(int pid) { + startListening(pid); + } + + @Override + public void onEitItemParsed(PsipData.VctItem channel, List<PsipData.EitItem> items) { + TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber()); + if (DEBUG) { + Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " " + + channel.getProgramNumber()); + } + int channelSourceId = channel.getSourceId(); + + // Source id 0 is useful for cases where a cable operator wishes to define a channel for + // which no EPG data is currently available. + // We don't handle such a case. + if (channelSourceId == 0) { + return; + } + + // If at least a one caption track have been found in EIT items for the given channel, + // we starts to interpret the zero tracks as a clearance of the caption tracks. + boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId); + for (PsipData.EitItem item : items) { + if (captionTracksFound) { + break; + } + List<AtscCaptionTrack> captionTracks = item.getCaptionTracks(); + if (captionTracks != null && !captionTracks.isEmpty()) { + captionTracksFound = true; + } + } + mEitCaptionTracksFound.put(channelSourceId, captionTracksFound); + if (captionTracksFound) { + for (PsipData.EitItem item : items) { + item.setHasCaptionTrack(); + } + } + if (tunerChannel != null && mEventListener != null) { + mEventListener.onEventDetected(tunerChannel, items); + } + } + + @Override + public void onEttPidDetected(int pid) { + startListening(pid); + } + + @Override + public void onAllVctItemsParsed() { + if (mEventListener != null) { + mEventListener.onChannelScanDone(); + } + } + + @Override + public void onVctItemParsed(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "onVctItemParsed VCT " + channel); + Log.d(TAG, " PMT " + pmtItems); + } + + // Merges the audio and caption tracks located in PMT items into the tracks of the given + // tuner channel. + TunerChannel tunerChannel = new TunerChannel(channel, pmtItems); + List<AtscAudioTrack> audioTracks = new ArrayList<>(); + List<AtscCaptionTrack> captionTracks = new ArrayList<>(); + for (PsiData.PmtItem pmtItem : pmtItems) { + if (pmtItem.getAudioTracks() != null) { + audioTracks.addAll(pmtItem.getAudioTracks()); + } + if (pmtItem.getCaptionTracks() != null) { + captionTracks.addAll(pmtItem.getCaptionTracks()); + } + } + int channelProgramNumber = channel.getProgramNumber(); + + // If at least a one caption track have been found in VCT items for the given channel, + // we starts to interpret the zero tracks as a clearance of the caption tracks. + boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber) + || !captionTracks.isEmpty(); + mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound); + if (captionTracksFound) { + tunerChannel.setHasCaptionTrack(); + } + tunerChannel.setAudioTracks(audioTracks); + tunerChannel.setCaptionTracks(captionTracks); + tunerChannel.setFrequency(mFrequency); + tunerChannel.setModulation(mModulation); + mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel); + boolean found = mVctProgramNumberSet.contains(channelProgramNumber); + if (!found) { + mVctProgramNumberSet.add(channelProgramNumber); + } + if (mEventListener != null) { + mEventListener.onChannelDetected(tunerChannel, !found); + } + } + }; + + /** + * Listener for detecting ATSC TV channels and receiving EPG data. + */ + public interface EventListener { + + /** + * Fired when new information of an ATSC TV channel arrived. + * + * @param channel an ATSC TV channel + * @param channelArrivedAtFirstTime tells whether this channel arrived at first time + */ + void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime); + + /** + * Fired when new program events of an ATSC TV channel arrived. + * + * @param channel an ATSC TV channel + * @param items a list of EIT items that were received + */ + void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items); + + /** + * Fired when information of all detectable ATSC TV channels in current frequency arrived. + */ + void onChannelScanDone(); + } + + /** + * Creates a detector for ATSC TV channles and program information. + * @param usbTunerInteface {@link TunerHal} + * @param listener for ATSC TV channels and program information + */ + public EventDetector(TunerHal usbTunerInteface, EventListener listener) { + mTunerHal = usbTunerInteface; + mEventListener = listener; + } + + private void reset() { + mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset() + mPidSet.clear(); + mVctProgramNumberSet.clear(); + mVctCaptionTracksFound.clear(); + mEitCaptionTracksFound.clear(); + mChannelMap.clear(); + } + + /** + * Starts detecting channel and program information. + * + * @param frequency The frequency to listen to. + * @param modulation The modulation type. + * @param programNumber The program number if this is for handling tune request. For scanning + * purpose, supply {@link #ALL_PROGRAM_NUMBERS}. + */ + public void startDetecting(int frequency, String modulation, int programNumber) { + reset(); + mFrequency = frequency; + mModulation = modulation; + mProgramNumber = programNumber; + } + + private void startListening(int pid) { + if (mPidSet.contains(pid)) { + return; + } + mPidSet.add(pid); + mTunerHal.addPidFilter(pid, TunerHal.FILTER_TYPE_OTHER); + } + + /** + * Feeds ATSC TS stream to detect channel and program information. + * @param data buffer for ATSC TS stream + * @param startOffset the offset where buffer starts + * @param length The length of available data + */ + public void feedTSStream(byte[] data, int startOffset, int length) { + if (mPidSet.isEmpty()) { + startListening(TsParser.ATSC_SI_BASE_PID); + } + if (mTsParser != null) { + mTsParser.feedTSData(data, startOffset, length); + } + } + + /** + * Retrieves the channel information regardless of being well-formed. + * @return {@link List} of {@link TunerChannel} + */ + public List<TunerChannel> getMalFormedChannels() { + return mTsParser.getMalFormedChannels(); + } +} diff --git a/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java new file mode 100644 index 00000000..46ff4ea1 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java @@ -0,0 +1,210 @@ +/* + * 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.tuner.tvinput; + +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseBooleanArray; + +import com.android.tv.tuner.data.PsiData.PatItem; +import com.android.tv.tuner.data.PsiData.PmtItem; +import com.android.tv.tuner.data.PsipData.EitItem; +import com.android.tv.tuner.data.PsipData.VctItem; +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.source.FileTsStreamer; +import com.android.tv.tuner.ts.TsParser; +import com.android.tv.tuner.tvinput.EventDetector.EventListener; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * PSIP event detector for a file source. + * + * <p>Uses {@link TsParser} to analyze input MPEG-2 transport stream, detects and reports + * various PSIP-related events via {@link TsParser.TsOutputListener}. + */ +public class FileSourceEventDetector { + private static final String TAG = "FileSourceEventDetector"; + private static final boolean DEBUG = true; + public static final int ALL_PROGRAM_NUMBERS = 0; + + private TsParser mTsParser; + private final Set<Integer> mVctProgramNumberSet = new HashSet<>(); + private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>(); + private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray(); + private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray(); + private final EventListener mEventListener; + private FileTsStreamer.StreamProvider mStreamProvider; + private int mProgramNumber = ALL_PROGRAM_NUMBERS; + + public FileSourceEventDetector(EventDetector.EventListener listener) { + mEventListener = listener; + } + + /** + * Starts detecting channel and program information. + * + * @param provider MPEG-2 transport stream source. + * @param programNumber The program number if this is for handling tune request. For scanning + * purpose, supply {@link #ALL_PROGRAM_NUMBERS}. + */ + public void start(FileTsStreamer.StreamProvider provider, int programNumber) { + mStreamProvider = provider; + mProgramNumber = programNumber; + reset(); + } + + private void reset() { + mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset() + mStreamProvider.clearPidFilter(); + mVctProgramNumberSet.clear(); + mVctCaptionTracksFound.clear(); + mEitCaptionTracksFound.clear(); + mChannelMap.clear(); + } + + public void feedTSStream(byte[] data, int startOffset, int length) { + if (mStreamProvider.isFilterEmpty()) { + startListening(TsParser.ATSC_SI_BASE_PID); + startListening(TsParser.PAT_PID); + } + if (mTsParser != null) { + mTsParser.feedTSData(data, startOffset, length); + } + } + + private void startListening(int pid) { + if (mStreamProvider.isInFilter(pid)) { + return; + } + mStreamProvider.addPidFilter(pid); + } + + private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() { + @Override + public void onPatDetected(List<PatItem> items) { + for (PatItem i : items) { + if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) { + mStreamProvider.addPidFilter(i.getPmtPid()); + } + } + } + + @Override + public void onEitPidDetected(int pid) { + startListening(pid); + } + + @Override + public void onEitItemParsed(VctItem channel, List<EitItem> items) { + TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber()); + if (DEBUG) { + Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " " + + channel.getProgramNumber()); + } + int channelSourceId = channel.getSourceId(); + + // Source id 0 is useful for cases where a cable operator wishes to define a channel for + // which no EPG data is currently available. + // We don't handle such a case. + if (channelSourceId == 0) { + return; + } + + // If at least a one caption track have been found in EIT items for the given channel, + // we starts to interpret the zero tracks as a clearance of the caption tracks. + boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId); + for (EitItem item : items) { + if (captionTracksFound) { + break; + } + List<AtscCaptionTrack> captionTracks = item.getCaptionTracks(); + if (captionTracks != null && !captionTracks.isEmpty()) { + captionTracksFound = true; + } + } + mEitCaptionTracksFound.put(channelSourceId, captionTracksFound); + if (captionTracksFound) { + for (EitItem item : items) { + item.setHasCaptionTrack(); + } + } + if (tunerChannel != null && mEventListener != null) { + mEventListener.onEventDetected(tunerChannel, items); + } + } + + @Override + public void onEttPidDetected(int pid) { + startListening(pid); + } + + @Override + public void onAllVctItemsParsed() { + // do nothing. + } + + @Override + public void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "onVctItemParsed VCT " + channel); + Log.d(TAG, " PMT " + pmtItems); + } + + // Merges the audio and caption tracks located in PMT items into the tracks of the given + // tuner channel. + TunerChannel tunerChannel = TunerChannel.forFile(channel, pmtItems); + List<AtscAudioTrack> audioTracks = new ArrayList<>(); + List<AtscCaptionTrack> captionTracks = new ArrayList<>(); + for (PmtItem pmtItem : pmtItems) { + if (pmtItem.getAudioTracks() != null) { + audioTracks.addAll(pmtItem.getAudioTracks()); + } + if (pmtItem.getCaptionTracks() != null) { + captionTracks.addAll(pmtItem.getCaptionTracks()); + } + } + int channelProgramNumber = channel.getProgramNumber(); + + // If at least a one caption track have been found in VCT items for the given channel, + // we starts to interpret the zero tracks as a clearance of the caption tracks. + boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber) + || !captionTracks.isEmpty(); + mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound); + if (captionTracksFound) { + tunerChannel.setHasCaptionTrack(); + } + tunerChannel.setFilepath(mStreamProvider.getFilepath()); + tunerChannel.setAudioTracks(audioTracks); + tunerChannel.setCaptionTracks(captionTracks); + + mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel); + boolean found = mVctProgramNumberSet.contains(channelProgramNumber); + if (!found) { + mVctProgramNumberSet.add(channelProgramNumber); + } + if (mEventListener != null) { + mEventListener.onChannelDetected(tunerChannel, !found); + } + } + }; +} diff --git a/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java new file mode 100644 index 00000000..3908fe6c --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java @@ -0,0 +1,42 @@ +/* + * 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.tuner.tvinput; + +/** + * The listener for buffer events occurred during playback. + */ +public interface PlaybackBufferListener { + + /** + * Invoked when the start position of the buffer has been changed. + * + * @param startTimeMs the new start time of the buffer in millisecond + */ + void onBufferStartTimeChanged(long startTimeMs); + + /** + * Invoked when the state of the buffer has been changed. + * + * @param available whether the buffer is available or not + */ + void onBufferStateChanged(boolean available); + + /** + * Invoked when the disk speed is too slow to write the buffers. + */ + void onDiskTooSlow(); +} diff --git a/src/com/android/tv/tuner/tvinput/TunerDebug.java b/src/com/android/tv/tuner/tvinput/TunerDebug.java new file mode 100644 index 00000000..a7a41ea7 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerDebug.java @@ -0,0 +1,150 @@ +/* + * 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.tuner.tvinput; + +import android.os.SystemClock; +import android.util.Log; + +/** + * A class to maintain various debugging information. + */ +public class TunerDebug { + private static final String TAG = "TunerDebug"; + public static final boolean ENABLED = false; + + private int mVideoFrameDrop; + private int mBytesInQueue; + + private long mAudioPositionUs; + private long mAudioPtsUs; + private long mVideoPtsUs; + + private long mLastAudioPositionUs; + private long mLastAudioPtsUs; + private long mLastVideoPtsUs; + private long mLastCheckTimestampMs; + + private long mAudioPositionUsRate; + private long mAudioPtsUsRate; + private long mVideoPtsUsRate; + + private TunerDebug() { + mVideoFrameDrop = 0; + mLastCheckTimestampMs = SystemClock.elapsedRealtime(); + } + + private static class LazyHolder { + private static final TunerDebug INSTANCE = new TunerDebug(); + } + + public static TunerDebug getInstance() { + return LazyHolder.INSTANCE; + } + + public static void notifyVideoFrameDrop(long delta) { + // TODO: provide timestamp mismatch information using delta + TunerDebug sTunerDebug = getInstance(); + sTunerDebug.mVideoFrameDrop++; + } + + public static int getVideoFrameDrop() { + TunerDebug sTunerDebug = getInstance(); + int videoFrameDrop = sTunerDebug.mVideoFrameDrop; + if (videoFrameDrop > 0) { + Log.d(TAG, "Dropped video frame: " + videoFrameDrop); + } + sTunerDebug.mVideoFrameDrop = 0; + return videoFrameDrop; + } + + public static void setBytesInQueue(int bytesInQueue) { + TunerDebug sTunerDebug = getInstance(); + sTunerDebug.mBytesInQueue = bytesInQueue; + } + + public static int getBytesInQueue() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mBytesInQueue; + } + + public static void setAudioPositionUs(long audioPositionUs) { + TunerDebug sTunerDebug = getInstance(); + sTunerDebug.mAudioPositionUs = audioPositionUs; + } + + public static long getAudioPositionUs() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mAudioPositionUs; + } + + public static void setAudioPtsUs(long audioPtsUs) { + TunerDebug sTunerDebug = getInstance(); + sTunerDebug.mAudioPtsUs = audioPtsUs; + } + + public static long getAudioPtsUs() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mAudioPtsUs; + } + + public static void setVideoPtsUs(long videoPtsUs) { + TunerDebug sTunerDebug = getInstance(); + sTunerDebug.mVideoPtsUs = videoPtsUs; + } + + public static long getVideoPtsUs() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mVideoPtsUs; + } + + public static void calculateDiff() { + TunerDebug sTunerDebug = getInstance(); + long currentTime = SystemClock.elapsedRealtime(); + long duration = currentTime - sTunerDebug.mLastCheckTimestampMs; + if (duration != 0) { + sTunerDebug.mAudioPositionUsRate = + (sTunerDebug.mAudioPositionUs - sTunerDebug.mLastAudioPositionUs) * 1000 + / duration; + sTunerDebug.mAudioPtsUsRate = + (sTunerDebug.mAudioPtsUs - sTunerDebug.mLastAudioPtsUs) * 1000 + / duration; + sTunerDebug.mVideoPtsUsRate = + (sTunerDebug.mVideoPtsUs - sTunerDebug.mLastVideoPtsUs) * 1000 + / duration; + } + + sTunerDebug.mLastAudioPositionUs = sTunerDebug.mAudioPositionUs; + sTunerDebug.mLastAudioPtsUs = sTunerDebug.mAudioPtsUs; + sTunerDebug.mLastVideoPtsUs = sTunerDebug.mVideoPtsUs; + sTunerDebug.mLastCheckTimestampMs = currentTime; + } + + public static long getAudioPositionUsRate() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mAudioPositionUsRate; + } + + public static long getAudioPtsUsRate() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mAudioPtsUsRate; + } + + public static long getVideoPtsUsRate() { + TunerDebug sTunerDebug = getInstance(); + return sTunerDebug.mVideoPtsUsRate; + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java new file mode 100644 index 00000000..acdd149f --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java @@ -0,0 +1,104 @@ +/* + * 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.tuner.tvinput; + +import android.content.Context; +import android.media.tv.TvInputManager; +import android.media.tv.TvInputService; +import android.net.Uri; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.util.Log; + +/** + * Processes DVR recordings, and deletes the previously recorded contents. + */ +public class TunerRecordingSession extends TvInputService.RecordingSession { + private static final String TAG = "TunerRecordingSession"; + private static final boolean DEBUG = false; + + private final TunerRecordingSessionWorker mSessionWorker; + + public TunerRecordingSession(Context context, String inputId, + ChannelDataManager channelDataManager) { + super(context); + mSessionWorker = new TunerRecordingSessionWorker(context, inputId, channelDataManager, + this); + } + + // RecordingSession + @MainThread + @Override + public void onTune(Uri channelUri) { + // TODO(dvr): support calling more than once, http://b/27171225 + if (DEBUG) { + Log.d(TAG, "Requesting recording session tune: " + channelUri); + } + mSessionWorker.tune(channelUri); + } + + @MainThread + @Override + public void onRelease() { + if (DEBUG) { + Log.d(TAG, "Requesting recording session release."); + } + mSessionWorker.release(); + } + + @MainThread + @Override + public void onStartRecording(@Nullable Uri programUri) { + if (DEBUG) { + Log.d(TAG, "Requesting start recording."); + } + mSessionWorker.startRecording(programUri); + } + + @MainThread + @Override + public void onStopRecording() { + if (DEBUG) { + Log.d(TAG, "Requesting stop recording."); + } + mSessionWorker.stopRecording(); + } + + // Called from TunerRecordingSessionImpl in a worker thread. + @WorkerThread + public void onTuned(Uri channelUri) { + if (DEBUG) { + Log.d(TAG, "Notifying recording session tuned."); + } + notifyTuned(channelUri); + } + + @WorkerThread + public void onRecordFinished(final Uri recordedProgramUri) { + if (DEBUG) { + Log.d(TAG, "Notifying record successfully finished."); + } + notifyRecordingStopped(recordedProgramUri); + } + + @WorkerThread + public void onError(int reason) { + Log.w(TAG, "Notifying recording error: " + reason); + notifyError(reason); + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java new file mode 100644 index 00000000..6ec55e4f --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java @@ -0,0 +1,594 @@ +/* + * 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.tuner.tvinput; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.recording.RecordingCapability; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.RecordedProgram; +import com.android.tv.tuner.DvbDeviceAccessor; +import com.android.tv.tuner.data.PsipData; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.exoplayer.ExoPlayerSampleExtractor; +import com.android.tv.tuner.exoplayer.SampleExtractor; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager; +import com.android.tv.tuner.source.TsDataSource; +import com.android.tv.tuner.source.TsDataSourceManager; +import com.android.tv.util.Utils; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Implements a DVR feature. + */ +public class TunerRecordingSessionWorker implements PlaybackBufferListener, + EventDetector.EventListener, SampleExtractor.OnCompletionListener, + Handler.Callback { + private static final String TAG = "TunerRecordingSessionW"; + private static final boolean DEBUG = false; + + private static final String SORT_BY_TIME = TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS + + ", " + TvContract.Programs.COLUMN_CHANNEL_ID + ", " + + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS; + private static final long STORAGE_MONITOR_INTERVAL_MS = TimeUnit.SECONDS.toMillis(4); + private static final long MIN_PARTIAL_RECORDING_DURATION_MS = TimeUnit.SECONDS.toMillis(10); + private static final long PREPARE_RECORDER_POLL_MS = 50; + private static final int MSG_TUNE = 1; + private static final int MSG_START_RECORDING = 2; + private static final int MSG_PREPARE_RECODER = 3; + private static final int MSG_STOP_RECORDING = 4; + private static final int MSG_MONITOR_STORAGE_STATUS = 5; + private static final int MSG_RELEASE = 6; + private final RecordingCapability mCapabilities; + + public RecordingCapability getCapabilities() { + return mCapabilities; + } + + @IntDef({STATE_IDLE, STATE_TUNED, STATE_RECORDING}) + @Retention(RetentionPolicy.SOURCE) + public @interface DvrSessionState {} + private static final int STATE_IDLE = 1; + private static final int STATE_TUNED = 2; + private static final int STATE_RECORDING = 3; + + private static final long CHANNEL_ID_NONE = -1; + + private final Context mContext; + private final ChannelDataManager mChannelDataManager; + private final DvrStorageStatusManager mDvrStorageStatusManager; + private final Handler mHandler; + private final TsDataSourceManager mSourceManager; + private final Random mRandom = new Random(); + + private TsDataSource mTunerSource; + private TunerChannel mChannel; + private File mStorageDir; + private long mRecordStartTime; + private long mRecordEndTime; + private boolean mRecorderRunning; + private BufferManager mBufferManager; + private SampleExtractor mRecorder; + private final TunerRecordingSession mSession; + @DvrSessionState private int mSessionState = STATE_IDLE; + private final String mInputId; + private Uri mProgramUri; + + public TunerRecordingSessionWorker(Context context, String inputId, + ChannelDataManager dataManager, TunerRecordingSession session) { + mRandom.setSeed(System.nanoTime()); + mContext = context; + HandlerThread handlerThread = new HandlerThread(TAG); + handlerThread.start(); + mHandler = new Handler(handlerThread.getLooper(), this); + mDvrStorageStatusManager = + TvApplication.getSingletons(context).getDvrStorageStatusManager(); + mChannelDataManager = dataManager; + mChannelDataManager.checkDataVersion(context); + mSourceManager = TsDataSourceManager.createSourceManager(true); + mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId); + mInputId = inputId; + if (DEBUG) Log.d(TAG, mCapabilities.toString()); + mSession = session; + } + + // PlaybackBufferListener + @Override + public void onBufferStartTimeChanged(long startTimeMs) { } + + @Override + public void onBufferStateChanged(boolean available) { } + + @Override + public void onDiskTooSlow() { } + + // EventDetector.EventListener + @Override + public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + if (mChannel == null || mChannel.compareTo(channel) != 0) { + return; + } + mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); + } + + @Override + public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) { + if (mChannel == null || mChannel.compareTo(channel) != 0) { + return; + } + mChannelDataManager.notifyEventDetected(channel, items); + } + + @Override + public void onChannelScanDone() { + // do nothing. + } + + // SampleExtractor.OnCompletionListener + @Override + public void onCompletion(boolean success, long lastExtractedPositionUs) { + onRecordingResult(success, lastExtractedPositionUs); + reset(); + } + + /** + * Tunes to {@code channelUri}. + */ + @MainThread + public void tune(Uri channelUri) { + mHandler.removeCallbacksAndMessages(null); + mHandler.obtainMessage(MSG_TUNE, channelUri).sendToTarget(); + } + + /** + * Starts recording. + */ + @MainThread + public void startRecording(@Nullable Uri programUri) { + mHandler.obtainMessage(MSG_START_RECORDING, programUri).sendToTarget(); + } + + /** + * Stops recording. + */ + @MainThread + public void stopRecording() { + mHandler.sendEmptyMessage(MSG_STOP_RECORDING); + } + + /** + * Releases all resources. + */ + @MainThread + public void release() { + mHandler.removeCallbacksAndMessages(null); + mHandler.sendEmptyMessage(MSG_RELEASE); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_TUNE: { + Uri channelUri = (Uri) msg.obj; + if (DEBUG) Log.d(TAG, "Tune to " + channelUri); + if (doTune(channelUri)) { + mSession.onTuned(channelUri); + } else { + reset(); + } + return true; + } + case MSG_START_RECORDING: { + if (DEBUG) Log.d(TAG, "Start recording"); + if (!doStartRecording((Uri) msg.obj)) { + reset(); + } + return true; + } + case MSG_PREPARE_RECODER: { + if (DEBUG) Log.d(TAG, "Preparing recorder"); + if (!mRecorderRunning) { + return true; + } + try { + if (!mRecorder.prepare()) { + mHandler.sendEmptyMessageDelayed(MSG_PREPARE_RECODER, + PREPARE_RECORDER_POLL_MS); + } + } catch (IOException e) { + Log.w(TAG, "Failed to start recording. Couldn't prepare an extractor"); + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + reset(); + } + return true; + } + case MSG_STOP_RECORDING: { + if (DEBUG) Log.d(TAG, "Stop recording"); + if (mSessionState != STATE_RECORDING) { + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + reset(); + return true; + } + if (mRecorderRunning) { + stopRecorder(); + } + return true; + } + case MSG_MONITOR_STORAGE_STATUS: { + if (mSessionState != STATE_RECORDING) { + return true; + } + if (!mDvrStorageStatusManager.isStorageSufficient()) { + if (mRecorderRunning) { + stopRecorder(); + } + new DeleteRecordingTask().execute(mStorageDir); + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + reset(); + } else { + mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS, + STORAGE_MONITOR_INTERVAL_MS); + } + return true; + } + case MSG_RELEASE: { + // Since release was requested, current recording will be cancelled + // without notification. + reset(); + mSourceManager.release(); + mHandler.removeCallbacksAndMessages(null); + mHandler.getLooper().quitSafely(); + return true; + } + } + return false; + } + + @Nullable + private TunerChannel getChannel(Uri channelUri) { + if (channelUri == null) { + return null; + } + long channelId; + try { + channelId = ContentUris.parseId(channelUri); + } catch (UnsupportedOperationException | NumberFormatException e) { + channelId = CHANNEL_ID_NONE; + } + return (channelId == CHANNEL_ID_NONE) ? null : mChannelDataManager.getChannel(channelId); + } + + private String getStorageKey() { + long prefix = System.currentTimeMillis(); + int suffix = mRandom.nextInt(); + return String.format(Locale.ENGLISH, "%016x_%016x", prefix, suffix); + } + + private void reset() { + if (mRecorder != null) { + mRecorder.release(); + mRecorder = null; + } + if (mBufferManager != null) { + mBufferManager.close(); + mBufferManager = null; + } + if (mTunerSource != null) { + mSourceManager.releaseDataSource(mTunerSource); + mTunerSource = null; + } + mSessionState = STATE_IDLE; + mRecorderRunning = false; + } + + private boolean doTune(Uri channelUri) { + if (mSessionState != STATE_IDLE) { + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.e(TAG, "Tuning was requested from wrong status."); + return false; + } + mChannel = getChannel(channelUri); + if (mChannel == null) { + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel); + return false; + } + if (!mDvrStorageStatusManager.isStorageSufficient()) { + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Log.w(TAG, "Tuning failed due to insufficient storage."); + return false; + } + mTunerSource = mSourceManager.createDataSource(mContext, mChannel, this); + if (mTunerSource == null) { + mSession.onError(TvInputManager.RECORDING_ERROR_RESOURCE_BUSY); + Log.w(TAG, "Tuner stream cannot be created due to resource shortage."); + return false; + } + mSessionState = STATE_TUNED; + return true; + } + + private boolean doStartRecording(@Nullable Uri programUri) { + if (mSessionState != STATE_TUNED) { + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.e(TAG, "Recording session status abnormal"); + return false; + } + mStorageDir = mDvrStorageStatusManager.isStorageSufficient() ? + new File(mDvrStorageStatusManager.getRecordingRootDataDirectory(), + getStorageKey()) : null; + if (mStorageDir == null) { + mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Log.w(TAG, "Failed to start recording due to insufficient storage."); + return false; + } + // Since tuning might be happened a while ago, shifts the start position of tuned source. + mTunerSource.shiftStartPosition(mTunerSource.getBufferedPosition()); + mBufferManager = new BufferManager(new DvrStorageManager(mStorageDir, true)); + mRecordStartTime = System.currentTimeMillis(); + mRecorder = new ExoPlayerSampleExtractor(Uri.EMPTY, mTunerSource, mBufferManager, this, + true); + mRecorder.setOnCompletionListener(this, mHandler); + mProgramUri = programUri; + mSessionState = STATE_RECORDING; + mRecorderRunning = true; + mHandler.sendEmptyMessage(MSG_PREPARE_RECODER); + mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS); + mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS, + STORAGE_MONITOR_INTERVAL_MS); + return true; + } + + private void stopRecorder() { + // Do not change session status. + if (mRecorder != null) { + mRecorder.release(); + mRecordEndTime = System.currentTimeMillis(); + mRecorder = null; + } + mRecorderRunning = false; + mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS); + Log.i(TAG, "Recording stopped"); + } + + private static class Program { + private final long mChannelId; + private final String mTitle; + private String mSeriesId; + private final String mSeasonTitle; + private final String mEpisodeTitle; + private final String mSeasonNumber; + private final String mEpisodeNumber; + private final String mDescription; + private final String mPosterArtUri; + private final String mThumbnailUri; + private final String mCanonicalGenres; + private final String mContentRatings; + private final long mStartTimeUtcMillis; + private final long mEndTimeUtcMillis; + private final int mVideoWidth; + private final int mVideoHeight; + private final byte[] mInternalProviderData; + + private static final String[] PROJECTION = { + TvContract.Programs.COLUMN_CHANNEL_ID, + TvContract.Programs.COLUMN_TITLE, + TvContract.Programs.COLUMN_SEASON_TITLE, + TvContract.Programs.COLUMN_EPISODE_TITLE, + TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER, + TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER, + TvContract.Programs.COLUMN_SHORT_DESCRIPTION, + TvContract.Programs.COLUMN_POSTER_ART_URI, + TvContract.Programs.COLUMN_THUMBNAIL_URI, + TvContract.Programs.COLUMN_CANONICAL_GENRE, + TvContract.Programs.COLUMN_CONTENT_RATING, + TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, + TvContract.Programs.COLUMN_VIDEO_WIDTH, + TvContract.Programs.COLUMN_VIDEO_HEIGHT, + TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA + }; + + public Program(Cursor cursor) { + int index = 0; + mChannelId = cursor.getLong(index++); + mTitle = cursor.getString(index++); + mSeasonTitle = cursor.getString(index++); + mEpisodeTitle = cursor.getString(index++); + mSeasonNumber = cursor.getString(index++); + mEpisodeNumber = cursor.getString(index++); + mDescription = cursor.getString(index++); + mPosterArtUri = cursor.getString(index++); + mThumbnailUri = cursor.getString(index++); + mCanonicalGenres = cursor.getString(index++); + mContentRatings = cursor.getString(index++); + mStartTimeUtcMillis = cursor.getLong(index++); + mEndTimeUtcMillis = cursor.getLong(index++); + mVideoWidth = cursor.getInt(index++); + mVideoHeight = cursor.getInt(index++); + mInternalProviderData = cursor.getBlob(index++); + SoftPreconditions.checkArgument(index == PROJECTION.length); + } + + public Program(long channelId) { + mChannelId = channelId; + mTitle = "Unknown"; + mSeasonTitle = ""; + mEpisodeTitle = ""; + mSeasonNumber = ""; + mEpisodeNumber = ""; + mDescription = "Unknown"; + mPosterArtUri = null; + mThumbnailUri = null; + mCanonicalGenres = null; + mContentRatings = null; + mStartTimeUtcMillis = 0; + mEndTimeUtcMillis = 0; + mVideoWidth = 0; + mVideoHeight = 0; + mInternalProviderData = null; + } + + public static Program onQuery(Cursor c) { + Program program = null; + if (c != null && c.moveToNext()) { + program = new Program(c); + } + return program; + } + + public ContentValues buildValues() { + ContentValues values = new ContentValues(); + int index = 0; + values.put(PROJECTION[index++], mChannelId); + values.put(PROJECTION[index++], mTitle); + values.put(PROJECTION[index++], mSeasonTitle); + values.put(PROJECTION[index++], mEpisodeTitle); + values.put(PROJECTION[index++], mSeasonNumber); + values.put(PROJECTION[index++], mEpisodeNumber); + values.put(PROJECTION[index++], mDescription); + values.put(PROJECTION[index++], mPosterArtUri); + values.put(PROJECTION[index++], mThumbnailUri); + values.put(PROJECTION[index++], mCanonicalGenres); + values.put(PROJECTION[index++], mContentRatings); + values.put(PROJECTION[index++], mStartTimeUtcMillis); + values.put(PROJECTION[index++], mEndTimeUtcMillis); + values.put(PROJECTION[index++], mVideoWidth); + values.put(PROJECTION[index++], mVideoHeight); + values.put(PROJECTION[index++], mInternalProviderData); + SoftPreconditions.checkArgument(index == PROJECTION.length); + return values; + } + } + + private Program getRecordedProgram() { + ContentResolver resolver = mContext.getContentResolver(); + Uri programUri = mProgramUri; + if (mProgramUri == null) { + long avg = mRecordStartTime / 2 + mRecordEndTime / 2; + programUri = TvContract.buildProgramsUriForChannel(mChannel.getChannelId(), avg, avg); + } + try (Cursor c = resolver.query(programUri, Program.PROJECTION, null, null, SORT_BY_TIME)) { + if (c != null) { + Program result = Program.onQuery(c); + if (DEBUG) { + Log.v(TAG, "Finished query for " + this); + } + return result; + } else { + if (c == null) { + Log.e(TAG, "Unknown query error for " + this); + } else { + if (DEBUG) Log.d(TAG, "Canceled query for " + this); + } + return null; + } + } + } + + private Uri insertRecordedProgram(Program program, long channelId, String storageUri, + long totalBytes, long startTime, long endTime) { + // TODO: Set title even though program is null. + RecordedProgram recordedProgram = RecordedProgram.builder() + .setInputId(mInputId) + .setChannelId(channelId) + .setDataUri(storageUri) + .setDurationMillis(endTime - startTime) + .setDataBytes(totalBytes) + // startTime and endTime could be overridden by program's start and end value. + .setStartTimeUtcMillis(startTime) + .setEndTimeUtcMillis(endTime) + .build(); + ContentValues values = RecordedProgram.toValues(recordedProgram); + if (program != null) { + values.putAll(program.buildValues()); + } + return mContext.getContentResolver().insert(TvContract.RecordedPrograms.CONTENT_URI, + values); + } + + private void onRecordingResult(boolean success, long lastExtractedPositionUs) { + if (mSessionState != STATE_RECORDING) { + // Error notification is not needed. + Log.e(TAG, "Recording session status abnormal"); + return; + } + if (mRecorderRunning) { + // In case of recorder not being stopped, because of premature termination of recording. + stopRecorder(); + } + if (!success && lastExtractedPositionUs < + TimeUnit.MILLISECONDS.toMicros(MIN_PARTIAL_RECORDING_DURATION_MS)) { + new DeleteRecordingTask().execute(mStorageDir); + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.w(TAG, "Recording failed during recording"); + return; + } + Log.i(TAG, "recording finished " + (success ? "completely" : "partially")); + Uri uri = insertRecordedProgram(getRecordedProgram(), mChannel.getChannelId(), + Uri.fromFile(mStorageDir).toString(), 1024 * 1024, mRecordStartTime, + mRecordStartTime + TimeUnit.MICROSECONDS.toMillis(lastExtractedPositionUs)); + if (uri == null) { + new DeleteRecordingTask().execute(mStorageDir); + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.e(TAG, "Inserting a recording to DB failed"); + return; + } + mSession.onRecordFinished(uri); + } + + private static class DeleteRecordingTask extends AsyncTask<File, Void, Void> { + + @Override + public Void doInBackground(File... files) { + if (files == null || files.length == 0) { + return null; + } + for(File file : files) { + Utils.deleteDirOrFile(file); + } + return null; + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerSession.java b/src/com/android/tv/tuner/tvinput/TunerSession.java new file mode 100644 index 00000000..abfd2b30 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerSession.java @@ -0,0 +1,312 @@ +/* + * 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.tuner.tvinput; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.PlaybackParams; +import android.media.tv.TvContentRating; +import android.media.tv.TvInputManager; +import android.media.tv.TvInputService; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.text.Html; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.exoplayer.audio.AudioCapabilities; +import com.android.tv.tuner.R; +import com.android.tv.tuner.cc.CaptionLayout; +import com.android.tv.tuner.cc.CaptionTrackRenderer; +import com.android.tv.tuner.data.Cea708Data.CaptionEvent; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.util.GlobalSettingsUtils; +import com.android.tv.tuner.util.StatusTextUtils; +import com.android.tv.tuner.util.SystemPropertiesProxy; + +/** + * Provides a tuner TV input session. It handles Overlay UI works. Main tuner input functions + * are implemented in {@link TunerSessionWorker}. + */ +public class TunerSession extends TvInputService.Session implements Handler.Callback { + private static final String TAG = "TunerSession"; + private static final boolean DEBUG = false; + private static final String USBTUNER_SHOW_DEBUG = "persist.tv.tuner.show_debug"; + + public static final int MSG_UI_SHOW_MESSAGE = 1; + public static final int MSG_UI_HIDE_MESSAGE = 2; + public static final int MSG_UI_SHOW_AUDIO_UNPLAYABLE = 3; + public static final int MSG_UI_HIDE_AUDIO_UNPLAYABLE = 4; + public static final int MSG_UI_PROCESS_CAPTION_TRACK = 5; + public static final int MSG_UI_START_CAPTION_TRACK = 6; + public static final int MSG_UI_STOP_CAPTION_TRACK = 7; + public static final int MSG_UI_RESET_CAPTION_TRACK = 8; + public static final int MSG_UI_SET_STATUS_TEXT = 9; + public static final int MSG_UI_TOAST_RESCAN_NEEDED = 10; + + private final Context mContext; + private final Handler mUiHandler; + private final View mOverlayView; + private final TextView mMessageView; + private final TextView mStatusView; + private final TextView mAudioStatusView; + private final ViewGroup mMessageLayout; + private final CaptionTrackRenderer mCaptionTrackRenderer; + private final TunerSessionWorker mSessionWorker; + private boolean mReleased = false; + private boolean mPlayPaused; + private long mTuneStartTimestamp; + + public TunerSession(Context context, ChannelDataManager channelDataManager, + BufferManager bufferManager) { + super(context); + mContext = context; + mUiHandler = new Handler(this); + LayoutInflater inflater = (LayoutInflater) + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mOverlayView = inflater.inflate(R.layout.ut_overlay_view, null); + mMessageLayout = (ViewGroup) mOverlayView.findViewById(R.id.message_layout); + mMessageLayout.setVisibility(View.INVISIBLE); + mMessageView = (TextView) mOverlayView.findViewById(R.id.message); + mStatusView = (TextView) mOverlayView.findViewById(R.id.tuner_status); + boolean showDebug = SystemPropertiesProxy.getBoolean(USBTUNER_SHOW_DEBUG, false); + mStatusView.setVisibility(showDebug ? View.VISIBLE : View.INVISIBLE); + mAudioStatusView = (TextView) mOverlayView.findViewById(R.id.audio_status); + mAudioStatusView.setVisibility(View.INVISIBLE); + mAudioStatusView.setText(Html.fromHtml(StatusTextUtils.getAudioWarningInHTML( + context.getString(R.string.ut_surround_sound_disabled)))); + CaptionLayout captionLayout = (CaptionLayout) mOverlayView.findViewById(R.id.caption); + mCaptionTrackRenderer = new CaptionTrackRenderer(captionLayout); + mSessionWorker = new TunerSessionWorker(context, channelDataManager, + bufferManager, this); + } + + public boolean isReleased() { + return mReleased; + } + + @Override + public View onCreateOverlayView() { + return mOverlayView; + } + + @Override + public boolean onSelectTrack(int type, String trackId) { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_SELECT_TRACK, type, 0, trackId); + return false; + } + + @Override + public void onSetCaptionEnabled(boolean enabled) { + mSessionWorker.setCaptionEnabled(enabled); + } + + @Override + public void onSetStreamVolume(float volume) { + mSessionWorker.setStreamVolume(volume); + } + + @Override + public boolean onSetSurface(Surface surface) { + mSessionWorker.setSurface(surface); + return true; + } + + @Override + public void onTimeShiftPause() { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_PAUSE); + mPlayPaused = true; + } + + @Override + public void onTimeShiftResume() { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_RESUME); + mPlayPaused = false; + } + + @Override + public void onTimeShiftSeekTo(long timeMs) { + if (DEBUG) Log.d(TAG, "Timeshift seekTo requested position: " + timeMs / 1000); + mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_SEEK_TO, + mPlayPaused ? 1 : 0, 0, timeMs); + } + + @Override + public void onTimeShiftSetPlaybackParams(PlaybackParams params) { + mSessionWorker.sendMessage( + TunerSessionWorker.MSG_TIMESHIFT_SET_PLAYBACKPARAMS, params); + } + + @Override + public long onTimeShiftGetStartPosition() { + return mSessionWorker.getStartPosition(); + } + + @Override + public long onTimeShiftGetCurrentPosition() { + return mSessionWorker.getCurrentPosition(); + } + + @Override + public boolean onTune(Uri channelUri) { + if (DEBUG) { + Log.d(TAG, "onTune to " + channelUri != null ? channelUri.toString() : ""); + } + if (channelUri == null) { + Log.w(TAG, "onTune() is failed due to null channelUri."); + mSessionWorker.stopTune(); + return false; + } + mTuneStartTimestamp = SystemClock.elapsedRealtime(); + mSessionWorker.tune(channelUri); + mPlayPaused = false; + return true; + } + + @TargetApi(Build.VERSION_CODES.N) + @Override + public void onTimeShiftPlay(Uri recordUri) { + if (recordUri == null) { + Log.w(TAG, "onTimeShiftPlay() is failed due to null channelUri."); + mSessionWorker.stopTune(); + return; + } + mTuneStartTimestamp = SystemClock.elapsedRealtime(); + mSessionWorker.tune(recordUri); + mPlayPaused = false; + } + + @Override + public void onUnblockContent(TvContentRating unblockedRating) { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_UNBLOCKED_RATING, + unblockedRating); + } + + @Override + public void onRelease() { + if (DEBUG) { + Log.d(TAG, "onRelease"); + } + mReleased = true; + mSessionWorker.release(); + mUiHandler.removeCallbacksAndMessages(null); + } + + /** + * Sets {@link AudioCapabilities}. + */ + public void setAudioCapabilities(AudioCapabilities audioCapabilities) { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_AUDIO_CAPABILITIES_CHANGED, + audioCapabilities); + } + + @Override + public void notifyVideoAvailable() { + super.notifyVideoAvailable(); + if (mTuneStartTimestamp != 0) { + Log.i(TAG, "[Profiler] Video available in " + + (SystemClock.elapsedRealtime() - mTuneStartTimestamp) + " ms"); + mTuneStartTimestamp = 0; + } + } + + @Override + public void notifyVideoUnavailable(int reason) { + super.notifyVideoUnavailable(reason); + if (reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING + && reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL) { + notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE); + } + } + + public void sendUiMessage(int message) { + mUiHandler.sendEmptyMessage(message); + } + + public void sendUiMessage(int message, Object object) { + mUiHandler.obtainMessage(message, object).sendToTarget(); + } + + public void sendUiMessage(int message, int arg1, int arg2, Object object) { + mUiHandler.obtainMessage(message, arg1, arg2, object).sendToTarget(); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_UI_SHOW_MESSAGE: { + mMessageView.setText((String) msg.obj); + mMessageLayout.setVisibility(View.VISIBLE); + return true; + } + case MSG_UI_HIDE_MESSAGE: { + mMessageLayout.setVisibility(View.INVISIBLE); + return true; + } + case MSG_UI_SHOW_AUDIO_UNPLAYABLE: { + // Showing message of enabling surround sound only when global surround sound + // setting is "never". + final int value = GlobalSettingsUtils.getEncodedSurroundOutputSettings(mContext); + if (value == GlobalSettingsUtils.ENCODED_SURROUND_OUTPUT_NEVER) { + mAudioStatusView.setVisibility(View.VISIBLE); + } else { + Log.e(TAG, "Audio is unavailable, surround sound setting is " + value); + } + return true; + } + case MSG_UI_HIDE_AUDIO_UNPLAYABLE: { + mAudioStatusView.setVisibility(View.INVISIBLE); + return true; + } + case MSG_UI_PROCESS_CAPTION_TRACK: { + mCaptionTrackRenderer.processCaptionEvent((CaptionEvent) msg.obj); + return true; + } + case MSG_UI_START_CAPTION_TRACK: { + mCaptionTrackRenderer.start((AtscCaptionTrack) msg.obj); + return true; + } + case MSG_UI_STOP_CAPTION_TRACK: { + mCaptionTrackRenderer.stop(); + return true; + } + case MSG_UI_RESET_CAPTION_TRACK: { + mCaptionTrackRenderer.reset(); + return true; + } + case MSG_UI_SET_STATUS_TEXT: { + mStatusView.setText((CharSequence) msg.obj); + return true; + } + case MSG_UI_TOAST_RESCAN_NEEDED: { + Toast.makeText(mContext, R.string.ut_rescan_needed, Toast.LENGTH_LONG).show(); + return true; + } + } + return false; + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java new file mode 100644 index 00000000..c0a613a4 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java @@ -0,0 +1,1583 @@ +/* + * 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.tuner.tvinput; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.media.MediaFormat; +import android.media.PlaybackParams; +import android.media.tv.TvContentRating; +import android.media.tv.TvContract; +import android.media.tv.TvInputManager; +import android.media.tv.TvTrackInfo; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.SystemClock; +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; +import android.support.annotation.WorkerThread; +import android.text.Html; +import android.util.Log; +import android.util.Pair; +import android.util.SparseArray; +import android.view.Surface; +import android.view.accessibility.CaptioningManager; + +import com.google.android.exoplayer.audio.AudioCapabilities; +import com.google.android.exoplayer.ExoPlayer; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvContentRatingCache; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.data.Cea708Data; +import com.android.tv.tuner.data.Channel; +import com.android.tv.tuner.data.PsipData.EitItem; +import com.android.tv.tuner.data.PsipData.TvTracksInterface; +import com.android.tv.tuner.data.Track.AtscAudioTrack; +import com.android.tv.tuner.data.Track.AtscCaptionTrack; +import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.exoplayer.MpegTsRendererBuilder; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager; +import com.android.tv.tuner.exoplayer.MpegTsPlayer; +import com.android.tv.tuner.source.TsDataSource; +import com.android.tv.tuner.source.TsDataSourceManager; +import com.android.tv.tuner.util.StatusTextUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; + +/** + * {@link TunerSessionWorker} implements a handler thread which processes TV input jobs + * such as handling {@link ExoPlayer}, managing a tuner device, trickplay, and so on. + */ +@WorkerThread +public class TunerSessionWorker implements PlaybackBufferListener, + MpegTsPlayer.VideoEventListener, MpegTsPlayer.Listener, EventDetector.EventListener, + ChannelDataManager.ProgramInfoListener, Handler.Callback { + private static final String TAG = "TunerSessionWorker"; + private static final boolean DEBUG = false; + private static final boolean ENABLE_PROFILER = true; + private static final String PLAY_FROM_CHANNEL = "channel"; + + // Public messages + public static final int MSG_SELECT_TRACK = 1; + public static final int MSG_UPDATE_CAPTION_TRACK = 2; + public static final int MSG_SET_STREAM_VOLUME = 3; + public static final int MSG_TIMESHIFT_PAUSE = 4; + public static final int MSG_TIMESHIFT_RESUME = 5; + public static final int MSG_TIMESHIFT_SEEK_TO = 6; + public static final int MSG_TIMESHIFT_SET_PLAYBACKPARAMS = 7; + public static final int MSG_AUDIO_CAPABILITIES_CHANGED = 8; + public static final int MSG_UNBLOCKED_RATING = 9; + + // Private messages + private static final int MSG_TUNE = 1000; + private static final int MSG_RELEASE = 1001; + private static final int MSG_RETRY_PLAYBACK = 1002; + private static final int MSG_START_PLAYBACK = 1003; + private static final int MSG_UPDATE_PROGRAM = 1008; + private static final int MSG_SCHEDULE_OF_PROGRAMS = 1009; + private static final int MSG_UPDATE_CHANNEL_INFO = 1010; + private static final int MSG_TRICKPLAY_BY_SEEK = 1011; + private static final int MSG_SMOOTH_TRICKPLAY_MONITOR = 1012; + private static final int MSG_PARENTAL_CONTROLS = 1015; + private static final int MSG_RESCHEDULE_PROGRAMS = 1016; + private static final int MSG_BUFFER_START_TIME_CHANGED = 1017; + private static final int MSG_CHECK_SIGNAL = 1018; + private static final int MSG_DISCOVER_CAPTION_SERVICE_NUMBER = 1019; + private static final int MSG_RESET_PLAYBACK = 1020; + private static final int MSG_BUFFER_STATE_CHANGED = 1021; + private static final int MSG_PROGRAM_DATA_RESULT = 1022; + private static final int MSG_STOP_TUNE = 1023; + private static final int MSG_SET_SURFACE = 1024; + private static final int MSG_NOTIFY_AUDIO_TRACK_UPDATED = 1025; + + private static final int TS_PACKET_SIZE = 188; + private static final int CHECK_NO_SIGNAL_INITIAL_DELAY_MS = 4000; + private static final int CHECK_NO_SIGNAL_PERIOD_MS = 500; + private static final int RECOVER_STOPPED_PLAYBACK_PERIOD_MS = 2500; + private static final int PARENTAL_CONTROLS_INTERVAL_MS = 5000; + private static final int RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS = 4000; + private static final int RESCHEDULE_PROGRAMS_INTERVAL_MS = 10000; + private static final int RESCHEDULE_PROGRAMS_TOLERANCE_MS = 2000; + // The following 3s is defined empirically. This should be larger than 2s considering video + // key frame interval in the TS stream. + private static final int PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS = 3000; + private static final int PLAYBACK_RETRY_DELAY_MS = 5000; + private static final int MAX_IMMEDIATE_RETRY_COUNT = 5; + private static final long INVALID_TIME = -1; + + // Some examples of the track ids of the audio tracks, "a0", "a1", "a2". + // The number after prefix is being used for indicating a index of the given audio track. + private static final String AUDIO_TRACK_PREFIX = "a"; + + // Some examples of the tracks id of the caption tracks, "s1", "s2", "s3". + // The number after prefix is being used for indicating a index of a caption service number + // of the given caption track. + private static final String SUBTITLE_TRACK_PREFIX = "s"; + private static final int TRACK_PREFIX_SIZE = 1; + private static final String VIDEO_TRACK_ID = "v"; + private static final long BUFFER_UNDERFLOW_BUFFER_MS = 5000; + + // Actual interval would be divided by the speed. + private static final int EXPECTED_KEY_FRAME_INTERVAL_MS = 500; + private static final int MIN_TRICKPLAY_SEEK_INTERVAL_MS = 20; + private static final int TRICKPLAY_MONITOR_INTERVAL_MS = 250; + + private final Context mContext; + private final ChannelDataManager mChannelDataManager; + private final TsDataSourceManager mSourceManager; + private volatile Surface mSurface; + private volatile float mVolume = 1.0f; + private volatile boolean mCaptionEnabled; + private volatile MpegTsPlayer mPlayer; + private volatile TunerChannel mChannel; + private volatile Long mRecordingDuration; + private volatile long mRecordStartTimeMs; + private volatile long mBufferStartTimeMs; + private String mRecordingId; + private final Handler mHandler; + private int mRetryCount; + private final ArrayList<TvTrackInfo> mTvTracks; + private final SparseArray<AtscAudioTrack> mAudioTrackMap; + private final SparseArray<AtscCaptionTrack> mCaptionTrackMap; + private AtscCaptionTrack mCaptionTrack; + private PlaybackParams mPlaybackParams = new PlaybackParams(); + private boolean mPlayerStarted = false; + private boolean mReportedDrawnToSurface = false; + private boolean mReportedWeakSignal = false; + private EitItem mProgram; + private List<EitItem> mPrograms; + private final TvInputManager mTvInputManager; + private boolean mChannelBlocked; + private TvContentRating mUnblockedContentRating; + private long mLastPositionMs; + private AudioCapabilities mAudioCapabilities; + private final CountDownLatch mReleaseLatch = new CountDownLatch(1); + private long mLastLimitInBytes; + private long mLastPositionInBytes; + private final BufferManager mBufferManager; + private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance(); + private final TunerSession mSession; + private int mPlayerState = ExoPlayer.STATE_IDLE; + private long mPreparingStartTimeMs; + private long mBufferingStartTimeMs; + private long mReadyStartTimeMs; + + public TunerSessionWorker(Context context, ChannelDataManager channelDataManager, + BufferManager bufferManager, TunerSession tunerSession) { + if (DEBUG) Log.d(TAG, "TunerSessionWorker created"); + mContext = context; + + // HandlerThread should be set up before it is registered as a listener in the all other + // components. + HandlerThread handlerThread = new HandlerThread(TAG); + handlerThread.start(); + mHandler = new Handler(handlerThread.getLooper(), this); + mSession = tunerSession; + mChannelDataManager = channelDataManager; + mChannelDataManager.setListener(this); + mChannelDataManager.checkDataVersion(mContext); + mSourceManager = TsDataSourceManager.createSourceManager(false); + mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); + mTvTracks = new ArrayList<>(); + mAudioTrackMap = new SparseArray<>(); + mCaptionTrackMap = new SparseArray<>(); + CaptioningManager captioningManager = + (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); + mCaptionEnabled = captioningManager.isEnabled(); + mPlaybackParams.setSpeed(1.0f); + mBufferManager = bufferManager; + mPreparingStartTimeMs = INVALID_TIME; + mBufferingStartTimeMs = INVALID_TIME; + mReadyStartTimeMs = INVALID_TIME; + } + + // Public methods + @MainThread + public void tune(Uri channelUri) { + mHandler.removeCallbacksAndMessages(null); + mSourceManager.setHasPendingTune(); + sendMessage(MSG_TUNE, channelUri); + } + + @MainThread + public void stopTune() { + mHandler.removeCallbacksAndMessages(null); + sendMessage(MSG_STOP_TUNE); + } + + /** + * Sets {@link Surface}. + */ + @MainThread + public void setSurface(Surface surface) { + if (surface != null && !surface.isValid()) { + Log.w(TAG, "Ignoring invalid surface."); + return; + } + // mSurface is kept even when tune is called right after. But, messages can be deleted by + // tune or updateChannelBlockStatus. So mSurface should be stored here, not through message. + mSurface = surface; + mHandler.sendEmptyMessage(MSG_SET_SURFACE); + } + + /** + * Sets volume. + */ + @MainThread + public void setStreamVolume(float volume) { + // mVolume is kept even when tune is called right after. But, messages can be deleted by + // tune or updateChannelBlockStatus. So mVolume is stored here and mPlayer.setVolume will be + // called in MSG_SET_STREAM_VOLUME. + mVolume = volume; + mHandler.sendEmptyMessage(MSG_SET_STREAM_VOLUME); + } + + /** + * Sets if caption is enabled or disabled. + */ + @MainThread + public void setCaptionEnabled(boolean captionEnabled) { + // mCaptionEnabled is kept even when tune is called right after. But, messages can be + // deleted by tune or updateChannelBlockStatus. So mCaptionEnabled is stored here and + // start/stopCaptionTrack will be called in MSG_UPDATE_CAPTION_STATUS. + mCaptionEnabled = captionEnabled; + mHandler.sendEmptyMessage(MSG_UPDATE_CAPTION_TRACK); + } + + public TunerChannel getCurrentChannel() { + return mChannel; + } + + @MainThread + public long getStartPosition() { + return mBufferStartTimeMs; + } + + + private String getRecordingPath() { + return Uri.parse(mRecordingId).getPath(); + } + + private Long getDurationForRecording(String recordingId) { + try { + DvrStorageManager storageManager = + new DvrStorageManager(new File(getRecordingPath()), false); + Pair<String, MediaFormat> trackInfo = null; + try { + trackInfo = storageManager.readTrackInfoFile(false); + } catch (FileNotFoundException e) { + } + if (trackInfo == null) { + trackInfo = storageManager.readTrackInfoFile(true); + } + Long durationUs = trackInfo.second.getLong(MediaFormat.KEY_DURATION); + // we need duration by milli for trickplay notification. + return durationUs != null ? durationUs / 1000 : null; + } catch (IOException e) { + Log.e(TAG, "meta file for recording was not found: " + recordingId); + return null; + } + } + + @MainThread + public long getCurrentPosition() { + // TODO: More precise time may be necessary. + MpegTsPlayer mpegTsPlayer = mPlayer; + long currentTime = mpegTsPlayer != null + ? mRecordStartTimeMs + mpegTsPlayer.getCurrentPosition() : mRecordStartTimeMs; + if (mChannel == null && mPlayerState == ExoPlayer.STATE_ENDED) { + currentTime = mRecordingDuration + mRecordStartTimeMs; + } + if (DEBUG) { + long systemCurrentTime = System.currentTimeMillis(); + Log.d(TAG, "currentTime = " + currentTime + + " ; System.currentTimeMillis() = " + systemCurrentTime + + " ; diff = " + (currentTime - systemCurrentTime)); + } + return currentTime; + } + + @AnyThread + public void sendMessage(int messageType) { + mHandler.sendEmptyMessage(messageType); + } + + @AnyThread + public void sendMessage(int messageType, Object object) { + mHandler.obtainMessage(messageType, object).sendToTarget(); + } + + @AnyThread + public void sendMessage(int messageType, int arg1, int arg2, Object object) { + mHandler.obtainMessage(messageType, arg1, arg2, object).sendToTarget(); + } + + @MainThread + public void release() { + if (DEBUG) Log.d(TAG, "release()"); + mChannelDataManager.setListener(null); + mHandler.removeCallbacksAndMessages(null); + mHandler.sendEmptyMessage(MSG_RELEASE); + try { + mReleaseLatch.await(); + } catch (InterruptedException e) { + Log.e(TAG, "Couldn't wait for finish of MSG_RELEASE", e); + } finally { + mHandler.getLooper().quitSafely(); + } + } + + // MpegTsPlayer.Listener + // Called in the same thread as mHandler. + @Override + public void onStateChanged(boolean playWhenReady, int playbackState) { + if (DEBUG) Log.d(TAG, "ExoPlayer state change: " + playbackState + " " + playWhenReady); + if (playbackState == mPlayerState) { + return; + } + mReadyStartTimeMs = INVALID_TIME; + mPreparingStartTimeMs = INVALID_TIME; + mBufferingStartTimeMs = INVALID_TIME; + if (playbackState == ExoPlayer.STATE_READY) { + if (DEBUG) Log.d(TAG, "ExoPlayer ready"); + if (!mPlayerStarted) { + sendMessage(MSG_START_PLAYBACK, mPlayer); + } + mReadyStartTimeMs = SystemClock.elapsedRealtime(); + } else if (playbackState == ExoPlayer.STATE_PREPARING) { + mPreparingStartTimeMs = SystemClock.elapsedRealtime(); + } else if (playbackState == ExoPlayer.STATE_BUFFERING) { + mBufferingStartTimeMs = SystemClock.elapsedRealtime(); + } else if (playbackState == ExoPlayer.STATE_ENDED) { + // Final status + // notification of STATE_ENDED from MpegTsPlayer will be ignored afterwards. + Log.i(TAG, "Player ended: end of stream"); + if (mChannel != null) { + sendMessage(MSG_RETRY_PLAYBACK, mPlayer); + } + } + mPlayerState = playbackState; + } + + @Override + public void onError(Exception e) { + if (TunerPreferences.getStoreTsStream(mContext)) { + // Crash intentionally to capture the error causing TS file. + Log.e(TAG, "Crash intentionally to capture the error causing TS file. " + + e.getMessage()); + SoftPreconditions.checkState(false); + } + // There maybe some errors that finally raise ExoPlaybackException and will be handled here. + // If we are playing live stream, retrying playback maybe helpful. But for recorded stream, + // retrying playback is not helpful. + if (mChannel != null) { + mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer).sendToTarget(); + } + } + + @Override + public void onVideoSizeChanged(int width, int height, float pixelWidthHeight) { + if (mChannel != null && mChannel.hasVideo()) { + updateVideoTrack(width, height); + } + if (mRecordingId != null) { + updateVideoTrack(width, height); + } + } + + @Override + public void onDrawnToSurface(MpegTsPlayer player, Surface surface) { + if (mSurface != null && mPlayerStarted) { + if (DEBUG) Log.d(TAG, "MSG_DRAWN_TO_SURFACE"); + mBufferStartTimeMs = mRecordStartTimeMs = + (mRecordingId != null) ? 0 : System.currentTimeMillis(); + notifyVideoAvailable(); + mReportedDrawnToSurface = true; + + // If surface is drawn successfully, it means that the playback was brought back + // to normal and therefore, the playback recovery status will be reset through + // setting a zero value to the retry count. + // TODO: Consider audio only channels for detecting playback status changes to + // be normal. + mRetryCount = 0; + if (mCaptionEnabled && mCaptionTrack != null) { + startCaptionTrack(); + } else { + stopCaptionTrack(); + } + mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED); + } + } + + @Override + public void onSmoothTrickplayForceStopped() { + if (mPlayer == null || !mHandler.hasMessages(MSG_SMOOTH_TRICKPLAY_MONITOR)) { + return; + } + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + doTrickplayBySeek((int) mPlayer.getCurrentPosition()); + } + + @Override + public void onAudioUnplayable() { + if (mPlayer == null) { + return; + } + Log.i(TAG, "AC3 audio cannot be played due to device limitation"); + mSession.sendUiMessage( + TunerSession.MSG_UI_SHOW_AUDIO_UNPLAYABLE); + } + + // MpegTsPlayer.VideoEventListener + @Override + public void onEmitCaptionEvent(Cea708Data.CaptionEvent event) { + mSession.sendUiMessage(TunerSession.MSG_UI_PROCESS_CAPTION_TRACK, event); + } + + @Override + public void onDiscoverCaptionServiceNumber(int serviceNumber) { + sendMessage(MSG_DISCOVER_CAPTION_SERVICE_NUMBER, serviceNumber); + } + + // ChannelDataManager.ProgramInfoListener + @Override + public void onProgramsArrived(TunerChannel channel, List<EitItem> programs) { + sendMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(channel, programs)); + } + + @Override + public void onChannelArrived(TunerChannel channel) { + sendMessage(MSG_UPDATE_CHANNEL_INFO, channel); + } + + @Override + public void onRescanNeeded() { + mSession.sendUiMessage(TunerSession.MSG_UI_TOAST_RESCAN_NEEDED); + } + + @Override + public void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs) { + sendMessage(MSG_PROGRAM_DATA_RESULT, new Pair<>(channel, programs)); + } + + // PlaybackBufferListener + @Override + public void onBufferStartTimeChanged(long startTimeMs) { + sendMessage(MSG_BUFFER_START_TIME_CHANGED, startTimeMs); + } + + @Override + public void onBufferStateChanged(boolean available) { + sendMessage(MSG_BUFFER_STATE_CHANGED, available); + } + + @Override + public void onDiskTooSlow() { + sendMessage(MSG_RETRY_PLAYBACK, mPlayer); + } + + // EventDetector.EventListener + @Override + public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) { + mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); + } + + @Override + public void onEventDetected(TunerChannel channel, List<EitItem> items) { + mChannelDataManager.notifyEventDetected(channel, items); + } + + @Override + public void onChannelScanDone() { + // do nothing. + } + + private long parseChannel(Uri uri) { + try { + List<String> paths = uri.getPathSegments(); + if (paths.size() > 1 && paths.get(0).equals(PLAY_FROM_CHANNEL)) { + return ContentUris.parseId(uri); + } + } catch (UnsupportedOperationException | NumberFormatException e) { + } + return -1; + } + + private static class RecordedProgram { + private final long mChannelId; + private final String mDataUri; + + private static final String[] PROJECTION = { + TvContract.Programs.COLUMN_CHANNEL_ID, + TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI, + }; + + public RecordedProgram(Cursor cursor) { + int index = 0; + mChannelId = cursor.getLong(index++); + mDataUri = cursor.getString(index++); + } + + public RecordedProgram(long channelId, String dataUri) { + mChannelId = channelId; + mDataUri = dataUri; + } + + public static RecordedProgram onQuery(Cursor c) { + RecordedProgram recording = null; + if (c != null && c.moveToNext()) { + recording = new RecordedProgram(c); + } + return recording; + } + + public String getDataUri() { + return mDataUri; + } + } + + private RecordedProgram getRecordedProgram(Uri recordedUri) { + ContentResolver resolver = mContext.getContentResolver(); + try(Cursor c = resolver.query(recordedUri, RecordedProgram.PROJECTION, null, null, null)) { + if (c != null) { + RecordedProgram result = RecordedProgram.onQuery(c); + if (DEBUG) { + Log.d(TAG, "Finished query for " + this); + } + return result; + } else { + if (c == null) { + Log.e(TAG, "Unknown query error for " + this); + } else { + if (DEBUG) Log.d(TAG, "Canceled query for " + this); + } + return null; + } + } + } + + private String parseRecording(Uri uri) { + RecordedProgram recording = getRecordedProgram(uri); + if (recording != null) { + return recording.getDataUri(); + } + return null; + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_TUNE: { + if (DEBUG) Log.d(TAG, "MSG_TUNE"); + + // When sequential tuning messages arrived, it skips middle tuning messages in order + // to change to the last requested channel quickly. + if (mHandler.hasMessages(MSG_TUNE)) { + return true; + } + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + Uri channelUri = (Uri) msg.obj; + String recording = null; + long channelId = parseChannel(channelUri); + TunerChannel channel = (channelId == -1) ? null + : mChannelDataManager.getChannel(channelId); + if (channelId == -1) { + recording = parseRecording(channelUri); + } + if (channel == null && recording == null) { + Log.w(TAG, "onTune() is failed. Can't find channel for " + channelUri); + stopTune(); + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN); + return true; + } + mHandler.removeCallbacksAndMessages(null); + if (channel != null) { + mChannelDataManager.requestProgramsData(channel); + } + prepareTune(channel, recording); + // TODO: Need to refactor. notifyContentAllowed() should not be called if parental + // control is turned on. + mSession.notifyContentAllowed(); + resetPlayback(); + resetTvTracks(); + mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS, + RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS); + return true; + } + case MSG_STOP_TUNE: { + if (DEBUG) Log.d(TAG, "MSG_STOP_TUNE"); + mChannel = null; + stopPlayback(); + stopCaptionTrack(); + resetTvTracks(); + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN); + return true; + } + case MSG_RELEASE: { + if (DEBUG) Log.d(TAG, "MSG_RELEASE"); + mHandler.removeCallbacksAndMessages(null); + stopPlayback(); + stopCaptionTrack(); + mSourceManager.release(); + mReleaseLatch.countDown(); + return true; + } + case MSG_RETRY_PLAYBACK: { + if (mPlayer == msg.obj) { + Log.i(TAG, "Retrying the playback for channel: " + mChannel); + mHandler.removeMessages(MSG_RETRY_PLAYBACK); + // When there is a request of retrying playback, don't reuse TunerHal. + mSourceManager.setKeepTuneStatus(false); + mRetryCount++; + if (DEBUG) { + Log.d(TAG, "MSG_RETRY_PLAYBACK " + mRetryCount); + } + if (mRetryCount <= MAX_IMMEDIATE_RETRY_COUNT) { + resetPlayback(); + } else { + // When it reaches this point, it may be due to an error that occurred in + // the tuner device. Calling stopPlayback() resets the tuner device + // to recover from the error. + stopPlayback(); + stopCaptionTrack(); + + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + + // After MAX_IMMEDIATE_RETRY_COUNT, give some delay of an empirically chosen + // value before recovering the playback. + mHandler.sendEmptyMessageDelayed(MSG_RESET_PLAYBACK, + RECOVER_STOPPED_PLAYBACK_PERIOD_MS); + } + } + return true; + } + case MSG_RESET_PLAYBACK: { + if (DEBUG) Log.d(TAG, "MSG_RESET_PLAYBACK"); + resetPlayback(); + return true; + } + case MSG_START_PLAYBACK: { + if (DEBUG) Log.d(TAG, "MSG_START_PLAYBACK"); + if (mChannel != null || mRecordingId != null) { + startPlayback(msg.obj); + } + return true; + } + case MSG_UPDATE_PROGRAM: { + if (mChannel != null) { + EitItem program = (EitItem) msg.obj; + updateTvTracks(program, false); + mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS); + } + return true; + } + case MSG_SCHEDULE_OF_PROGRAMS: { + mHandler.removeMessages(MSG_UPDATE_PROGRAM); + Pair<TunerChannel, List<EitItem>> pair = + (Pair<TunerChannel, List<EitItem>>) msg.obj; + TunerChannel channel = pair.first; + if (mChannel == null) { + return true; + } + if (mChannel != null && mChannel.compareTo(channel) != 0) { + return true; + } + mPrograms = pair.second; + EitItem currentProgram = getCurrentProgram(); + if (currentProgram == null) { + mProgram = null; + } + long currentTimeMs = getCurrentPosition(); + if (mPrograms != null) { + for (EitItem item : mPrograms) { + if (currentProgram != null && currentProgram.compareTo(item) == 0) { + if (DEBUG) { + Log.d(TAG, "Update current TvTracks " + item); + } + if (mProgram != null && mProgram.compareTo(item) == 0) { + continue; + } + mProgram = item; + updateTvTracks(item, false); + } else if (item.getStartTimeUtcMillis() > currentTimeMs) { + if (DEBUG) { + Log.d(TAG, "Update next TvTracks " + item + " " + + (item.getStartTimeUtcMillis() - currentTimeMs)); + } + mHandler.sendMessageDelayed( + mHandler.obtainMessage(MSG_UPDATE_PROGRAM, item), + item.getStartTimeUtcMillis() - currentTimeMs); + } + } + } + mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS); + return true; + } + case MSG_UPDATE_CHANNEL_INFO: { + TunerChannel channel = (TunerChannel) msg.obj; + if (mChannel != null && mChannel.compareTo(channel) == 0) { + updateChannelInfo(channel); + } + return true; + } + case MSG_PROGRAM_DATA_RESULT: { + TunerChannel channel = (TunerChannel) ((Pair) msg.obj).first; + + // If there already exists, skip it since real-time data is a top priority, + if (mChannel != null && mChannel.compareTo(channel) == 0 + && mPrograms == null && mProgram == null) { + sendMessage(MSG_SCHEDULE_OF_PROGRAMS, msg.obj); + } + return true; + } + case MSG_TRICKPLAY_BY_SEEK: { + if (mPlayer == null) { + return true; + } + doTrickplayBySeek(msg.arg1); + return true; + } + case MSG_SMOOTH_TRICKPLAY_MONITOR: { + if (mPlayer == null) { + return true; + } + long systemCurrentTime = System.currentTimeMillis(); + long position = getCurrentPosition(); + if (mRecordingId == null) { + // Checks if the position exceeds the upper bound when forwarding, + // or exceed the lower bound when rewinding. + // If the direction is not checked, there can be some issues. + // (See b/29939781 for more details.) + if ((position > systemCurrentTime && mPlaybackParams.getSpeed() > 0L) + || (position < mBufferStartTimeMs && mPlaybackParams.getSpeed() < 0L)) { + doTimeShiftResume(); + return true; + } + } else { + if (position > mRecordingDuration || position < 0) { + doTimeShiftPause(); + return true; + } + } + mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR, + TRICKPLAY_MONITOR_INTERVAL_MS); + return true; + } + case MSG_RESCHEDULE_PROGRAMS: { + doReschedulePrograms(); + return true; + } + case MSG_PARENTAL_CONTROLS: { + doParentalControls(); + mHandler.removeMessages(MSG_PARENTAL_CONTROLS); + mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, + PARENTAL_CONTROLS_INTERVAL_MS); + return true; + } + case MSG_UNBLOCKED_RATING: { + mUnblockedContentRating = (TvContentRating) msg.obj; + doParentalControls(); + mHandler.removeMessages(MSG_PARENTAL_CONTROLS); + mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, + PARENTAL_CONTROLS_INTERVAL_MS); + return true; + } + case MSG_DISCOVER_CAPTION_SERVICE_NUMBER: { + int serviceNumber = (int) msg.obj; + doDiscoverCaptionServiceNumber(serviceNumber); + return true; + } + case MSG_SELECT_TRACK: { + if (mChannel != null) { + doSelectTrack(msg.arg1, (String) msg.obj); + } else if (mRecordingId != null) { + // TODO : mChannel == null && mRecordingId != null + Log.d(TAG, "track selected for recording"); + } + return true; + } + case MSG_UPDATE_CAPTION_TRACK: { + if (mCaptionEnabled) { + startCaptionTrack(); + } else { + stopCaptionTrack(); + } + return true; + } + case MSG_TIMESHIFT_PAUSE: { + if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_PAUSE"); + if (mPlayer == null) { + return true; + } + doTimeShiftPause(); + return true; + } + case MSG_TIMESHIFT_RESUME: { + if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_RESUME"); + if (mPlayer == null) { + return true; + } + doTimeShiftResume(); + return true; + } + case MSG_TIMESHIFT_SEEK_TO: { + long position = (long) msg.obj; + if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_SEEK_TO (position=" + position + ")"); + if (mPlayer == null) { + return true; + } + doTimeShiftSeekTo(position); + return true; + } + case MSG_TIMESHIFT_SET_PLAYBACKPARAMS: { + if (mPlayer == null) { + return true; + } + doTimeShiftSetPlaybackParams((PlaybackParams) msg.obj); + return true; + } + case MSG_AUDIO_CAPABILITIES_CHANGED: { + AudioCapabilities capabilities = (AudioCapabilities) msg.obj; + if (DEBUG) { + Log.d(TAG, "MSG_AUDIO_CAPABILITIES_CHANGED " + capabilities); + } + if (capabilities == null) { + return true; + } + if (!capabilities.equals(mAudioCapabilities)) { + // HDMI supported encodings are changed. restart player. + mAudioCapabilities = capabilities; + resetPlayback(); + } + return true; + } + case MSG_SET_STREAM_VOLUME: { + if (mPlayer != null && mPlayer.isPlaying()) { + mPlayer.setVolume(mVolume); + } + return true; + } + case MSG_BUFFER_START_TIME_CHANGED: { + if (mPlayer == null) { + return true; + } + mBufferStartTimeMs = (long) msg.obj; + if (!hasEnoughBackwardBuffer() + && (!mPlayer.isPlaying() || mPlaybackParams.getSpeed() < 1.0f)) { + mPlayer.setPlayWhenReady(true); + mPlayer.setAudioTrack(true); + mPlaybackParams.setSpeed(1.0f); + } + return true; + } + case MSG_BUFFER_STATE_CHANGED: { + boolean available = (boolean) msg.obj; + mSession.notifyTimeShiftStatusChanged(available + ? TvInputManager.TIME_SHIFT_STATUS_AVAILABLE + : TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE); + return true; + } + case MSG_CHECK_SIGNAL: { + if (mChannel == null || mPlayer == null) { + return true; + } + TsDataSource source = mPlayer.getDataSource(); + long limitInBytes = source != null ? source.getBufferedPosition() : 0L; + long positionInBytes = source != null ? source.getLastReadPosition() : 0L; + if (TunerDebug.ENABLED) { + TunerDebug.calculateDiff(); + mSession.sendUiMessage(TunerSession.MSG_UI_SET_STATUS_TEXT, + Html.fromHtml( + StatusTextUtils.getStatusWarningInHTML( + (limitInBytes - mLastLimitInBytes) + / TS_PACKET_SIZE, + TunerDebug.getVideoFrameDrop(), + TunerDebug.getBytesInQueue(), + TunerDebug.getAudioPositionUs(), + TunerDebug.getAudioPositionUsRate(), + TunerDebug.getAudioPtsUs(), + TunerDebug.getAudioPtsUsRate(), + TunerDebug.getVideoPtsUs(), + TunerDebug.getVideoPtsUsRate() + ))); + } + if (DEBUG) { + Log.d(TAG, String.format("MSG_CHECK_SIGNAL position: %d, limit: %d", + positionInBytes, limitInBytes)); + } + mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE); + long currentTime = SystemClock.elapsedRealtime(); + boolean noBufferRead = positionInBytes == mLastPositionInBytes + && limitInBytes == mLastLimitInBytes; + boolean isBufferingTooLong = mBufferingStartTimeMs != INVALID_TIME + && currentTime - mBufferingStartTimeMs + > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS; + boolean isPreparingTooLong = mPreparingStartTimeMs != INVALID_TIME + && currentTime - mPreparingStartTimeMs + > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS; + boolean isWeakSignal = source != null + && mChannel.getType() == Channel.TYPE_TUNER + && (noBufferRead || isBufferingTooLong || isPreparingTooLong); + if (isWeakSignal && !mReportedWeakSignal) { + if (!mHandler.hasMessages(MSG_RETRY_PLAYBACK)) { + mHandler.sendMessageDelayed(mHandler.obtainMessage( + MSG_RETRY_PLAYBACK, mPlayer), PLAYBACK_RETRY_DELAY_MS); + } + if (mPlayer != null) { + mPlayer.setAudioTrack(false); + } + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + } else if (!isWeakSignal && mReportedWeakSignal) { + boolean isPlaybackStable = mReadyStartTimeMs != INVALID_TIME + && currentTime - mReadyStartTimeMs + > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS; + if (!isPlaybackStable) { + // Wait until playback becomes stable. + } else if (mReportedDrawnToSurface) { + mHandler.removeMessages(MSG_RETRY_PLAYBACK); + notifyVideoAvailable(); + mPlayer.setAudioTrack(true); + } + } + mLastLimitInBytes = limitInBytes; + mLastPositionInBytes = positionInBytes; + mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_PERIOD_MS); + return true; + } + case MSG_SET_SURFACE: { + if (mPlayer != null) { + mPlayer.setSurface(mSurface); + } else { + // TODO: Since surface is dynamically set, we can remove the dependency of + // playback start on mSurface nullity. + resetPlayback(); + } + return true; + } + case MSG_NOTIFY_AUDIO_TRACK_UPDATED: { + notifyAudioTracksUpdated(); + return true; + } + default: { + Log.w(TAG, "Unhandled message code: " + msg.what); + return false; + } + } + } + + // Private methods + private void doSelectTrack(int type, String trackId) { + int numTrackId = trackId != null + ? Integer.parseInt(trackId.substring(TRACK_PREFIX_SIZE)) : -1; + if (type == TvTrackInfo.TYPE_AUDIO) { + if (trackId == null) { + return; + } + AtscAudioTrack audioTrack = mAudioTrackMap.get(numTrackId); + if (audioTrack == null) { + return; + } + int oldAudioPid = mChannel.getAudioPid(); + mChannel.selectAudioTrack(audioTrack.index); + int newAudioPid = mChannel.getAudioPid(); + if (oldAudioPid != newAudioPid) { + mPlayer.setSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO, audioTrack.index); + } + mSession.notifyTrackSelected(type, trackId); + } else if (type == TvTrackInfo.TYPE_SUBTITLE) { + if (trackId == null) { + mSession.notifyTrackSelected(type, null); + mCaptionTrack = null; + stopCaptionTrack(); + return; + } + for (TvTrackInfo track : mTvTracks) { + if (track.getId().equals(trackId)) { + // The service number of the caption service is used for track id of a + // subtitle track. Passes the following track id on to TsParser. + mSession.notifyTrackSelected(type, trackId); + mCaptionTrack = mCaptionTrackMap.get(numTrackId); + startCaptionTrack(); + return; + } + } + } + } + + private MpegTsPlayer createPlayer(AudioCapabilities capabilities, BufferManager bufferManager) { + if (capabilities == null) { + Log.w(TAG, "No Audio Capabilities"); + } + + MpegTsPlayer player = new MpegTsPlayer( + new MpegTsRendererBuilder(mContext, bufferManager, this), + mHandler, mSourceManager, capabilities, this); + Log.i(TAG, "Passthrough AC3 renderer"); + if (DEBUG) Log.d(TAG, "ExoPlayer created"); + return player; + } + + private void startCaptionTrack() { + if (mCaptionEnabled && mCaptionTrack != null) { + mSession.sendUiMessage( + TunerSession.MSG_UI_START_CAPTION_TRACK, mCaptionTrack); + if (mPlayer != null) { + mPlayer.setCaptionServiceNumber(mCaptionTrack.serviceNumber); + } + } + } + + private void stopCaptionTrack() { + if (mPlayer != null) { + mPlayer.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER); + } + mSession.sendUiMessage(TunerSession.MSG_UI_STOP_CAPTION_TRACK); + } + + private void resetTvTracks() { + mTvTracks.clear(); + mAudioTrackMap.clear(); + mCaptionTrackMap.clear(); + mSession.sendUiMessage(TunerSession.MSG_UI_RESET_CAPTION_TRACK); + mSession.notifyTracksChanged(mTvTracks); + } + + private void updateTvTracks(TvTracksInterface tvTracksInterface, boolean fromPmt) { + if (DEBUG) { + Log.d(TAG, "UpdateTvTracks " + tvTracksInterface); + } + List<AtscAudioTrack> audioTracks = tvTracksInterface.getAudioTracks(); + List<AtscCaptionTrack> captionTracks = tvTracksInterface.getCaptionTracks(); + // According to ATSC A/69 chapter 6.9, both PMT and EIT should have descriptors for audio + // tracks, but in real world, we see some bogus audio track info in EIT, so, we trust audio + // track info in PMT more and use info in EIT only when we have nothing. + if (audioTracks != null && !audioTracks.isEmpty() + && (mChannel.getAudioTracks() == null || fromPmt)) { + updateAudioTracks(audioTracks); + } + if (captionTracks == null || captionTracks.isEmpty()) { + if (tvTracksInterface.hasCaptionTrack()) { + updateCaptionTracks(captionTracks); + } + } else { + updateCaptionTracks(captionTracks); + } + } + + private void removeTvTracks(int trackType) { + Iterator<TvTrackInfo> iterator = mTvTracks.iterator(); + while (iterator.hasNext()) { + TvTrackInfo tvTrackInfo = iterator.next(); + if (tvTrackInfo.getType() == trackType) { + iterator.remove(); + } + } + } + + private void updateVideoTrack(int width, int height) { + removeTvTracks(TvTrackInfo.TYPE_VIDEO); + mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID) + .setVideoWidth(width).setVideoHeight(height).build()); + mSession.notifyTracksChanged(mTvTracks); + mSession.notifyTrackSelected(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID); + } + + private void updateAudioTracks(List<AtscAudioTrack> audioTracks) { + if (DEBUG) { + Log.d(TAG, "Update AudioTracks " + audioTracks); + } + mAudioTrackMap.clear(); + if (audioTracks != null) { + int index = 0; + for (AtscAudioTrack audioTrack : audioTracks) { + audioTrack.index = index; + mAudioTrackMap.put(index, audioTrack); + ++index; + } + } + mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED); + } + + private void notifyAudioTracksUpdated() { + if (mPlayer == null) { + // Audio tracks will be updated later once player initialization is done. + return; + } + int audioTrackCount = mPlayer.getTrackCount(MpegTsPlayer.TRACK_TYPE_AUDIO); + removeTvTracks(TvTrackInfo.TYPE_AUDIO); + for (int i = 0; i < audioTrackCount; i++) { + AtscAudioTrack audioTrack = mAudioTrackMap.get(i); + if (audioTrack == null) { + continue; + } + String language = audioTrack.language; + if (language == null && mChannel.getAudioTracks() != null + && mChannel.getAudioTracks().size() == mAudioTrackMap.size()) { + // If a language is not present, use a language field in PMT section parsed. + language = mChannel.getAudioTracks().get(i).language; + } + // Save the index to the audio track. + // Later, when an audio track is selected, both the audio pid and its audio stream + // type reside in the selected index position of the tuner channel's audio data. + audioTrack.index = i; + TvTrackInfo.Builder builder = new TvTrackInfo.Builder( + TvTrackInfo.TYPE_AUDIO, AUDIO_TRACK_PREFIX + i); + builder.setLanguage(language); + builder.setAudioChannelCount(audioTrack.channelCount); + builder.setAudioSampleRate(audioTrack.sampleRate); + TvTrackInfo track = builder.build(); + mTvTracks.add(track); + } + mSession.notifyTracksChanged(mTvTracks); + } + + private void updateCaptionTracks(List<AtscCaptionTrack> captionTracks) { + if (DEBUG) { + Log.d(TAG, "Update CaptionTrack " + captionTracks); + } + removeTvTracks(TvTrackInfo.TYPE_SUBTITLE); + mCaptionTrackMap.clear(); + if (captionTracks != null) { + for (AtscCaptionTrack captionTrack : captionTracks) { + if (mCaptionTrackMap.indexOfKey(captionTrack.serviceNumber) >= 0) { + continue; + } + String language = captionTrack.language; + + // The service number of the caption service is used for track id of a subtitle. + // Later, when a subtitle is chosen, track id will be passed on to TsParser. + TvTrackInfo.Builder builder = + new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE, + SUBTITLE_TRACK_PREFIX + captionTrack.serviceNumber); + builder.setLanguage(language); + mTvTracks.add(builder.build()); + mCaptionTrackMap.put(captionTrack.serviceNumber, captionTrack); + } + } + mSession.notifyTracksChanged(mTvTracks); + } + + private void updateChannelInfo(TunerChannel channel) { + if (DEBUG) { + Log.d(TAG, String.format("Channel Info (old) videoPid: %d audioPid: %d " + + "audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(), + mChannel.getAudioPids().size())); + } + + // The list of the audio tracks resided in a channel is often changed depending on a + // program being on the air. So, we should update the streaming PIDs and types of the + // tuned channel according to the newly received channel data. + int oldVideoPid = mChannel.getVideoPid(); + int oldAudioPid = mChannel.getAudioPid(); + List<Integer> audioPids = channel.getAudioPids(); + List<Integer> audioStreamTypes = channel.getAudioStreamTypes(); + int size = audioPids.size(); + mChannel.setVideoPid(channel.getVideoPid()); + mChannel.setAudioPids(audioPids); + mChannel.setAudioStreamTypes(audioStreamTypes); + updateTvTracks(channel, true); + int index = audioPids.isEmpty() ? -1 : 0; + for (int i = 0; i < size; ++i) { + if (audioPids.get(i) == oldAudioPid) { + index = i; + break; + } + } + mChannel.selectAudioTrack(index); + mSession.notifyTrackSelected(TvTrackInfo.TYPE_AUDIO, + index == -1 ? null : AUDIO_TRACK_PREFIX + index); + + // Reset playback if there is a change in the listening streaming PIDs. + if (oldVideoPid != mChannel.getVideoPid() + || oldAudioPid != mChannel.getAudioPid()) { + // TODO: Implement a switching between tracks more smoothly. + resetPlayback(); + } + if (DEBUG) { + Log.d(TAG, String.format("Channel Info (new) videoPid: %d audioPid: %d " + + " audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(), + mChannel.getAudioPids().size())); + } + } + + private void stopPlayback() { + mChannelDataManager.removeAllCallbacksAndMessages(); + if (mPlayer != null) { + mPlayer.setPlayWhenReady(false); + mPlayer.release(); + mPlayer = null; + mPlayerState = ExoPlayer.STATE_IDLE; + mPlaybackParams.setSpeed(1.0f); + mPlayerStarted = false; + mReportedDrawnToSurface = false; + mPreparingStartTimeMs = INVALID_TIME; + mBufferingStartTimeMs = INVALID_TIME; + mReadyStartTimeMs = INVALID_TIME; + mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_AUDIO_UNPLAYABLE); + mSession.notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE); + } + } + + private void startPlayback(Object playerObj) { + // TODO: provide hasAudio()/hasVideo() for play recordings. + if (mPlayer == null || mPlayer != playerObj) { + return; + } + if (mChannel != null && !mChannel.hasAudio()) { + if (DEBUG) Log.d(TAG, "Channel " + mChannel + " does not have audio."); + // Playbacks with video-only stream have not been tested yet. + // No video-only channel has been found. + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN); + return; + } + if (mChannel != null && ((mChannel.hasAudio() && !mPlayer.hasAudio()) + || (mChannel.hasVideo() && !mPlayer.hasVideo()))) { + // Tracks haven't been detected in the extractor. Try again. + sendMessage(MSG_RETRY_PLAYBACK, mPlayer); + return; + } + // Since mSurface is volatile, we define a local variable surface to keep the same value + // inside this method. + Surface surface = mSurface; + if (surface != null && !mPlayerStarted) { + mPlayer.setSurface(surface); + mPlayer.setPlayWhenReady(true); + mPlayer.setVolume(mVolume); + if (mChannel != null && !mChannel.hasVideo() && mChannel.hasAudio()) { + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY); + } else if (!mReportedWeakSignal) { + // Doesn't show buffering during weak signal. + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING); + } + mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE); + mPlayerStarted = true; + } + } + + private void preparePlayback() { + SoftPreconditions.checkState(mPlayer == null); + if (mChannel == null && mRecordingId == null) { + return; + } + mSourceManager.setKeepTuneStatus(true); + BufferManager bufferManager = mChannel != null ? mBufferManager : new BufferManager( + new DvrStorageManager(new File(getRecordingPath()), false)); + MpegTsPlayer player = createPlayer(mAudioCapabilities, bufferManager); + player.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER); + player.setVideoEventListener(this); + player.setCaptionServiceNumber(mCaptionTrack != null ? + mCaptionTrack.serviceNumber : Cea708Data.EMPTY_SERVICE_NUMBER); + if (!player.prepare(mContext, mChannel, this)) { + mSourceManager.setKeepTuneStatus(false); + player.release(); + if (!mHandler.hasMessages(MSG_TUNE)) { + // When prepare failed, there may be some errors related to hardware. In that + // case, retry playback immediately may not help. + notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer), + PLAYBACK_RETRY_DELAY_MS); + } + } else { + mPlayer = player; + mPlayerStarted = false; + mHandler.removeMessages(MSG_CHECK_SIGNAL); + mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS); + } + } + + private void resetPlayback() { + long timestamp, oldTimestamp; + timestamp = SystemClock.elapsedRealtime(); + stopPlayback(); + stopCaptionTrack(); + if (ENABLE_PROFILER) { + oldTimestamp = timestamp; + timestamp = SystemClock.elapsedRealtime(); + Log.i(TAG, "[Profiler] stopPlayback() takes " + (timestamp - oldTimestamp) + " ms"); + } + if (mChannelBlocked || mSurface == null) { + return; + } + preparePlayback(); + } + + private void prepareTune(TunerChannel channel, String recording) { + mChannelBlocked = false; + mUnblockedContentRating = null; + mRetryCount = 0; + mChannel = channel; + mRecordingId = recording; + mRecordingDuration = recording != null ? getDurationForRecording(recording) : null; + mProgram = null; + mPrograms = null; + mBufferStartTimeMs = mRecordStartTimeMs = + (mRecordingId != null) ? 0 : System.currentTimeMillis(); + mLastPositionMs = 0; + mCaptionTrack = null; + mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS); + } + + private void doReschedulePrograms() { + long currentPositionMs = getCurrentPosition(); + long forwardDifference = Math.abs(currentPositionMs - mLastPositionMs + - RESCHEDULE_PROGRAMS_INTERVAL_MS); + mLastPositionMs = currentPositionMs; + + // A gap is measured as the time difference between previous and next current position + // periodically. If the gap has a significant difference with an interval of a period, + // this means that there is a change of playback status and the programs of the current + // channel should be rescheduled to new playback timeline. + if (forwardDifference > RESCHEDULE_PROGRAMS_TOLERANCE_MS) { + if (DEBUG) { + Log.d(TAG, "reschedule programs size:" + + (mPrograms != null ? mPrograms.size() : 0) + " current program: " + + getCurrentProgram()); + } + mHandler.obtainMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(mChannel, mPrograms)) + .sendToTarget(); + } + mHandler.removeMessages(MSG_RESCHEDULE_PROGRAMS); + mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS, + RESCHEDULE_PROGRAMS_INTERVAL_MS); + } + + private int getTrickPlaySeekIntervalMs() { + return Math.max(EXPECTED_KEY_FRAME_INTERVAL_MS / (int) Math.abs(mPlaybackParams.getSpeed()), + MIN_TRICKPLAY_SEEK_INTERVAL_MS); + } + + private void doTrickplayBySeek(int seekPositionMs) { + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + if (mPlaybackParams.getSpeed() == 1.0f || !mPlayer.isPrepared()) { + return; + } + if (seekPositionMs < mBufferStartTimeMs - mRecordStartTimeMs) { + if (mPlaybackParams.getSpeed() > 1.0f) { + // If fast forwarding, the seekPositionMs can be out of the buffered range + // because of chuck evictions. + seekPositionMs = (int) (mBufferStartTimeMs - mRecordStartTimeMs); + } else { + mPlayer.seekTo(mBufferStartTimeMs - mRecordStartTimeMs); + mPlaybackParams.setSpeed(1.0f); + mPlayer.setAudioTrack(true); + return; + } + } else if (seekPositionMs > System.currentTimeMillis() - mRecordStartTimeMs) { + mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs); + mPlaybackParams.setSpeed(1.0f); + mPlayer.setAudioTrack(true); + return; + } + + long delayForNextSeek = getTrickPlaySeekIntervalMs(); + if (!mPlayer.isBuffering()) { + mPlayer.seekTo(seekPositionMs); + } else { + delayForNextSeek = MIN_TRICKPLAY_SEEK_INTERVAL_MS; + } + seekPositionMs += mPlaybackParams.getSpeed() * delayForNextSeek; + mHandler.sendMessageDelayed(mHandler.obtainMessage( + MSG_TRICKPLAY_BY_SEEK, seekPositionMs, 0), delayForNextSeek); + } + + private void doTimeShiftPause() { + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + if (!hasEnoughBackwardBuffer()) { + return; + } + mPlaybackParams.setSpeed(1.0f); + mPlayer.setPlayWhenReady(false); + mPlayer.setAudioTrack(true); + } + + private void doTimeShiftResume() { + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + mPlaybackParams.setSpeed(1.0f); + mPlayer.setPlayWhenReady(true); + mPlayer.setAudioTrack(true); + } + + private void doTimeShiftSeekTo(long timeMs) { + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + mPlayer.seekTo((int) (timeMs - mRecordStartTimeMs)); + } + + private void doTimeShiftSetPlaybackParams(PlaybackParams params) { + if (!hasEnoughBackwardBuffer() && params.getSpeed() < 1.0f) { + return; + } + mPlaybackParams = params; + float speed = mPlaybackParams.getSpeed(); + if (speed == 1.0f) { + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + doTimeShiftResume(); + } else if (mPlayer.supportSmoothTrickPlay(speed)) { + mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); + mPlayer.setAudioTrack(false); + mPlayer.startSmoothTrickplay(mPlaybackParams); + mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR, + TRICKPLAY_MONITOR_INTERVAL_MS); + } else { + mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR); + if (!mHandler.hasMessages(MSG_TRICKPLAY_BY_SEEK)) { + mPlayer.setAudioTrack(false); + mPlayer.setPlayWhenReady(false); + // Initiate trickplay + mHandler.sendMessage(mHandler.obtainMessage(MSG_TRICKPLAY_BY_SEEK, + (int) (mPlayer.getCurrentPosition() + + speed * getTrickPlaySeekIntervalMs()), 0)); + } + } + } + + private EitItem getCurrentProgram() { + if (mPrograms == null || mPrograms.isEmpty()) { + return null; + } + if (mChannel.getType() == Channel.TYPE_FILE) { + // For the playback from the local file, we use the first one from the given program. + EitItem first = mPrograms.get(0); + if (first != null && (mProgram == null + || first.getStartTimeUtcMillis() < mProgram.getStartTimeUtcMillis())) { + return first; + } + return null; + } + long currentTimeMs = getCurrentPosition(); + for (EitItem item : mPrograms) { + if (item.getStartTimeUtcMillis() <= currentTimeMs + && item.getEndTimeUtcMillis() >= currentTimeMs) { + return item; + } + } + return null; + } + + private void doParentalControls() { + boolean isParentalControlsEnabled = mTvInputManager.isParentalControlsEnabled(); + if (isParentalControlsEnabled) { + TvContentRating blockContentRating = getContentRatingOfCurrentProgramBlocked(); + if (DEBUG) { + if (blockContentRating != null) { + Log.d(TAG, "Check parental controls: blocked by content rating - " + + blockContentRating); + } else { + Log.d(TAG, "Check parental controls: available"); + } + } + updateChannelBlockStatus(blockContentRating != null, blockContentRating); + } else { + if (DEBUG) { + Log.d(TAG, "Check parental controls: available"); + } + updateChannelBlockStatus(false, null); + } + } + + private void doDiscoverCaptionServiceNumber(int serviceNumber) { + int index = mCaptionTrackMap.indexOfKey(serviceNumber); + if (index < 0) { + AtscCaptionTrack captionTrack = new AtscCaptionTrack(); + captionTrack.serviceNumber = serviceNumber; + captionTrack.wideAspectRatio = false; + captionTrack.easyReader = false; + mCaptionTrackMap.put(serviceNumber, captionTrack); + mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE, + SUBTITLE_TRACK_PREFIX + serviceNumber).build()); + mSession.notifyTracksChanged(mTvTracks); + } + } + + private TvContentRating getContentRatingOfCurrentProgramBlocked() { + EitItem currentProgram = getCurrentProgram(); + if (currentProgram == null) { + return null; + } + TvContentRating[] ratings = mTvContentRatingCache + .getRatings(currentProgram.getContentRating()); + if (ratings == null) { + return null; + } + for (TvContentRating rating : ratings) { + if (!Objects.equals(mUnblockedContentRating, rating) && mTvInputManager + .isRatingBlocked(rating)) { + return rating; + } + } + return null; + } + + private void updateChannelBlockStatus(boolean channelBlocked, + TvContentRating contentRating) { + if (mChannelBlocked == channelBlocked) { + return; + } + mChannelBlocked = channelBlocked; + if (mChannelBlocked) { + mHandler.removeCallbacksAndMessages(null); + stopPlayback(); + resetTvTracks(); + if (contentRating != null) { + mSession.notifyContentBlocked(contentRating); + } + mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, PARENTAL_CONTROLS_INTERVAL_MS); + } else { + mHandler.removeCallbacksAndMessages(null); + resetPlayback(); + mSession.notifyContentAllowed(); + mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS, + RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS); + mHandler.removeMessages(MSG_CHECK_SIGNAL); + mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS); + } + } + + private boolean hasEnoughBackwardBuffer() { + return mPlayer.getCurrentPosition() + BUFFER_UNDERFLOW_BUFFER_MS + >= mBufferStartTimeMs - mRecordStartTimeMs; + } + + private void notifyVideoUnavailable(final int reason) { + mReportedWeakSignal = (reason == TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + if (mSession != null) { + mSession.notifyVideoUnavailable(reason); + } + } + + private void notifyVideoAvailable() { + mReportedWeakSignal = false; + if (mSession != null) { + mSession.notifyVideoAvailable(); + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java new file mode 100644 index 00000000..e734b779 --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java @@ -0,0 +1,166 @@ +/* + * 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.tuner.tvinput; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.AsyncTask; + +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.util.Utils; + +import java.io.File; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Creates {@link JobService} to clean up recorded program files which are not referenced + * from database. + */ +public class TunerStorageCleanUpService extends JobService { + private CleanUpStorageTask mTask; + + @Override + public void onCreate() { + TvApplication.setCurrentRunningProcess(this, false); + super.onCreate(); + mTask = new CleanUpStorageTask(this, this); + } + + @Override + public boolean onStartJob(JobParameters params) { + mTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, params); + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + + /** + * Cleans up recorded program files which are not referenced from database. + * Cleaning up will be done periodically. + */ + public static class CleanUpStorageTask extends AsyncTask<JobParameters, Void, JobParameters[]> { + private final static String[] mProjection = { + TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME, + TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI + }; + private final static long ELAPSED_MILLIS_TO_DELETE = TimeUnit.DAYS.toMillis(1); + + private final Context mContext; + private final DvrStorageStatusManager mDvrStorageStatusManager; + private final JobService mJobService; + private final ContentResolver mContentResolver; + + /** + * Creates a recurring storage cleaning task. + * + * @param context {@link Context} + * @param jobService {@link JobService} + */ + public CleanUpStorageTask(Context context, JobService jobService) { + mContext = context; + mDvrStorageStatusManager = + TvApplication.getSingletons(mContext).getDvrStorageStatusManager(); + mJobService = jobService; + mContentResolver = mContext.getContentResolver(); + } + + private Set<String> getRecordedProgramsDirs() { + try (Cursor c = mContentResolver.query( + TvContract.RecordedPrograms.CONTENT_URI, mProjection, null, null, null)) { + if (c == null) { + return null; + } + Set<String> recordedProgramDirs = new HashSet<>(); + while (c.moveToNext()) { + String packageName = c.getString(0); + String dataUriString = c.getString(1); + if (dataUriString == null) { + continue; + } + Uri dataUri = Uri.parse(dataUriString); + if (!Utils.isInBundledPackageSet(packageName) + || dataUri == null || dataUri.getPath() == null + || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) { + continue; + } + File recordedProgramDir = new File(dataUri.getPath()); + try { + recordedProgramDirs.add(recordedProgramDir.getCanonicalPath()); + } catch (IOException | SecurityException e) { + } + } + return recordedProgramDirs; + } + } + + @Override + protected JobParameters[] doInBackground(JobParameters... params) { + if (mDvrStorageStatusManager.getDvrStorageStatus() + == DvrStorageStatusManager.STORAGE_STATUS_MISSING) { + return params; + } + File dvrRecordingDir = mDvrStorageStatusManager.getRecordingRootDataDirectory(); + if (dvrRecordingDir == null || !dvrRecordingDir.isDirectory()) { + return params; + } + Set<String> recordedProgramDirs = getRecordedProgramsDirs(); + if (recordedProgramDirs == null) { + return params; + } + File[] files = dvrRecordingDir.listFiles(); + if (files == null || files.length == 0) { + return params; + } + for (File recordingDir : files) { + try { + if (!recordedProgramDirs.contains(recordingDir.getCanonicalPath())) { + long lastModified = recordingDir.lastModified(); + long now = System.currentTimeMillis(); + if (lastModified != 0 + && lastModified < now - ELAPSED_MILLIS_TO_DELETE) { + // To prevent current recordings from being deleted, + // deletes recordings which was not modified for long enough time. + Utils.deleteDirOrFile(recordingDir); + } + } + } catch (IOException | SecurityException e) { + // would not happen + } + } + return params; + } + + @Override + protected void onPostExecute(JobParameters[] params) { + for (JobParameters param : params) { + mJobService.jobFinished(param, false); + } + } + } +} diff --git a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java new file mode 100644 index 00000000..684ebdbd --- /dev/null +++ b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java @@ -0,0 +1,146 @@ +/* + * 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.tuner.tvinput; + +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.Context; +import android.media.tv.TvContract; +import android.media.tv.TvInputService; +import android.util.Log; + +import com.google.android.exoplayer.audio.AudioCapabilities; +import com.google.android.exoplayer.audio.AudioCapabilitiesReceiver; +import com.android.tv.TvApplication; +import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.TrickplayStorageManager; +import com.android.tv.tuner.util.SystemPropertiesProxy; + +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.TimeUnit; + +/** + * {@link TunerTvInputService} serves TV channels coming from a tuner device. + */ +public class TunerTvInputService extends TvInputService + implements AudioCapabilitiesReceiver.Listener{ + private static final String TAG = "TunerTvInputService"; + private static final boolean DEBUG = false; + + private static final String MAX_BUFFER_SIZE_KEY = "tv.tuner.buffersize_mbytes"; + private static final int MAX_BUFFER_SIZE_DEF = 2 * 1024; // 2GB + private static final int MIN_BUFFER_SIZE_DEF = 256; // 256MB + private static final int DVR_STORAGE_CLEANUP_JOB_ID = 100; + + // WeakContainer for {@link TvInputSessionImpl} + private final Set<TunerSession> mTunerSessions = Collections.newSetFromMap(new WeakHashMap<>()); + private ChannelDataManager mChannelDataManager; + private AudioCapabilitiesReceiver mAudioCapabilitiesReceiver; + private AudioCapabilities mAudioCapabilities; + private BufferManager mBufferManager; + + @Override + public void onCreate() { + TvApplication.setCurrentRunningProcess(this, false); + super.onCreate(); + if (DEBUG) Log.d(TAG, "onCreate"); + mChannelDataManager = new ChannelDataManager(getApplicationContext()); + mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(getApplicationContext(), this); + mAudioCapabilitiesReceiver.register(); + mBufferManager = createBufferManager(); + if (CommonFeatures.DVR.isEnabled(this)) { + JobScheduler jobScheduler = + (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE); + JobInfo pendingJob = jobScheduler.getPendingJob(DVR_STORAGE_CLEANUP_JOB_ID); + if (pendingJob != null) { + // storage cleaning job is already scheduled. + } else { + JobInfo job = new JobInfo.Builder(DVR_STORAGE_CLEANUP_JOB_ID, + new ComponentName(this, TunerStorageCleanUpService.class)) + .setPersisted(true).setPeriodic(TimeUnit.DAYS.toMillis(1)).build(); + jobScheduler.schedule(job); + } + } + if (mBufferManager == null) { + Log.i(TAG, "Trickplay is disabled"); + } else { + Log.i(TAG, "Trickplay is enabled"); + } + } + + @Override + public void onDestroy() { + if (DEBUG) Log.d(TAG, "onDestroy"); + super.onDestroy(); + mChannelDataManager.release(); + mAudioCapabilitiesReceiver.unregister(); + if (mBufferManager != null) { + mBufferManager.close(); + } + } + + @Override + public RecordingSession onCreateRecordingSession(String inputId) { + return new TunerRecordingSession(this, inputId, mChannelDataManager); + } + + @Override + public Session onCreateSession(String inputId) { + if (DEBUG) Log.d(TAG, "onCreateSession"); + try { + final TunerSession session = new TunerSession( + this, mChannelDataManager, mBufferManager); + mTunerSessions.add(session); + session.setAudioCapabilities(mAudioCapabilities); + session.setOverlayViewEnabled(true); + return session; + } catch (RuntimeException e) { + // There are no available DVB devices. + Log.e(TAG, "Creating a session for " + inputId + " failed.", e); + return null; + } + } + + @Override + public void onAudioCapabilitiesChanged(AudioCapabilities audioCapabilities) { + mAudioCapabilities = audioCapabilities; + for (TunerSession session : mTunerSessions) { + if (!session.isReleased()) { + session.setAudioCapabilities(audioCapabilities); + } + } + } + + private BufferManager createBufferManager() { + int maxBufferSizeMb = + SystemPropertiesProxy.getInt(MAX_BUFFER_SIZE_KEY, MAX_BUFFER_SIZE_DEF); + if (maxBufferSizeMb >= MIN_BUFFER_SIZE_DEF) { + return new BufferManager( + new TrickplayStorageManager(getApplicationContext(), getCacheDir(), + 1024L * 1024 * maxBufferSizeMb)); + } + return null; + } + + public static String getInputId(Context context) { + return TvContract.buildInputId(new ComponentName(context, TunerTvInputService.class)); + } +} diff --git a/src/com/android/tv/tuner/util/ByteArrayBuffer.java b/src/com/android/tv/tuner/util/ByteArrayBuffer.java new file mode 100644 index 00000000..da887e7d --- /dev/null +++ b/src/com/android/tv/tuner/util/ByteArrayBuffer.java @@ -0,0 +1,149 @@ +/* + * $HeadURL: http://svn.apache.org/repos/asf/httpcomponents/httpcore/trunk/module-main/src/main/java/org/apache/http/util/ByteArrayBuffer.java $ + * $Revision: 496070 $ + * $Date: 2007-01-14 04:18:34 -0800 (Sun, 14 Jan 2007) $ + * + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * <http://www.apache.org/>. + * + */ + +package com.android.tv.tuner.util; + +/** + * An expandable byte buffer built on byte array. + */ +public final class ByteArrayBuffer { + + private byte[] buffer; + private int len; + + public ByteArrayBuffer(int capacity) { + super(); + if (capacity < 0) { + throw new IllegalArgumentException("Buffer capacity may not be negative"); + } + this.buffer = new byte[capacity]; + } + + private void expand(int newlen) { + byte newbuffer[] = new byte[Math.max(this.buffer.length << 1, newlen)]; + System.arraycopy(this.buffer, 0, newbuffer, 0, this.len); + this.buffer = newbuffer; + } + + public void append(final byte[] b, int off, int len) { + if (b == null) { + return; + } + if ((off < 0) || (off > b.length) || (len < 0) || + ((off + len) < 0) || ((off + len) > b.length)) { + throw new IndexOutOfBoundsException(); + } + if (len == 0) { + return; + } + int newlen = this.len + len; + if (newlen > this.buffer.length) { + expand(newlen); + } + System.arraycopy(b, off, this.buffer, this.len, len); + this.len = newlen; + } + + public void append(int b) { + int newlen = this.len + 1; + if (newlen > this.buffer.length) { + expand(newlen); + } + this.buffer[this.len] = (byte) b; + this.len = newlen; + } + + public void append(final char[] b, int off, int len) { + if (b == null) { + return; + } + if ((off < 0) || (off > b.length) || (len < 0) || + ((off + len) < 0) || ((off + len) > b.length)) { + throw new IndexOutOfBoundsException(); + } + if (len == 0) { + return; + } + int oldlen = this.len; + int newlen = oldlen + len; + if (newlen > this.buffer.length) { + expand(newlen); + } + for (int i1 = off, i2 = oldlen; i2 < newlen; i1++, i2++) { + this.buffer[i2] = (byte) b[i1]; + } + this.len = newlen; + } + + public void clear() { + this.len = 0; + } + + public byte[] toByteArray() { + byte[] b = new byte[this.len]; + if (this.len > 0) { + System.arraycopy(this.buffer, 0, b, 0, this.len); + } + return b; + } + + public int byteAt(int i) { + return this.buffer[i]; + } + + public int capacity() { + return this.buffer.length; + } + + public int length() { + return this.len; + } + + public byte[] buffer() { + return this.buffer; + } + + public void setLength(int len) { + if (len < 0 || len > this.buffer.length) { + throw new IndexOutOfBoundsException(); + } + this.len = len; + } + + public boolean isEmpty() { + return this.len == 0; + } + + public boolean isFull() { + return this.len == this.buffer.length; + } + +} diff --git a/src/com/android/tv/tuner/util/ConvertUtils.java b/src/com/android/tv/tuner/util/ConvertUtils.java new file mode 100644 index 00000000..abf18d8c --- /dev/null +++ b/src/com/android/tv/tuner/util/ConvertUtils.java @@ -0,0 +1,35 @@ +/* + * 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.tuner.util; + +/** + * Utility class for converting date and time. + */ +public class ConvertUtils { + // Time diff between 1.1.1970 00:00:00 and 6.1.1980 00:00:00 + private static final long DIFF_BETWEEN_UNIX_EPOCH_AND_GPS = 315964800; + + private ConvertUtils() { } + + public static long convertGPSTimeToUnixEpoch(long gpsTime) { + return gpsTime + DIFF_BETWEEN_UNIX_EPOCH_AND_GPS; + } + + public static long convertUnixEpochToGPSTime(long epochTime) { + return epochTime - DIFF_BETWEEN_UNIX_EPOCH_AND_GPS; + } +} diff --git a/src/com/android/tv/tuner/util/GlobalSettingsUtils.java b/src/com/android/tv/tuner/util/GlobalSettingsUtils.java new file mode 100644 index 00000000..0cefcbed --- /dev/null +++ b/src/com/android/tv/tuner/util/GlobalSettingsUtils.java @@ -0,0 +1,36 @@ +/* + * 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.tuner.util; + +import android.content.Context; +import android.provider.Settings; + +/** + * Utility class that get information of global settings. + */ +public class GlobalSettingsUtils { + // Since global surround setting is hided, add the related variable here for checking surround + // sound setting when the audio is unavailable. Remove this workaround after b/31254857 fixed. + private static final String ENCODED_SURROUND_OUTPUT = "encoded_surround_output"; + public static final int ENCODED_SURROUND_OUTPUT_NEVER = 1; + + private GlobalSettingsUtils () { } + + public static int getEncodedSurroundOutputSettings(Context context) { + return Settings.Global.getInt(context.getContentResolver(), ENCODED_SURROUND_OUTPUT, 0); + } +} diff --git a/src/com/android/tv/tuner/util/Ints.java b/src/com/android/tv/tuner/util/Ints.java new file mode 100644 index 00000000..0b1be426 --- /dev/null +++ b/src/com/android/tv/tuner/util/Ints.java @@ -0,0 +1,28 @@ +package com.android.tv.tuner.util; + +import java.util.ArrayList; +import java.util.List; + +/** + * Static utility methods pertaining to int primitives. (Referred Guava's Ints class) + */ +public class Ints { + private Ints() {} + + public static int[] toArray(List<Integer> integerList) { + int[] intArray = new int[integerList.size()]; + int i = 0; + for (Integer data : integerList) { + intArray[i++] = data; + } + return intArray; + } + + public static List<Integer> asList(int[] intArray) { + List<Integer> integerList = new ArrayList<>(intArray.length); + for (int data : intArray) { + integerList.add(data); + } + return integerList; + } +} diff --git a/src/com/android/tv/tuner/util/StatusTextUtils.java b/src/com/android/tv/tuner/util/StatusTextUtils.java new file mode 100644 index 00000000..2633834b --- /dev/null +++ b/src/com/android/tv/tuner/util/StatusTextUtils.java @@ -0,0 +1,119 @@ +/* + * 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.tuner.util; + +import java.util.Locale; + +/** + * Utility class for tuner status messages. + */ +public class StatusTextUtils { + private static final int PACKETS_PER_SEC_YELLOW = 1500; + private static final int PACKETS_PER_SEC_RED = 1000; + private static final int AUDIO_POSITION_MS_RATE_DIFF_YELLOW = 100; + private static final int AUDIO_POSITION_MS_RATE_DIFF_RED = 200; + private static final String COLOR_RED = "red"; + private static final String COLOR_YELLOW = "yellow"; + private static final String COLOR_GREEN = "green"; + private static final String COLOR_GRAY = "gray"; + + private StatusTextUtils() { } + + /** + * Returns tuner status warning message in HTML. + * + * <p>This is only called for debuging and always shown in english.</p> + */ + public static String getStatusWarningInHTML(long packetsPerSec, + int videoFrameDrop, int bytesInQueue, + long audioPositionUs, long audioPositionUsRate, + long audioPtsUs, long audioPtsUsRate, + long videoPtsUs, long videoPtsUsRate) { + StringBuffer buffer = new StringBuffer(); + + // audioPosition should go in rate of 1000ms. + long audioPositionMsRate = audioPositionUsRate / 1000; + String audioPositionColor; + if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_RED) { + audioPositionColor = COLOR_RED; + } else if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_YELLOW) { + audioPositionColor = COLOR_YELLOW; + } else { + audioPositionColor = COLOR_GRAY; + } + buffer.append(String.format(Locale.US, "<font color=%s>", audioPositionColor)); + buffer.append( + String.format(Locale.US, "audioPositionMs: %d (%d)<br>", audioPositionUs / 1000, + audioPositionMsRate)); + buffer.append("</font>\n"); + buffer.append("<font color=" + COLOR_GRAY + ">"); + buffer.append(String.format(Locale.US, "audioPtsMs: %d (%d, %d)<br>", audioPtsUs / 1000, + audioPtsUsRate / 1000, (audioPtsUs - audioPositionUs) / 1000)); + buffer.append(String.format(Locale.US, "videoPtsMs: %d (%d, %d)<br>", videoPtsUs / 1000, + videoPtsUsRate / 1000, (videoPtsUs - audioPositionUs) / 1000)); + buffer.append("</font>\n"); + + appendStatusLine(buffer, "KbytesInQueue", bytesInQueue / 1000, 1, 10); + buffer.append("<br/>"); + appendErrorStatusLine(buffer, "videoFrameDrop", videoFrameDrop, 0, 2); + buffer.append("<br/>"); + appendStatusLine(buffer, "packetsPerSec", packetsPerSec, PACKETS_PER_SEC_RED, + PACKETS_PER_SEC_YELLOW); + return buffer.toString(); + } + + /** + * Returns audio unavailable warning message in HTML. + */ + public static String getAudioWarningInHTML(String msg) { + return String.format("<font color=%s>%s</font>\n", COLOR_YELLOW, msg); + } + + private static void appendStatusLine(StringBuffer buffer, String factorName, long value, + int minRed, int minYellow) { + buffer.append("<font color="); + if (value <= minRed) { + buffer.append(COLOR_RED); + } else if (value <= minYellow) { + buffer.append(COLOR_YELLOW); + } else { + buffer.append(COLOR_GREEN); + } + buffer.append(">"); + buffer.append(factorName); + buffer.append(" : "); + buffer.append(value); + buffer.append("</font>"); + } + + private static void appendErrorStatusLine(StringBuffer buffer, String factorName, int value, + int minGreen, int minYellow) { + buffer.append("<font color="); + if (value <= minGreen) { + buffer.append(COLOR_GREEN); + } else if (value <= minYellow) { + buffer.append(COLOR_YELLOW); + } else { + buffer.append(COLOR_RED); + } + buffer.append(">"); + buffer.append(factorName); + buffer.append(" : "); + buffer.append(value); + buffer.append("</font>"); + } +} diff --git a/src/com/android/tv/dvr/SeasonRecording.java b/src/com/android/tv/tuner/util/StringUtils.java index 7f89e135..15571e75 100644 --- a/src/com/android/tv/dvr/SeasonRecording.java +++ b/src/com/android/tv/tuner/util/StringUtils.java @@ -14,22 +14,25 @@ * limitations under the License. */ -package com.android.tv.dvr; - -import java.util.List; +package com.android.tv.tuner.util; /** - * A data class for one recorded contents. + * Utility class for handling {@link String}. */ -public class SeasonRecording { - private static final String TAG = "Recording"; +public final class StringUtils { + + private StringUtils() { } /** - * Constant for all season. + * Returns compares two strings lexicographically and handles null values quietly. */ - private static final int ALL_SEASON = -1; - - private List<ScheduledRecording> mSchedule; - private String mTitle; - private int mSeasonNumber; + public static int compare(String a, String b) { + if (a == null) { + return b == null ? 0 : -1; + } + if (b == null) { + return 1; + } + return a.compareTo(b); + } } diff --git a/src/com/android/tv/tuner/util/SystemPropertiesProxy.java b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java new file mode 100644 index 00000000..62a64361 --- /dev/null +++ b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java @@ -0,0 +1,61 @@ +/* + * 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.tuner.util; + +import android.util.Log; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Proxy class that gives an access to a hidden API {@link android.os.SystemProperties#getBoolean}. + */ +public class SystemPropertiesProxy { + private static final String TAG = "SystemPropertiesProxy"; + + private SystemPropertiesProxy() { } + + public static boolean getBoolean(String key, boolean def) + throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getBooleanMethod = SystemPropertiesClass.getDeclaredMethod("getBoolean", + String.class, boolean.class); + getBooleanMethod.setAccessible(true); + return (boolean) getBooleanMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.getBoolean()", e); + } + return def; + } + + public static int getInt(String key, int def) + throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getIntMethod = SystemPropertiesClass.getDeclaredMethod("getInt", + String.class, int.class); + getIntMethod.setAccessible(true); + return (int) getIntMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.getInt()", e); + } + return def; + } +} diff --git a/src/com/android/tv/tuner/util/TisConfiguration.java b/src/com/android/tv/tuner/util/TisConfiguration.java new file mode 100644 index 00000000..ca861d67 --- /dev/null +++ b/src/com/android/tv/tuner/util/TisConfiguration.java @@ -0,0 +1,22 @@ +package com.android.tv.tuner.util; + +import android.content.Context; + +/** + * A helper class of tuner configuration. + */ +public class TisConfiguration { + private static final String LC_PACKAGE_NAME = "com.android.tv"; + + public static boolean isPackagedWithLiveChannels(Context context) { + return (LC_PACKAGE_NAME.equals(context.getPackageName())); + } + + public static boolean isInternalTunerTvInput(Context context) { + return (!LC_PACKAGE_NAME.equals(context.getPackageName())); + } + + public static int getTunerHwDeviceId(Context context) { + return 0; // FIXME: Make it OEM configurable + } +} diff --git a/src/com/android/tv/tuner/util/TunerInputInfoUtils.java b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java new file mode 100644 index 00000000..5c411f64 --- /dev/null +++ b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java @@ -0,0 +1,100 @@ +/* + * 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.tuner.util; + +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Context; +import android.media.tv.TvInputInfo; +import android.media.tv.TvInputManager; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.v4.os.BuildCompat; +import android.util.Log; + +import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerHal; +import com.android.tv.tuner.tvinput.TunerTvInputService; + +/** + * Utility class for providing tuner input info. + */ +public class TunerInputInfoUtils { + private static final String TAG = "TunerInputInfoUtils"; + private static final boolean DEBUG = false; + + /** + * Builds tuner input's info. + */ + @Nullable + @TargetApi(Build.VERSION_CODES.N) + public static TvInputInfo buildTunerInputInfo(Context context, boolean fromBuiltInTuner) { + int numOfDevices = TunerHal.getTunerCount(context); + if (numOfDevices == 0) { + return null; + } + TvInputInfo.Builder builder = new TvInputInfo.Builder(context, new ComponentName(context, + TunerTvInputService.class)); + if (fromBuiltInTuner) { + builder.setLabel(R.string.bt_app_name); + } else { + builder.setLabel(R.string.ut_app_name); + } + try { + return builder.setCanRecord(CommonFeatures.DVR.isEnabled(context)) + .setTunerCount(numOfDevices) + .build(); + } catch (NullPointerException e) { + // TunerTvInputService is not enabled. + return null; + } + } + + /** + * Updates tuner input's info. + * + * @param context {@link Context} instance + */ + public static void updateTunerInputInfo(Context context) { + if (BuildCompat.isAtLeastN()) { + if (DEBUG) Log.d(TAG, "updateTunerInputInfo()"); + TvInputInfo info = buildTunerInputInfo(context, isBuiltInTuner(context)); + if (info != null) { + ((TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE)) + .updateTvInputInfo(info); + if (DEBUG) { + Log.d(TAG, "TvInputInfo [" + info.loadLabel(context) + + "] updated: " + info.toString()); + } + } else { + if (DEBUG) { + Log.d(TAG, "Updating tuner input's info failed. Input is not ready yet."); + } + } + } + } + + /** + * Returns if the current tuner service is for a built-in tuner. + * + * @param context {@link Context} instance + */ + public static boolean isBuiltInTuner(Context context) { + return TunerHal.getTunerType(context) == TunerHal.TUNER_TYPE_BUILT_IN; + } +} diff --git a/src/com/android/tv/ui/AppLayerTvView.java b/src/com/android/tv/ui/AppLayerTvView.java index c7b94a15..09acb36b 100644 --- a/src/com/android/tv/ui/AppLayerTvView.java +++ b/src/com/android/tv/ui/AppLayerTvView.java @@ -19,6 +19,10 @@ package com.android.tv.ui; import android.content.Context; import android.media.tv.TvView; import android.util.AttributeSet; +import android.view.SurfaceView; +import android.view.View; + +import com.android.tv.experiments.Experiments; /** * A TvView class for application layer when multiple windows are being used in the app. @@ -46,4 +50,13 @@ public class AppLayerTvView extends TvView { public boolean hasWindowFocus() { return true; } + + @Override + public void onViewAdded(View child) { + if (child instanceof SurfaceView) { + // Note: See b/29118070 for detail. + ((SurfaceView) child).setSecure(!Experiments.ENABLE_DEVELOPER_FEATURES.get()); + } + super.onViewAdded(child); + } } diff --git a/src/com/android/tv/ui/ChannelBannerView.java b/src/com/android/tv/ui/ChannelBannerView.java index a36ba83c..3cf4de83 100644 --- a/src/com/android/tv/ui/ChannelBannerView.java +++ b/src/com/android/tv/ui/ChannelBannerView.java @@ -25,6 +25,7 @@ import android.content.Context; import android.content.res.Resources; import android.database.ContentObserver; import android.graphics.Bitmap; +import android.media.tv.TvContentRating; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.net.Uri; @@ -50,10 +51,14 @@ import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.common.recording.RecordedProgram; +import com.android.tv.TvApplication; +import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; import com.android.tv.data.Program; import com.android.tv.data.StreamInfo; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.parental.ContentRatingsManager; import com.android.tv.util.ImageCache; import com.android.tv.util.ImageLoader; import com.android.tv.util.ImageLoader.ImageLoaderCallback; @@ -89,6 +94,8 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage */ public static final int LOCK_CHANNEL_INFO = 2; + private static final int DISPLAYED_CONTENT_RATINGS_COUNT = 3; + private static final String EMPTY_STRING = ""; private static Program sNoProgram; @@ -106,17 +113,21 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private TextView mChannelNameTextView; private TextView mProgramTimeTextView; private ProgressBar mRemainingTimeView; + private TextView mRecordingIndicatorView; private TextView mClosedCaptionTextView; private TextView mAspectRatioTextView; private TextView mResolutionTextView; private TextView mAudioChannelTextView; + private TextView[] mContentRatingsTextViews = new TextView[DISPLAYED_CONTENT_RATINGS_COUNT]; private TextView mProgramDescriptionTextView; private String mProgramDescriptionText; private View mAnchorView; private Channel mCurrentChannel; private Program mLastUpdatedProgram; - private RecordedProgram mLastUpdatedRecordedProgram; private final Handler mHandler = new Handler(); + private final DvrManager mDvrManager; + private ContentRatingsManager mContentRatingsManager; + private TvContentRating mBlockingContentRating; private int mLockType; @@ -147,6 +158,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private final int mChannelBannerTextColor; private final int mChannelBannerDimTextColor; private final int mResizeAnimDuration; + private final int mRecordingIconPadding; private final Interpolator mResizeInterpolator; private final AnimatorListenerAdapter mResizeAnimatorListener = new AnimatorListenerAdapter() { @@ -208,10 +220,12 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage R.dimen.channel_banner_channel_logo_margin_start); mProgramDescriptionTextViewWidth = mResources.getDimensionPixelSize( R.dimen.channel_banner_program_description_width); - mChannelBannerTextColor = Utils.getColor(mResources, R.color.channel_banner_text_color); - mChannelBannerDimTextColor = Utils.getColor(mResources, - R.color.channel_banner_dim_text_color); + mChannelBannerTextColor = mResources.getColor(R.color.channel_banner_text_color, null); + mChannelBannerDimTextColor = mResources.getColor(R.color.channel_banner_dim_text_color, + null); mResizeAnimDuration = mResources.getInteger(R.integer.channel_banner_fast_anim_duration); + mRecordingIconPadding = mResources.getDimensionPixelOffset( + R.dimen.channel_banner_recording_icon_padding); mResizeInterpolator = AnimationUtils.loadInterpolator(context, android.R.interpolator.linear_out_slow_in); @@ -221,6 +235,14 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mProgramDescriptionFadeOutAnimator = AnimatorInflater.loadAnimator(mMainActivity, R.animator.channel_banner_program_description_fade_out); + if (CommonFeatures.DVR.isEnabled(mMainActivity)) { + mDvrManager = TvApplication.getSingletons(mMainActivity).getDvrManager(); + } else { + mDvrManager = null; + } + mContentRatingsManager = TvApplication.getSingletons(getContext()) + .getTvInputManagerHelper().getContentRatingsManager(); + if (sNoProgram == null) { sNoProgram = new Program.Builder() .setTitle(context.getString(R.string.channel_banner_no_title)) @@ -266,10 +288,14 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mChannelNameTextView = (TextView) findViewById(R.id.channel_name); mProgramTimeTextView = (TextView) findViewById(R.id.program_time_text); mRemainingTimeView = (ProgressBar) findViewById(R.id.remaining_time); + mRecordingIndicatorView = (TextView) findViewById(R.id.recording_indicator); mClosedCaptionTextView = (TextView) findViewById(R.id.closed_caption); mAspectRatioTextView = (TextView) findViewById(R.id.aspect_ratio); mResolutionTextView = (TextView) findViewById(R.id.resolution); mAudioChannelTextView = (TextView) findViewById(R.id.audio_channel); + mContentRatingsTextViews[0] = (TextView) findViewById(R.id.content_ratings_0); + mContentRatingsTextViews[1] = (TextView) findViewById(R.id.content_ratings_1); + mContentRatingsTextViews[2] = (TextView) findViewById(R.id.content_ratings_2); mProgramDescriptionTextView = (TextView) findViewById(R.id.program_description); mAnchorView = findViewById(R.id.anchor); @@ -335,6 +361,15 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage } /** + * Sets the content rating that blocks the current watched channel for displaying it in the + * channel banner. + */ + public void setBlockingContentRating(TvContentRating rating) { + mBlockingContentRating = rating; + updateProgramRatings(mMainActivity.getCurrentProgram()); + } + + /** * Update channel banner view. * * @param info A StreamInfo that includes stream information. @@ -343,8 +378,11 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage public void updateViews(StreamInfo info) { resetAnimationEffects(); Channel channel = mMainActivity.getCurrentChannel(); - if (!Objects.equals(mCurrentChannel, channel) && isShown()) { - scheduleHide(); + if (!Objects.equals(mCurrentChannel, channel)) { + mBlockingContentRating = null; + if (isShown()) { + scheduleHide(); + } } mCurrentChannel = channel; mChannelView.setVisibility(VISIBLE); @@ -355,11 +393,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage : null); updateChannelInfo(); } - if (mMainActivity.isRecordingPlayback()) { - updateProgramInfo(mMainActivity.getPlayingRecordedProgram()); - } else { - updateProgramInfo(mMainActivity.getCurrentProgram()); - } + updateProgramInfo(mMainActivity.getCurrentProgram()); } private void updateStreamInfo(StreamInfo info) { @@ -380,6 +414,9 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mAspectRatioTextView.setVisibility(View.GONE); mResolutionTextView.setVisibility(View.GONE); mAudioChannelTextView.setVisibility(View.GONE); + for (int i = 0; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) { + mContentRatingsTextViews[i].setVisibility(View.GONE); + } } } @@ -439,15 +476,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private String getCurrentInputId() { Channel channel = mMainActivity.getCurrentChannel(); - if (channel != null) { - return channel.getInputId(); - } else if (mMainActivity.isRecordingPlayback()) { - RecordedProgram recordedProgram = mMainActivity.getPlayingRecordedProgram(); - if (recordedProgram != null) { - return recordedProgram.getInputId(); - } - } - return null; + return channel != null ? channel.getInputId() : null; } private void updateTvInputLogo(Bitmap bitmap) { @@ -531,7 +560,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private void updateProgramInfo(Program program) { if (mLockType == LOCK_CHANNEL_INFO) { program = sLockedChannelProgram; - } else if (!Program.isValid(program) || TextUtils.isEmpty(program.getTitle())) { + } else if (program == null || !program.isValid() || TextUtils.isEmpty(program.getTitle())) { program = sNoProgram; } @@ -542,10 +571,12 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage updateProgramTextView(program); } updateProgramTimeInfo(program); + updateRecordingStatus(program); + updateProgramRatings(program); // When the program is changed, but the previous resize animation has not ended yet, // cancel the animation. - boolean isProgramChanged = !Objects.equals(mLastUpdatedProgram, program); + boolean isProgramChanged = !program.equals(mLastUpdatedProgram); if (mResizeAnimator != null && isProgramChanged) { setLastUpdatedProgram(program); mProgramInfoUpdatePendingByResizing = true; @@ -568,67 +599,15 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage setLastUpdatedProgram(program); } - private void updateProgramInfo(RecordedProgram recordedProgram) { - if (mLockType == LOCK_CHANNEL_INFO) { - updateProgramInfo(sLockedChannelProgram); - return; - } else if (recordedProgram == null) { - updateProgramInfo(sNoProgram); - return; - } - - if (mLastUpdatedRecordedProgram == null - || !TextUtils.equals(recordedProgram.getTitle(), - mLastUpdatedRecordedProgram.getTitle()) - || !TextUtils.equals(recordedProgram.getEpisodeDisplayTitle(getContext()), - mLastUpdatedRecordedProgram.getEpisodeDisplayTitle(getContext()))) { - updateProgramTextView(recordedProgram); - } - updateProgramTimeInfo(recordedProgram); - - // When the program is changed, but the previous resize animation has not ended yet, - // cancel the animation. - boolean isProgramChanged = !Objects.equals(mLastUpdatedRecordedProgram, recordedProgram); - if (mResizeAnimator != null && isProgramChanged) { - setLastUpdatedRecordedProgram(recordedProgram); - mProgramInfoUpdatePendingByResizing = true; - mResizeAnimator.cancel(); - } else if (mResizeAnimator == null) { - if (mLockType != LOCK_NONE - || TextUtils.isEmpty(recordedProgram.getShortDescription())) { - mProgramDescriptionTextView.setVisibility(GONE); - mProgramDescriptionText = ""; - } else { - mProgramDescriptionTextView.setVisibility(VISIBLE); - mProgramDescriptionText = recordedProgram.getShortDescription(); - } - String description = mProgramDescriptionTextView.getText().toString(); - boolean needFadeAnimation = isProgramChanged - || !description.equals(mProgramDescriptionText); - updateBannerHeight(needFadeAnimation); - } else { - mProgramInfoUpdatePendingByResizing = true; - } - setLastUpdatedRecordedProgram(recordedProgram); - } - private void updateProgramTextView(Program program) { if (program == null) { return; } updateProgramTextView(program == sLockedChannelProgram, program.getTitle(), - program.getEpisodeTitle(), program.getEpisodeDisplayTitle(getContext())); - } - - private void updateProgramTextView(RecordedProgram recordedProgram) { - if (recordedProgram == null) { - return; - } - updateProgramTextView(false, recordedProgram.getTitle(), recordedProgram.getEpisodeTitle(), - recordedProgram.getEpisodeDisplayTitle(getContext())); + program.getEpisodeDisplayTitle(getContext())); } - private void updateProgramTextView(boolean dimText, String title, String episodeTitle, + private void updateProgramTextView(boolean dimText, String title, String episodeDisplayTitle) { mProgramTextView.setVisibility(View.VISIBLE); if (dimText) { @@ -639,7 +618,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage updateTextView(mProgramTextView, R.dimen.channel_banner_program_large_text_size, R.dimen.channel_banner_program_large_margin_top); - if (TextUtils.isEmpty(episodeTitle)) { + if (TextUtils.isEmpty(episodeDisplayTitle)) { mProgramTextView.setText(title); } else { String fullTitle = title + " " + episodeDisplayTitle; @@ -675,61 +654,119 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage : R.dimen.channel_banner_anchor_two_line_y); } + private void updateProgramRatings(Program program) { + if (mBlockingContentRating != null) { + mContentRatingsTextViews[0].setText( + mContentRatingsManager.getDisplayNameForRating(mBlockingContentRating)); + mContentRatingsTextViews[0].setVisibility(View.VISIBLE); + for (int i = 1; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) { + mContentRatingsTextViews[i].setVisibility(View.GONE); + } + return; + } + TvContentRating[] ratings = (program == null) ? null : program.getContentRatings(); + for (int i = 0; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) { + if (ratings == null || ratings.length <= i) { + mContentRatingsTextViews[i].setVisibility(View.GONE); + } else { + mContentRatingsTextViews[i].setText( + mContentRatingsManager.getDisplayNameForRating(ratings[i])); + mContentRatingsTextViews[i].setVisibility(View.VISIBLE); + } + } + } + private void updateProgramTimeInfo(Program program) { - long startTime = program.getStartTimeUtcMillis(); - long endTime = program.getEndTimeUtcMillis(); - if (mLockType != LOCK_CHANNEL_INFO && startTime > 0 && endTime > startTime) { + long durationMs = program.getDurationMillis(); + long startTimeMs = program.getStartTimeUtcMillis(); + long endTimeMs = program.getEndTimeUtcMillis(); + + if (mLockType != LOCK_CHANNEL_INFO && durationMs > 0 && startTimeMs > 0) { mProgramTimeTextView.setVisibility(View.VISIBLE); mRemainingTimeView.setVisibility(View.VISIBLE); - mProgramTimeTextView.setText(Utils.getDurationString( - getContext(), startTime, endTime, true)); - - long currTime = mMainActivity.getCurrentPlayingPosition(); - if (currTime <= startTime) { - mRemainingTimeView.setProgress(0); - } else if (currTime >= endTime) { - mRemainingTimeView.setProgress(100); - } else { - mRemainingTimeView.setProgress( - (int) (100 * (currTime - startTime) / (endTime - startTime))); - } + getContext(), startTimeMs, endTimeMs, true)); } else { mProgramTimeTextView.setVisibility(View.GONE); mRemainingTimeView.setVisibility(View.GONE); } } - private void updateProgramTimeInfo(RecordedProgram recordedProgram) { - long durationMs = recordedProgram.getDurationMillis(); - if (mLockType != LOCK_CHANNEL_INFO && durationMs > 0) { - mProgramTimeTextView.setVisibility(View.VISIBLE); - mRemainingTimeView.setVisibility(View.VISIBLE); + private int getProgressPercent(long currTime, long startTime, long endTime) { + if (currTime <= startTime) { + return 0; + } else if (currTime >= endTime) { + return 100; + } else { + return (int) (100 * (currTime - startTime) / (endTime - startTime)); + } + } - mProgramTimeTextView.setText(DateUtils.formatElapsedTime(durationMs / 1000)); + private void updateRecordingStatus(Program program) { + if (mDvrManager == null) { + updateProgressBarAndRecIcon(program, null); + return; + } + ScheduledRecording currentRecording = (mCurrentChannel == null) ? null + : mDvrManager.getCurrentRecording(mCurrentChannel.getId()); + if (DEBUG) { + Log.d(TAG, currentRecording == null ? "No Recording" : "Recording:" + currentRecording); + } + if (currentRecording != null && isCurrentProgram(currentRecording, program)) { + updateProgressBarAndRecIcon(program, currentRecording); + } else { + updateProgressBarAndRecIcon(program, null); + } + } + + private void updateProgressBarAndRecIcon(Program program, + @Nullable ScheduledRecording recording) { + long programStartTime = program.getStartTimeUtcMillis(); + long programEndTime = program.getEndTimeUtcMillis(); + long currentPosition = mMainActivity.getCurrentPlayingPosition(); + updateRecordingIndicator(recording); + if (recording != null) { + // Recording now. Use recording-style progress bar. + mRemainingTimeView.setProgress(getProgressPercent(recording.getStartTimeMs(), + programStartTime, programEndTime)); + mRemainingTimeView.setSecondaryProgress(getProgressPercent(currentPosition, + programStartTime, programEndTime)); + } else { + // No recording is going now. Recover progress bar. + mRemainingTimeView.setProgress(getProgressPercent(currentPosition, + programStartTime, programEndTime)); + mRemainingTimeView.setSecondaryProgress(0); + } + } - long currTimeMs = mMainActivity.getCurrentPlayingPosition(); - if (currTimeMs <= 0) { - mRemainingTimeView.setProgress(0); - } else if (currTimeMs >= durationMs) { - mRemainingTimeView.setProgress(100); + private void updateRecordingIndicator(@Nullable ScheduledRecording recording) { + if (recording != null) { + if (mRemainingTimeView.getVisibility() == View.GONE) { + mRecordingIndicatorView.setText(mMainActivity.getResources().getString( + R.string.dvr_recording_till_format, DateUtils.formatDateTime(mMainActivity, + recording.getEndTimeMs(), DateUtils.FORMAT_SHOW_TIME))); + mRecordingIndicatorView.setCompoundDrawablePadding(mRecordingIconPadding); } else { - mRemainingTimeView.setProgress((int) (100 * currTimeMs / durationMs)); + mRecordingIndicatorView.setText(""); + mRecordingIndicatorView.setCompoundDrawablePadding(0); } + mRecordingIndicatorView.setVisibility(View.VISIBLE); } else { - mProgramTimeTextView.setVisibility(View.GONE); - mRemainingTimeView.setVisibility(View.GONE); + mRecordingIndicatorView.setVisibility(View.GONE); } } - private void setLastUpdatedProgram(Program program) { - mLastUpdatedProgram = program; - mLastUpdatedRecordedProgram = null; + private boolean isCurrentProgram(ScheduledRecording recording, Program program) { + long currentPosition = mMainActivity.getCurrentPlayingPosition(); + return (recording.getType() == ScheduledRecording.TYPE_PROGRAM + && recording.getProgramId() == program.getId()) + || (recording.getType() == ScheduledRecording.TYPE_TIMED + && currentPosition >= recording.getStartTimeMs() + && currentPosition <= recording.getEndTimeMs()); } - private void setLastUpdatedRecordedProgram(RecordedProgram recordedProgram) { - mLastUpdatedProgram = null; - mLastUpdatedRecordedProgram = recordedProgram; + private void setLastUpdatedProgram(Program program) { + mLastUpdatedProgram = program; } private void updateBannerHeight(boolean needFadeAnimation) { @@ -788,4 +825,4 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage animator.addListener(mResizeAnimatorListener); return animator; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/ui/GuidedActionsStylistWithDivider.java b/src/com/android/tv/ui/GuidedActionsStylistWithDivider.java new file mode 100644 index 00000000..39ec1279 --- /dev/null +++ b/src/com/android/tv/ui/GuidedActionsStylistWithDivider.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.ui; + +import android.content.Context; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidedAction; +import android.support.v17.leanback.widget.GuidedActionsStylist; + +import com.android.tv.R; + +/** + * Extended stylist class used for {@link GuidedStepFragment} with divider support. + */ +public class GuidedActionsStylistWithDivider extends GuidedActionsStylist { + /** + * ID used mark a divider. + */ + public static final int ACTION_DIVIDER = -100; + private static final int VIEW_TYPE_DIVIDER = 1; + + @Override + public int getItemViewType(GuidedAction action) { + if (action.getId() == ACTION_DIVIDER) { + return VIEW_TYPE_DIVIDER; + } + return super.getItemViewType(action); + } + + @Override + public int onProvideItemLayoutId(int viewType) { + if (viewType == VIEW_TYPE_DIVIDER) { + return R.layout.guided_action_divider; + } + return super.onProvideItemLayoutId(viewType); + } + + /** + * Creates a divider for {@link GuidedStepFragment}, targeted fragments must use + * {@link GuidedActionsStylistWithDivider} as its actions' stylist for divider to work. + */ + public static GuidedAction createDividerAction(Context context) { + return new GuidedAction.Builder(context) + .id(ACTION_DIVIDER) + .title(null) + .description(null) + .focusable(false) + .infoOnly(true) + .build(); + } +} diff --git a/src/com/android/tv/ui/OverlayRootView.java b/src/com/android/tv/ui/OverlayRootView.java deleted file mode 100644 index f6dc2537..00000000 --- a/src/com/android/tv/ui/OverlayRootView.java +++ /dev/null @@ -1,51 +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.ui; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.widget.FrameLayout; - -import com.android.tv.MainActivity; - -public class OverlayRootView extends FrameLayout { - - private final MainActivity mMainActivity; - - public OverlayRootView(Context context) { - this(context, null, 0, 0); - } - - public OverlayRootView(Context context, AttributeSet attrs) { - this(context, attrs, 0, 0); - } - - public OverlayRootView(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - public OverlayRootView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - mMainActivity = (MainActivity) context; - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - return mMainActivity.dispatchKeyEvent(event) || super.dispatchKeyEvent(event); - } -} diff --git a/src/com/android/tv/ui/SelectInputView.java b/src/com/android/tv/ui/SelectInputView.java index 646f9159..5e25ae43 100644 --- a/src/com/android/tv/ui/SelectInputView.java +++ b/src/com/android/tv/ui/SelectInputView.java @@ -34,14 +34,13 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import com.android.tv.R; import com.android.tv.ApplicationSingletons; +import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.analytics.DurationTimer; import com.android.tv.analytics.Tracker; import com.android.tv.data.Channel; import com.android.tv.util.TvInputManagerHelper; -import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; @@ -156,9 +155,9 @@ public class SelectInputView extends VerticalGridView implements mShowDurationMillis = resources.getInteger(R.integer.select_input_show_duration); mRippleAnimDurationMillis = resources.getInteger( R.integer.select_input_ripple_anim_duration); - mTextColorPrimary = Utils.getColor(resources, R.color.select_input_text_color_primary); - mTextColorSecondary = Utils.getColor(resources, R.color.select_input_text_color_secondary); - mTextColorDisabled = Utils.getColor(resources, R.color.select_input_text_color_disabled); + mTextColorPrimary = resources.getColor(R.color.select_input_text_color_primary, null); + mTextColorSecondary = resources.getColor(R.color.select_input_text_color_secondary, null); + mTextColorDisabled = resources.getColor(R.color.select_input_text_color_disabled, null); mItemViewForMeasure = LayoutInflater.from(context).inflate( R.layout.select_input_item, this, false); diff --git a/src/com/android/tv/ui/TunableTvView.java b/src/com/android/tv/ui/TunableTvView.java index 6d3d62aa..cbe459fb 100644 --- a/src/com/android/tv/ui/TunableTvView.java +++ b/src/com/android/tv/ui/TunableTvView.java @@ -20,8 +20,6 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.TimeInterpolator; import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.content.ContentUris; import android.content.Context; import android.content.pm.PackageManager; import android.media.PlaybackParams; @@ -35,13 +33,14 @@ import android.media.tv.TvView.TvInputCallback; import android.net.ConnectivityManager; import android.net.Uri; import android.os.AsyncTask; -import android.os.Build; import android.os.Bundle; import android.support.annotation.IntDef; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.UiThread; import android.support.v4.os.BuildCompat; import android.text.TextUtils; +import android.text.format.DateUtils; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; @@ -53,17 +52,16 @@ import android.widget.FrameLayout; import android.widget.ImageView; import com.android.tv.ApplicationSingletons; +import com.android.tv.InputSessionManager; +import com.android.tv.InputSessionManager.TvViewSession; import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.analytics.DurationTimer; import com.android.tv.analytics.Tracker; 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.StreamInfo; import com.android.tv.data.WatchedHistoryManager; -import com.android.tv.dvr.DvrDataManager; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.recommendation.NotificationService; import com.android.tv.util.NetworkUtils; @@ -73,8 +71,6 @@ import com.android.tv.util.Utils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.List; public class TunableTvView extends FrameLayout implements StreamInfo { @@ -82,6 +78,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private static final String TAG = "TunableTvView"; public static final int VIDEO_UNAVAILABLE_REASON_NOT_TUNED = -1; + public static final int VIDEO_UNAVAILABLE_REASON_NO_RESOURCE = -2; @Retention(RetentionPolicy.SOURCE) @IntDef({BLOCK_SCREEN_TYPE_NO_UI, BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW, BLOCK_SCREEN_TYPE_NORMAL}) @@ -108,14 +105,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private static final int FADING_IN = 2; private static final int FADING_OUT = 3; - private static final long INVALID_TIME = -1; - // It is too small to see the description text without PIP_BLOCK_SCREEN_SCALE_FACTOR. private static final float PIP_BLOCK_SCREEN_SCALE_FACTOR = 1.2f; private AppLayerTvView mTvView; + private TvViewSession mTvViewSession; private Channel mCurrentChannel; - private RecordedProgram mRecordedProgram; private TvInputManagerHelper mInputManagerHelper; private ContentRatingsManager mContentRatingsManager; @Nullable @@ -149,7 +144,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @TimeShiftState private int mTimeShiftState = TIME_SHIFT_STATE_NONE; private TimeShiftListener mTimeShiftListener; private boolean mTimeShiftAvailable; - private long mTimeShiftCurrentPositionMs = INVALID_TIME; + private long mTimeShiftCurrentPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; private final Tracker mTracker; private final DurationTimer mChannelViewTimer = new DurationTimer(); @@ -175,173 +170,167 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @BlockScreenType private int mBlockScreenType; - private final DvrDataManager mDvrDataManager; - private final ChannelDataManager mChannelDataManager; + private final TvInputManagerHelper mInputManager; private final ConnectivityManager mConnectivityManager; + private final InputSessionManager mInputSessionManager; - private final TvInputCallback mCallback = - new TvInputCallback() { - @Override - public void onConnectionFailed(String inputId) { - Log.w(TAG, "Failed to bind an input"); - mTracker.sendInputConnectionFailure(inputId); - Channel channel = mCurrentChannel; - mCurrentChannel = null; - mInputInfo = null; - mCanReceiveInputEvent = false; - if (mOnTuneListener != null) { - // If tune is called inside onTuneFailed, mOnTuneListener will be set to - // a new instance. In order to avoid to clear the new mOnTuneListener, - // we copy mOnTuneListener to l and clear mOnTuneListener before - // calling onTuneFailed. - OnTuneListener listener = mOnTuneListener; - mOnTuneListener = null; - listener.onTuneFailed(channel); - } - } + private final TvInputCallback mCallback = new TvInputCallback() { + @Override + public void onConnectionFailed(String inputId) { + Log.w(TAG, "Failed to bind an input"); + mTracker.sendInputConnectionFailure(inputId); + Channel channel = mCurrentChannel; + mCurrentChannel = null; + mInputInfo = null; + mCanReceiveInputEvent = false; + if (mOnTuneListener != null) { + // If tune is called inside onTuneFailed, mOnTuneListener will be set to + // a new instance. In order to avoid to clear the new mOnTuneListener, + // we copy mOnTuneListener to l and clear mOnTuneListener before + // calling onTuneFailed. + OnTuneListener listener = mOnTuneListener; + mOnTuneListener = null; + listener.onTuneFailed(channel); + } + } - @Override - public void onDisconnected(String inputId) { - Log.w(TAG, "Session is released by crash"); - mTracker.sendInputDisconnected(inputId); - Channel channel = mCurrentChannel; - mCurrentChannel = null; - mInputInfo = null; - mCanReceiveInputEvent = false; - if (mOnTuneListener != null) { - OnTuneListener listener = mOnTuneListener; - mOnTuneListener = null; - listener.onUnexpectedStop(channel); - } - } + @Override + public void onDisconnected(String inputId) { + Log.w(TAG, "Session is released by crash"); + mTracker.sendInputDisconnected(inputId); + Channel channel = mCurrentChannel; + mCurrentChannel = null; + mInputInfo = null; + mCanReceiveInputEvent = false; + if (mOnTuneListener != null) { + OnTuneListener listener = mOnTuneListener; + mOnTuneListener = null; + listener.onUnexpectedStop(channel); + } + } - @Override - public void onChannelRetuned(String inputId, Uri channelUri) { - if (DEBUG) { - Log.d(TAG, "onChannelRetuned(inputId=" + inputId + ", channelUri=" - + channelUri + ")"); - } - if (mOnTuneListener != null) { - mOnTuneListener.onChannelRetuned(channelUri); - } - } + @Override + public void onChannelRetuned(String inputId, Uri channelUri) { + if (DEBUG) { + Log.d(TAG, "onChannelRetuned(inputId=" + inputId + ", channelUri=" + + channelUri + ")"); + } + if (mOnTuneListener != null) { + mOnTuneListener.onChannelRetuned(channelUri); + } + } - @Override - public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) { - mHasClosedCaption = false; - for (TvTrackInfo track : tracks) { - if (track.getType() == TvTrackInfo.TYPE_SUBTITLE) { - mHasClosedCaption = true; - break; - } - } - if (mOnTuneListener != null) { - mOnTuneListener.onStreamInfoChanged(TunableTvView.this); - } + @Override + public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) { + mHasClosedCaption = false; + for (TvTrackInfo track : tracks) { + if (track.getType() == TvTrackInfo.TYPE_SUBTITLE) { + mHasClosedCaption = true; + break; } + } + if (mOnTuneListener != null) { + mOnTuneListener.onStreamInfoChanged(TunableTvView.this); + } + } - @Override - public void onTrackSelected(String inputId, int type, String trackId) { - if (trackId == null) { - // A track is unselected. - if (type == TvTrackInfo.TYPE_VIDEO) { - mVideoWidth = 0; - mVideoHeight = 0; - mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN; - mVideoFrameRate = 0f; - mVideoDisplayAspectRatio = 0f; - } else if (type == TvTrackInfo.TYPE_AUDIO) { - mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; - } - } else { - List<TvTrackInfo> tracks = getTracks(type); - boolean trackFound = false; - if (tracks != null) { - for (TvTrackInfo track : tracks) { - if (track.getId().equals(trackId)) { - if (type == TvTrackInfo.TYPE_VIDEO) { - mVideoWidth = track.getVideoWidth(); - mVideoHeight = track.getVideoHeight(); - mVideoFormat = Utils.getVideoDefinitionLevelFromSize( - mVideoWidth, mVideoHeight); - mVideoFrameRate = track.getVideoFrameRate(); - if (mVideoWidth <= 0 || mVideoHeight <= 0) { - mVideoDisplayAspectRatio = 0.0f; - } else if (android.os.Build.VERSION.SDK_INT >= - android.os.Build.VERSION_CODES.M) { - float VideoPixelAspectRatio = - track.getVideoPixelAspectRatio(); - mVideoDisplayAspectRatio = VideoPixelAspectRatio - * mVideoWidth / mVideoHeight; - } else { - mVideoDisplayAspectRatio = mVideoWidth - / (float) mVideoHeight; - } - } else if (type == TvTrackInfo.TYPE_AUDIO) { - mAudioChannelCount = track.getAudioChannelCount(); - } - trackFound = true; - break; + @Override + public void onTrackSelected(String inputId, int type, String trackId) { + if (trackId == null) { + // A track is unselected. + if (type == TvTrackInfo.TYPE_VIDEO) { + mVideoWidth = 0; + mVideoHeight = 0; + mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN; + mVideoFrameRate = 0f; + mVideoDisplayAspectRatio = 0f; + } else if (type == TvTrackInfo.TYPE_AUDIO) { + mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; + } + } else { + List<TvTrackInfo> tracks = getTracks(type); + boolean trackFound = false; + if (tracks != null) { + for (TvTrackInfo track : tracks) { + if (track.getId().equals(trackId)) { + if (type == TvTrackInfo.TYPE_VIDEO) { + mVideoWidth = track.getVideoWidth(); + mVideoHeight = track.getVideoHeight(); + mVideoFormat = Utils.getVideoDefinitionLevelFromSize( + mVideoWidth, mVideoHeight); + mVideoFrameRate = track.getVideoFrameRate(); + if (mVideoWidth <= 0 || mVideoHeight <= 0) { + mVideoDisplayAspectRatio = 0.0f; + } else { + float VideoPixelAspectRatio = + track.getVideoPixelAspectRatio(); + mVideoDisplayAspectRatio = VideoPixelAspectRatio + * mVideoWidth / mVideoHeight; } + } else if (type == TvTrackInfo.TYPE_AUDIO) { + mAudioChannelCount = track.getAudioChannelCount(); } + trackFound = true; + break; } - if (!trackFound) { - Log.w(TAG, "Invalid track ID: " + trackId); - } - } - if (mOnTuneListener != null) { - mOnTuneListener.onStreamInfoChanged(TunableTvView.this); } } - - @Override - public void onVideoAvailable(String inputId) { - unhideScreenByVideoAvailability(); - if (mOnTuneListener != null) { - mOnTuneListener.onStreamInfoChanged(TunableTvView.this); - } + if (!trackFound) { + Log.w(TAG, "Invalid track ID: " + trackId); } + } + if (mOnTuneListener != null) { + mOnTuneListener.onStreamInfoChanged(TunableTvView.this); + } + } - @Override - public void onVideoUnavailable(String inputId, int reason) { - hideScreenByVideoAvailability(reason); - if (mOnTuneListener != null) { - mOnTuneListener.onStreamInfoChanged(TunableTvView.this); - } - switch (reason) { - case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING: - case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN: - case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: - mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason); - default: - // do nothing - } - } + @Override + public void onVideoAvailable(String inputId) { + unhideScreenByVideoAvailability(); + if (mOnTuneListener != null) { + mOnTuneListener.onStreamInfoChanged(TunableTvView.this); + } + } - @Override - public void onContentAllowed(String inputId) { - mBlockScreenForTuneView.setVisibility(View.GONE); - unblockScreenByContentRating(); - if (mOnTuneListener != null) { - mOnTuneListener.onContentAllowed(); - } - } + @Override + public void onVideoUnavailable(String inputId, int reason) { + hideScreenByVideoAvailability(inputId, reason); + if (mOnTuneListener != null) { + mOnTuneListener.onStreamInfoChanged(TunableTvView.this); + } + switch (reason) { + case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING: + case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN: + case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: + mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason); + default: + // do nothing + } + } - @Override - public void onContentBlocked(String inputId, TvContentRating rating) { - blockScreenByContentRating(rating); - if (mOnTuneListener != null) { - mOnTuneListener.onContentBlocked(); - } - } + @Override + public void onContentAllowed(String inputId) { + mBlockScreenForTuneView.setVisibility(View.GONE); + unblockScreenByContentRating(); + if (mOnTuneListener != null) { + mOnTuneListener.onContentAllowed(); + } + } - @Override - @TargetApi(Build.VERSION_CODES.M) - public void onTimeShiftStatusChanged(String inputId, int status) { - boolean available = status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE; - setTimeShiftAvailable(available); - } - }; + @Override + public void onContentBlocked(String inputId, TvContentRating rating) { + blockScreenByContentRating(rating); + if (mOnTuneListener != null) { + mOnTuneListener.onContentBlocked(); + } + } + + @Override + public void onTimeShiftStatusChanged(String inputId, int status) { + boolean available = status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE; + setTimeShiftAvailable(available); + } + }; public TunableTvView(Context context) { this(context, null); @@ -360,10 +349,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo { inflate(getContext(), R.layout.tunable_tv_view, this); ApplicationSingletons appSingletons = TvApplication.getSingletons(context); - mDvrDataManager = CommonFeatures.DVR.isEnabled(context) && BuildCompat.isAtLeastN() - ? appSingletons.getDvrDataManager() - : null; - mChannelDataManager = appSingletons.getChannelDataManager(); + if (CommonFeatures.DVR.isEnabled(context)) { + mInputSessionManager = appSingletons.getInputSessionManager(); + } else { + mInputSessionManager = null; + } + mInputManager = appSingletons.getTvInputManagerHelper(); mConnectivityManager = (ConnectivityManager) context .getSystemService(Context.CONNECTIVITY_SERVICE); mCanModifyParentalControls = PermissionUtils.hasModifyParentalControls(context); @@ -409,6 +400,11 @@ public class TunableTvView extends FrameLayout implements StreamInfo { public void initialize(AppLayerTvView tvView, boolean isPip, int screenHeight, int shrunkenTvViewHeight) { mTvView = tvView; + if (mInputSessionManager != null) { + mTvViewSession = mInputSessionManager.createTvViewSession(tvView, this, mCallback); + } else { + mTvView.setCallback(mCallback); + } mIsPip = isPip; mScreenHeight = screenHeight; mShrunkenTvViewHeight = shrunkenTvViewHeight; @@ -425,6 +421,20 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mStarted = true; } + /** + * Warms up the input to reduce the start time. + */ + public void warmUpInput(String inputId, Uri channelUri) { + if (!mStarted && inputId != null && channelUri != null) { + if (mTvViewSession != null) { + mTvViewSession.tune(inputId, channelUri); + } else { + mTvView.tune(inputId, channelUri); + } + hideScreenByVideoAvailability(inputId, TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + } + } + public void stop() { if (!mStarted) { return; @@ -441,15 +451,42 @@ public class TunableTvView extends FrameLayout implements StreamInfo { reset(); } + /** + * Releases the resources. + */ + public void release() { + if (mInputSessionManager != null) { + mInputSessionManager.releaseTvViewSession(mTvViewSession); + mTvViewSession = null; + } + } + + /** + * Reset TV view. + */ public void reset() { - mTvView.reset(); + resetInternal(); + hideScreenByVideoAvailability(null, VIDEO_UNAVAILABLE_REASON_NOT_TUNED); + } + + /** + * Reset TV view to acquire the recording session. + */ + public void resetByRecording() { + resetInternal(); + } + + private void resetInternal() { + if (mTvViewSession != null) { + mTvViewSession.reset(); + } else { + mTvView.reset(); + } mCurrentChannel = null; - mRecordedProgram = null; mInputInfo = null; mCanReceiveInputEvent = false; mOnTuneListener = null; setTimeShiftAvailable(false); - hideScreenByVideoAvailability(VIDEO_UNAVAILABLE_REASON_NOT_TUNED); } public void setMain() { @@ -475,85 +512,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } /** - * Returns {@code true}, if this view is the recording playback mode. - */ - public boolean isRecordingPlayback() { - return mRecordedProgram != null; - } - - /** - * Returns the recording which is being played right now. - */ - public RecordedProgram getPlayingRecordedProgram() { - return mRecordedProgram; - } - - /** - * Plays a recording. - */ - public boolean playRecording(Uri recordingUri, OnTuneListener listener) { - if (!mStarted) { - throw new IllegalStateException("TvView isn't started"); - } - if (!CommonFeatures.DVR.isEnabled(getContext()) || !BuildCompat.isAtLeastN()) { - return false; - } - if (DEBUG) Log.d(TAG, "playRecording " + recordingUri); - long recordingId = ContentUris.parseId(recordingUri); - mRecordedProgram = mDvrDataManager.getRecordedProgram(recordingId); - if (mRecordedProgram == null) { - Log.w(TAG, "No recorded program (Uri=" + recordingUri + ")"); - return false; - } - String inputId = mRecordedProgram.getInputId(); - TvInputInfo inputInfo = mInputManagerHelper.getTvInputInfo(inputId); - if (inputInfo == null) { - return false; - } - mOnTuneListener = listener; - // mCurrentChannel can be null. - mCurrentChannel = mChannelDataManager.getChannel(mRecordedProgram.getChannelId()); - // For recording playback, input event should not be sent. - mCanReceiveInputEvent = false; - boolean needSurfaceSizeUpdate = false; - if (!inputInfo.equals(mInputInfo)) { - mInputInfo = inputInfo; - if (DEBUG) { - Log.d(TAG, "Input \'" + mInputInfo.getId() + "\' can receive input event: " - + mCanReceiveInputEvent); - } - needSurfaceSizeUpdate = true; - } - mChannelViewTimer.start(); - mVideoWidth = 0; - mVideoHeight = 0; - mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN; - mVideoFrameRate = 0f; - mVideoDisplayAspectRatio = 0f; - mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; - mHasClosedCaption = false; - mTvView.setCallback(mCallback); - mTimeShiftCurrentPositionMs = INVALID_TIME; - mTvView.setTimeShiftPositionCallback(null); - setTimeShiftAvailable(false); - mTvView.timeShiftPlay(inputId, recordingUri); - if (needSurfaceSizeUpdate && mFixedSurfaceWidth > 0 && mFixedSurfaceHeight > 0) { - // When the input is changed, TvView recreates its SurfaceView internally. - // So we need to call SurfaceHolder.setFixedSize for the new SurfaceView. - getSurfaceView().getHolder().setFixedSize(mFixedSurfaceWidth, mFixedSurfaceHeight); - } - hideScreenByVideoAvailability(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); - unblockScreenByContentRating(); - if (mParentControlEnabled) { - mBlockScreenForTuneView.setVisibility(View.VISIBLE); - } - if (mOnTuneListener != null) { - mOnTuneListener.onStreamInfoChanged(this); - } - return true; - } - - /** * Tunes to a channel with the {@code channelId}. * * @param params extra data to send it to TIS and store the data in TIMS. @@ -579,7 +537,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } mOnTuneListener = listener; mCurrentChannel = channel; - mRecordedProgram = null; boolean tunedByRecommendation = params != null && params.getString(NotificationService.TUNE_PARAMS_RECOMMENDATION_TYPE) != null; boolean needSurfaceSizeUpdate = false; @@ -603,20 +560,22 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mVideoDisplayAspectRatio = 0f; mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; mHasClosedCaption = false; - mTvView.setCallback(mCallback); - mTimeShiftCurrentPositionMs = INVALID_TIME; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // To reduce the IPCs, unregister the callback here and register it when necessary. - mTvView.setTimeShiftPositionCallback(null); - } + mTimeShiftCurrentPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; + // To reduce the IPCs, unregister the callback here and register it when necessary. + mTvView.setTimeShiftPositionCallback(null); setTimeShiftAvailable(false); - mTvView.tune(mInputInfo.getId(), mCurrentChannel.getUri(), params); if (needSurfaceSizeUpdate && mFixedSurfaceWidth > 0 && mFixedSurfaceHeight > 0) { // When the input is changed, TvView recreates its SurfaceView internally. // So we need to call SurfaceHolder.setFixedSize for the new SurfaceView. getSurfaceView().getHolder().setFixedSize(mFixedSurfaceWidth, mFixedSurfaceHeight); } - hideScreenByVideoAvailability(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + hideScreenByVideoAvailability(mInputInfo.getId(), + TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + if (mTvViewSession != null) { + mTvViewSession.tune(channel, params, listener); + } else { + mTvView.tune(mInputInfo.getId(), mCurrentChannel.getUri(), params); + } unblockScreenByContentRating(); if (channel.isPassthrough()) { mBlockScreenForTuneView.setVisibility(View.GONE); @@ -709,17 +668,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } public void unblockContent(TvContentRating rating) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - try { - Method method = TvView.class.getMethod("requestUnblockContent", - TvContentRating.class); - method.invoke(mTvView, rating); - } catch (NoSuchMethodException|IllegalAccessException|InvocationTargetException e) { - e.printStackTrace(); - } - } else { - mTvView.unblockContent(rating); - } + mTvView.unblockContent(rating); } @Override @@ -811,6 +760,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { /** * Returns currently blocked content rating. {@code null} if it's not blocked. */ + @Override public TvContentRating getBlockedContentRating() { return mBlockedContentRating; } @@ -869,11 +819,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo { || tvViewLp.gravity != lp.gravity || tvViewLp.height != lp.height || tvViewLp.width != lp.width) { - if (lp.topMargin == tvViewLp.topMargin && lp.leftMargin == tvViewLp.leftMargin) { + if (lp.topMargin == tvViewLp.topMargin && lp.leftMargin == tvViewLp.leftMargin + && !BuildCompat.isAtLeastN()) { // HACK: If top and left position aren't changed and SurfaceHolder.setFixedSize is // used, SurfaceView doesn't catch the width and height change. It causes a bug that // PIP size change isn't shown when PIP is located TOP|LEFT. So we adjust 1 px for // small size PIP as a workaround. + // Note: This framework issue has been fixed from NYC. tvViewLp.leftMargin = lp.leftMargin + 1; } else { tvViewLp.leftMargin = lp.leftMargin; @@ -889,7 +841,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } @Override - protected void onVisibilityChanged(View changedView, int visibility) { + protected void onVisibilityChanged(@NonNull View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); if (mTvView != null) { mTvView.setVisibility(visibility); @@ -1024,7 +976,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } @UiThread - private void hideScreenByVideoAvailability(int reason) { + private void hideScreenByVideoAvailability(String inputId, int reason) { mVideoAvailable = false; mVideoUnavailableReason = reason; if (mInternetCheckTask != null) { @@ -1050,6 +1002,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mute(); break; case TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING: + mHideScreenView.setVisibility(VISIBLE); + mHideScreenView.setImageVisibility(false); + mHideScreenView.setText(null); + mBufferingSpinnerView.setVisibility(VISIBLE); + mute(); + break; case VIDEO_UNAVAILABLE_REASON_NOT_TUNED: mHideScreenView.setVisibility(VISIBLE); mHideScreenView.setImageVisibility(false); @@ -1057,6 +1015,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mBufferingSpinnerView.setVisibility(GONE); mute(); break; + case VIDEO_UNAVAILABLE_REASON_NO_RESOURCE: + mHideScreenView.setVisibility(VISIBLE); + mHideScreenView.setImageVisibility(false); + mHideScreenView.setText(getTuneConflictMessage(inputId)); + mBufferingSpinnerView.setVisibility(GONE); + mute(); + break; case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN: default: mHideScreenView.setVisibility(VISIBLE); @@ -1072,6 +1037,19 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } } + private String getTuneConflictMessage(String inputId) { + if (inputId != null) { + TvInputInfo input = mInputManager.getTvInputInfo(inputId); + Long timeMs = mInputSessionManager.getEarliestRecordingSessionEndTimeMs(inputId); + if (timeMs != null) { + return getResources().getQuantityString(R.plurals.tvview_msg_input_no_resource, + input.getTunerCount(), + DateUtils.formatDateTime(getContext(), timeMs, DateUtils.FORMAT_SHOW_TIME)); + } + } + return null; + } + private void unhideScreenByVideoAvailability() { mVideoAvailable = true; mHideScreenView.setVisibility(GONE); @@ -1166,7 +1144,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } private void setTimeShiftAvailable(boolean isTimeShiftAvailable) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || mTimeShiftAvailable == isTimeShiftAvailable) { + if (mTimeShiftAvailable == isTimeShiftAvailable) { return; } mTimeShiftAvailable = isTimeShiftAvailable; @@ -1201,23 +1179,9 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } /** - * Returns the current time-shift state. It returns one of {@link #TIME_SHIFT_STATE_NONE}, - * {@link #TIME_SHIFT_STATE_PLAY}, {@link #TIME_SHIFT_STATE_PAUSE}, - * {@link #TIME_SHIFT_STATE_REWIND}, {@link #TIME_SHIFT_STATE_FAST_FORWARD} - * or {@link #TIME_SHIFT_STATE_PAUSE}. - */ - @TimeShiftState public int getTimeShiftState() { - return mTimeShiftState; - } - - /** * Plays the media, if the current input supports time-shifting. */ public void timeshiftPlay() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - return; - } if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } @@ -1231,10 +1195,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * Pauses the media, if the current input supports time-shifting. */ public void timeshiftPause() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - return; - } if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } @@ -1250,9 +1210,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * @param speed The speed to rewind the media. e.g. 2 for 2x, 3 for 3x and 4 for 4x. */ public void timeshiftRewind(int speed) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - } else if (!isTimeShiftAvailable()) { + if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } else { if (speed <= 0) { @@ -1271,9 +1229,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * @param speed The speed to forward the media. e.g. 2 for 2x, 3 for 3x and 4 for 4x. */ public void timeshiftFastForward(int speed) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - } else if (!isTimeShiftAvailable()) { + if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } else { if (speed <= 0) { @@ -1292,10 +1248,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * @param timeMs The time in milliseconds to seek to. */ public void timeshiftSeekTo(long timeMs) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - return; - } if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } @@ -1306,10 +1258,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * Returns the current playback position in milliseconds. */ public long timeshiftGetCurrentPositionMs() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Log.w(TAG, "Time shifting is not supported in this platform."); - return INVALID_TIME; - } if (!isTimeShiftAvailable()) { throw new IllegalStateException("Time-shift is not supported for the current channel"); } @@ -1332,6 +1280,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { /** * Called when the record start time has been changed. + * This is not called when the recorded programs is played. */ public abstract void onRecordStartTimeChanged(long recordStartTimeMs); } @@ -1346,7 +1295,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { public abstract void onScreenBlockingChanged(boolean blocked); } - public class InternetCheckTask extends AsyncTask<Void, Void, Boolean> { + private class InternetCheckTask extends AsyncTask<Void, Void, Boolean> { @Override protected Boolean doInBackground(Void... params) { return NetworkUtils.isNetworkAvailable(mConnectivityManager); diff --git a/src/com/android/tv/ui/TvOverlayManager.java b/src/com/android/tv/ui/TvOverlayManager.java index 94f9b0f9..e14b286b 100644 --- a/src/com/android/tv/ui/TvOverlayManager.java +++ b/src/com/android/tv/ui/TvOverlayManager.java @@ -21,22 +21,16 @@ import android.app.FragmentManager; import android.app.FragmentManager.OnBackStackChangedListener; import android.content.Intent; import android.media.tv.TvInputInfo; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.annotation.IntDef; import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.annotation.UiThread; -import android.support.v4.os.BuildCompat; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; -import android.widget.Space; import com.android.tv.ApplicationSingletons; import com.android.tv.ChannelTuner; @@ -66,29 +60,30 @@ import com.android.tv.menu.MenuRowFactory; import com.android.tv.menu.MenuView; import com.android.tv.onboarding.NewSourcesFragment; import com.android.tv.onboarding.SetupSourcesFragment; -import com.android.tv.onboarding.SetupSourcesFragment.InputSetupRunnable; import com.android.tv.search.ProgramGuideSearchFragment; import com.android.tv.ui.TvTransitionManager.SceneType; import com.android.tv.ui.sidepanel.SettingsFragment; import com.android.tv.ui.sidepanel.SideFragmentManager; import com.android.tv.ui.sidepanel.parentalcontrols.RatingsFragment; +import com.android.tv.util.TvInputManagerHelper; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; +import java.util.Queue; import java.util.Set; /** * A class responsible for the life cycle and event handling of the pop-ups over TV view. */ -// TODO: Put TvTransitionManager into this class. @UiThread public class TvOverlayManager { private static final String TAG = "TvOverlayManager"; private static final boolean DEBUG = false; - public static final String INTRO_TRACKER_LABEL = "Intro dialog"; + private static final String INTRO_TRACKER_LABEL = "Intro dialog"; @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, @@ -97,7 +92,7 @@ public class TvOverlayManager { FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS, FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANEL_HISTORY, FLAG_HIDE_OVERLAYS_KEEP_PROGRAM_GUIDE, FLAG_HIDE_OVERLAYS_KEEP_MENU, FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT}) - public @interface HideOverlayFlag {} + private @interface HideOverlayFlag {} // FLAG_HIDE_OVERLAYs must be bitwise exclusive. public static final int FLAG_HIDE_OVERLAYS_DEFAULT = 0b000000000; public static final int FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION = 0b000000010; @@ -109,7 +104,7 @@ public class TvOverlayManager { public static final int FLAG_HIDE_OVERLAYS_KEEP_MENU = 0b010000000; public static final int FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT = 0b100000000; - public static final int MSG_OVERLAY_CLOSED = 1000; + private static final int MSG_OVERLAY_CLOSED = 1000; @Retention(RetentionPolicy.SOURCE) @IntDef(flag = true, @@ -119,16 +114,51 @@ public class TvOverlayManager { OVERLAY_TYPE_SCENE_SELECT_INPUT, OVERLAY_TYPE_FRAGMENT}) private @interface TvOverlayType {} // OVERLAY_TYPEs must be bitwise exclusive. + /** + * The overlay type which indicates that there are no overlays. + */ private static final int OVERLAY_TYPE_NONE = 0b000000000; + /** + * The overlay type for menu. + */ private static final int OVERLAY_TYPE_MENU = 0b000000001; + /** + * The overlay type for the side fragment. + */ private static final int OVERLAY_TYPE_SIDE_FRAGMENT = 0b000000010; + /** + * The overlay type for dialog fragment. + */ private static final int OVERLAY_TYPE_DIALOG = 0b000000100; + /** + * The overlay type for program guide. + */ private static final int OVERLAY_TYPE_GUIDE = 0b000001000; + /** + * The overlay type for channel banner. + */ private static final int OVERLAY_TYPE_SCENE_CHANNEL_BANNER = 0b000010000; + /** + * The overlay type for input banner. + */ private static final int OVERLAY_TYPE_SCENE_INPUT_BANNER = 0b000100000; + /** + * The overlay type for keypad channel switch view. + */ private static final int OVERLAY_TYPE_SCENE_KEYPAD_CHANNEL_SWITCH = 0b001000000; + /** + * The overlay type for select input view. + */ private static final int OVERLAY_TYPE_SCENE_SELECT_INPUT = 0b010000000; + /** + * The overlay type for fragment other than the side fragment and dialog fragment. + */ private static final int OVERLAY_TYPE_FRAGMENT = 0b100000000; + // Used for the padded print of the overlay type. + private static final int NUM_OVERLAY_TYPES = 9; + + private static final String FRAGMENT_TAG_SETUP_SOURCES = "tag_setup_sources"; + private static final String FRAGMENT_TAG_NEW_SOURCES = "tag_new_sources"; private static final Set<String> AVAILABLE_DIALOG_TAGS = new HashSet<>(); static { @@ -144,6 +174,7 @@ public class TvOverlayManager { private final ChannelTuner mChannelTuner; private final TvTransitionManager mTransitionManager; private final ChannelDataManager mChannelDataManager; + private final TvInputManagerHelper mInputManager; private final Menu mMenu; private final SideFragmentManager mSideFragmentManager; private final ProgramGuide mProgramGuide; @@ -152,18 +183,19 @@ public class TvOverlayManager { private final ProgramGuideSearchFragment mSearchFragment; private final Tracker mTracker; private SafeDismissDialogFragment mCurrentDialog; - private final SetupSourcesFragment mSetupFragment; private boolean mSetupFragmentActive; - private final NewSourcesFragment mNewSourcesFragment; private boolean mNewSourcesFragmentActive; private final Handler mHandler = new TvOverlayHandler(this); private @TvOverlayType int mOpenedOverlays; private final List<Runnable> mPendingActions = new ArrayList<>(); + private final Queue<PendingDialogAction> mPendingDialogActionQueue = new LinkedList<>(); + + private OnBackStackChangedListener mOnBackStackChangedListener; public TvOverlayManager(MainActivity mainActivity, ChannelTuner channelTuner, - KeypadChannelSwitchView keypadChannelSwitchView, + TunableTvView tvView, KeypadChannelSwitchView keypadChannelSwitchView, ChannelBannerView channelBannerView, InputBannerView inputBannerView, SelectInputView selectInputView, ViewGroup sceneContainer, ProgramGuideSearchFragment searchFragment) { @@ -171,6 +203,7 @@ public class TvOverlayManager { mChannelTuner = channelTuner; ApplicationSingletons singletons = TvApplication.getSingletons(mainActivity); mChannelDataManager = singletons.getChannelDataManager(); + mInputManager = singletons.getTvInputManagerHelper(); mKeypadChannelSwitchView = keypadChannelSwitchView; mSelectInputView = selectInputView; mSearchFragment = searchFragment; @@ -180,8 +213,8 @@ public class TvOverlayManager { mTransitionManager.setListener(new TvTransitionManager.Listener() { @Override public void onSceneChanged(int fromScene, int toScene) { - // Call notifyOverlayOpened first so that the listener can know that a new scene - // will be opened when the notifyOverlayClosed is called. + // Call onOverlayOpened first so that the listener can know that a new scene + // will be opened when the onOverlayClosed is called. if (toScene != TvTransitionManager.SCENE_TYPE_EMPTY) { onOverlayOpened(convertSceneToOverlayType(toScene)); } @@ -192,7 +225,7 @@ public class TvOverlayManager { }); // Menu MenuView menuView = (MenuView) mainActivity.findViewById(R.id.menu); - mMenu = new Menu(mainActivity, menuView, new MenuRowFactory(mainActivity), + mMenu = new Menu(mainActivity, tvView, menuView, new MenuRowFactory(mainActivity, tvView), new Menu.OnMenuVisibilityChangeListener() { @Override public void onMenuVisibilityChange(boolean visible) { @@ -203,6 +236,7 @@ public class TvOverlayManager { } } }); + mMenu.setChannelTuner(mChannelTuner); // Side Fragment mSideFragmentManager = new SideFragmentManager(mainActivity, new Runnable() { @@ -232,49 +266,49 @@ public class TvOverlayManager { onOverlayClosed(OVERLAY_TYPE_GUIDE); } }; - DvrDataManager dvrDataManager = - CommonFeatures.DVR.isEnabled(mainActivity) && BuildCompat.isAtLeastN() ? singletons - .getDvrDataManager() : null; + DvrDataManager dvrDataManager = CommonFeatures.DVR.isEnabled(mainActivity) + ? singletons.getDvrDataManager() : null; mProgramGuide = new ProgramGuide(mainActivity, channelTuner, singletons.getTvInputManagerHelper(), mChannelDataManager, - singletons.getProgramDataManager(), dvrDataManager, singletons.getTracker(), - preShowRunnable, + singletons.getProgramDataManager(), dvrDataManager, + singletons.getDvrScheduleManager(), singletons.getTracker(), preShowRunnable, postHideRunnable); - mSetupFragment = new SetupSourcesFragment(); - mSetupFragment.setOnActionClickListener(new OnActionClickListener() { - @Override - public void onActionClick(String category, int id) { - switch (id) { - case SetupMultiPaneFragment.ACTION_DONE: - closeSetupFragment(true); - break; - case SetupSourcesFragment.ACTION_PLAY_STORE: - mMainActivity.showMerchantCollection(); - break; - } - } - }); - mSetupFragment.setInputSetupRunnable(new InputSetupRunnable() { - @Override - public void runInputSetup(TvInputInfo input) { - mMainActivity.startSetupActivity(input, true); - } - }); - mNewSourcesFragment = new NewSourcesFragment(); - mNewSourcesFragment.setOnActionClickListener(new OnActionClickListener() { + mMainActivity.addOnActionClickListener(new OnActionClickListener() { @Override - public void onActionClick(String category, int id) { - switch (id) { - case NewSourcesFragment.ACTION_SETUP: - closeNewSourcesFragment(false); - showSetupFragment(); + public boolean onActionClick(String category, int id, Bundle params) { + switch (category) { + case SetupSourcesFragment.ACTION_CATEGORY: + switch (id) { + case SetupMultiPaneFragment.ACTION_DONE: + closeSetupFragment(true); + return true; + case SetupSourcesFragment.ACTION_ONLINE_STORE: + mMainActivity.showMerchantCollection(); + return true; + case SetupSourcesFragment.ACTION_SETUP_INPUT: { + String inputId = params.getString( + SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID); + TvInputInfo input = mInputManager.getTvInputInfo(inputId); + mMainActivity.startSetupActivity(input, true); + return true; + } + } break; - case NewSourcesFragment.ACTION_SKIP: - // Don't remove the fragment because new fragment will be replaced with - // this fragment. - closeNewSourcesFragment(true); + case NewSourcesFragment.ACTION_CATEOGRY: + switch (id) { + case NewSourcesFragment.ACTION_SETUP: + closeNewSourcesFragment(false); + showSetupFragment(); + return true; + case NewSourcesFragment.ACTION_SKIP: + // Don't remove the fragment because new fragment will be replaced + // with this fragment. + closeNewSourcesFragment(true); + return true; + } break; } + return false; } }); } @@ -313,16 +347,29 @@ public class TvOverlayManager { * Checks whether the setup fragment is active or not. */ public boolean isSetupFragmentActive() { + // "getSetupSourcesFragment() != null" doesn't return the correct state. That's because, + // when we call showSetupFragment(), we need to put off showing the fragment until the side + // fragment is closed. Until then, getSetupSourcesFragment() returns null. So we need + // to keep additional variable which indicates if showSetupFragment() is called. return mSetupFragmentActive; } + private Fragment getSetupSourcesFragment() { + return mMainActivity.getFragmentManager().findFragmentByTag(FRAGMENT_TAG_SETUP_SOURCES); + } + /** * Checks whether the new sources fragment is active or not. */ public boolean isNewSourcesFragmentActive() { + // See the comment in "isSetupFragmentActive". return mNewSourcesFragmentActive; } + private Fragment getNewSourcesFragment() { + return mMainActivity.getFragmentManager().findFragmentByTag(FRAGMENT_TAG_NEW_SOURCES); + } + /** * Returns the instance of {@link ProgramGuide}. */ @@ -373,9 +420,10 @@ public class TvOverlayManager { return; } - Fragment old = mMainActivity.getFragmentManager().findFragmentByTag(tag); - // Do not show the dialog if the same kind of dialog is already opened. - if (old != null) { + // Do not open two dialogs at the same time. + if (mCurrentDialog != null) { + mPendingDialogActionQueue.offer(new PendingDialogAction(tag, dialog, + keepSidePanelHistory, keepProgramGuide)); return; } @@ -389,43 +437,52 @@ public class TvOverlayManager { } private void runAfterSideFragmentsAreClosed(final Runnable runnable) { - final FragmentManager manager = mMainActivity.getFragmentManager(); if (mSideFragmentManager.isSidePanelVisible()) { - manager.addOnBackStackChangedListener(new OnBackStackChangedListener() { + // When the side panel is closing, it closes all the fragments, so the new fragment + // should be opened after the side fragment becomes invisible. + final FragmentManager manager = mMainActivity.getFragmentManager(); + mOnBackStackChangedListener = new OnBackStackChangedListener() { @Override public void onBackStackChanged() { if (manager.getBackStackEntryCount() == 0) { manager.removeOnBackStackChangedListener(this); + mOnBackStackChangedListener = null; runnable.run(); } } - }); + }; + manager.addOnBackStackChangedListener(mOnBackStackChangedListener); } else { runnable.run(); } } - private void showFragment(final Fragment fragment) { + private void showFragment(final Fragment fragment, final String tag) { hideOverlays(FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT); onOverlayOpened(OVERLAY_TYPE_FRAGMENT); runAfterSideFragmentsAreClosed(new Runnable() { @Override public void run() { + if (DEBUG) Log.d(TAG, "showFragment(" + fragment + ")"); mMainActivity.getFragmentManager().beginTransaction() - .replace(R.id.fragment_container, fragment).commit(); + .replace(R.id.fragment_container, fragment, tag).commit(); } }); } - private void closeFragment(Fragment fragmentToRemove) { + private void closeFragment(String fragmentTagToRemove) { + if (DEBUG) Log.d(TAG, "closeFragment(" + fragmentTagToRemove + ")"); onOverlayClosed(OVERLAY_TYPE_FRAGMENT); - if (fragmentToRemove != null) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // In L, NPE happens if there is no next fragment when removing or hiding a fragment - // which has an exit transition. b/22631964 - // A workaround is just replacing with a dummy fragment. - mMainActivity.getFragmentManager().beginTransaction() - .replace(R.id.fragment_container, new DummyFragment()).commit(); + if (fragmentTagToRemove != null) { + Fragment fragmentToRemove = mMainActivity.getFragmentManager() + .findFragmentByTag(fragmentTagToRemove); + if (fragmentToRemove == null) { + // If the fragment has not been added to the fragment manager yet, just remove the + // listener not to add the fragment. This is needed because the side fragment is + // closed asynchronously. + mMainActivity.getFragmentManager().removeOnBackStackChangedListener( + mOnBackStackChangedListener); + mOnBackStackChangedListener = null; } else { mMainActivity.getFragmentManager().beginTransaction().remove(fragmentToRemove) .commit(); @@ -439,11 +496,12 @@ public class TvOverlayManager { public void showSetupFragment() { if (DEBUG) Log.d(TAG, "showSetupFragment"); mSetupFragmentActive = true; - mSetupFragment.enableFragmentTransition(SetupFragment.FRAGMENT_ENTER_TRANSITION + SetupSourcesFragment setupFragment = new SetupSourcesFragment(); + setupFragment.enableFragmentTransition(SetupFragment.FRAGMENT_ENTER_TRANSITION | SetupFragment.FRAGMENT_EXIT_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION | SetupFragment.FRAGMENT_REENTER_TRANSITION); - mSetupFragment.setFragmentTransition(SetupFragment.FRAGMENT_EXIT_TRANSITION, Gravity.END); - showFragment(mSetupFragment); + setupFragment.setFragmentTransition(SetupFragment.FRAGMENT_EXIT_TRANSITION, Gravity.END); + showFragment(setupFragment, FRAGMENT_TAG_SETUP_SOURCES); } // Set removeFragment to false only when the new fragment is going to be shown. @@ -453,8 +511,9 @@ public class TvOverlayManager { return; } mSetupFragmentActive = false; - closeFragment(removeFragment ? mSetupFragment : null); + closeFragment(removeFragment ? FRAGMENT_TAG_SETUP_SOURCES : null); if (mChannelDataManager.getChannelCount() == 0) { + if (DEBUG) Log.d(TAG, "Finishing MainActivity because there are no channels."); mMainActivity.finish(); } } @@ -465,14 +524,17 @@ public class TvOverlayManager { public void showNewSourcesFragment() { if (DEBUG) Log.d(TAG, "showNewSourcesFragment"); mNewSourcesFragmentActive = true; - showFragment(mNewSourcesFragment); + showFragment(new NewSourcesFragment(), FRAGMENT_TAG_NEW_SOURCES); } // Set removeFragment to false only when the new fragment is going to be shown. private void closeNewSourcesFragment(boolean removeFragment) { if (DEBUG) Log.d(TAG, "closeNewSourcesFragment"); + if (!mNewSourcesFragmentActive) { + return; + } mNewSourcesFragmentActive = false; - closeFragment(removeFragment ? mNewSourcesFragment : null); + closeFragment(removeFragment ? FRAGMENT_TAG_NEW_SOURCES : null); } /** @@ -536,7 +598,12 @@ public class TvOverlayManager { */ public void onDialogDestroyed() { mCurrentDialog = null; - onOverlayClosed(OVERLAY_TYPE_DIALOG); + PendingDialogAction action = mPendingDialogActionQueue.poll(); + if (action == null) { + onOverlayClosed(OVERLAY_TYPE_DIALOG); + } else { + action.run(); + } } /** @@ -571,18 +638,26 @@ public class TvOverlayManager { } mCurrentDialog.dismiss(); } + mPendingDialogActionQueue.clear(); mCurrentDialog = null; } boolean withAnimation = (flags & FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION) == 0; if ((flags & FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT) == 0) { + Fragment setupSourcesFragment = getSetupSourcesFragment(); + Fragment newSourcesFragment = getNewSourcesFragment(); if (mSetupFragmentActive) { - if (!withAnimation) { - mSetupFragment.setReturnTransition(null); - mSetupFragment.setExitTransition(null); + if (!withAnimation && setupSourcesFragment != null) { + setupSourcesFragment.setReturnTransition(null); + setupSourcesFragment.setExitTransition(null); } closeSetupFragment(true); - } else if (mNewSourcesFragmentActive) { + } + if (mNewSourcesFragmentActive) { + if (!withAnimation && newSourcesFragment != null) { + newSourcesFragment.setReturnTransition(null); + newSourcesFragment.setExitTransition(null); + } closeNewSourcesFragment(true); } } @@ -642,20 +717,18 @@ public class TvOverlayManager { } } - @UiThread private void onOverlayOpened(@TvOverlayType int overlayType) { - if (DEBUG) Log.d(TAG, "Overlay opened: 0b" + Integer.toBinaryString(overlayType)); + if (DEBUG) Log.d(TAG, "Overlay opened: " + toBinaryString(overlayType)); mOpenedOverlays |= overlayType; - if (DEBUG) Log.d(TAG, "Opened overlays: 0b" + Integer.toBinaryString(mOpenedOverlays)); + if (DEBUG) Log.d(TAG, "Opened overlays: " + toBinaryString(mOpenedOverlays)); mHandler.removeMessages(MSG_OVERLAY_CLOSED); mMainActivity.updateKeyInputFocus(); } - @UiThread private void onOverlayClosed(@TvOverlayType int overlayType) { - if (DEBUG) Log.d(TAG, "Overlay closed: 0b" + Integer.toBinaryString(overlayType)); + if (DEBUG) Log.d(TAG, "Overlay closed: " + toBinaryString(overlayType)); mOpenedOverlays &= ~overlayType; - if (DEBUG) Log.d(TAG, "Opened overlays: 0b" + Integer.toBinaryString(mOpenedOverlays)); + if (DEBUG) Log.d(TAG, "Opened overlays: " + toBinaryString(mOpenedOverlays)); mHandler.removeMessages(MSG_OVERLAY_CLOSED); mMainActivity.updateKeyInputFocus(); // Show the main menu again if there are no pop-ups or banners only. @@ -676,6 +749,11 @@ public class TvOverlayManager { } } + private String toBinaryString(int value) { + return String.format("0b%" + NUM_OVERLAY_TYPES + "s", Integer.toBinaryString(value)) + .replace(' ', '0'); + } + private boolean canExecuteCloseAction() { return mMainActivity.isActivityResumed() && isOnlyBannerOrNoneOpened(); } @@ -688,7 +766,6 @@ public class TvOverlayManager { /** * Runs a given {@code action} after all the overlays are closed. */ - @UiThread public void runAfterOverlaysAreClosed(Runnable action) { if (canExecuteCloseAction()) { action.run(); @@ -783,10 +860,12 @@ public class TvOverlayManager { showMenu(Menu.REASON_PLAY_CONTROLS_FAST_FORWARD); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD: timeShiftManager.jumpToPrevious(); showMenu(Menu.REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS); break; case KeyEvent.KEYCODE_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD: timeShiftManager.jumpToNext(); showMenu(Menu.REASON_PLAY_CONTROLS_JUMP_TO_NEXT); break; @@ -890,13 +969,15 @@ public class TvOverlayManager { case KeyEvent.KEYCODE_MEDIA_PREVIOUS: case KeyEvent.KEYCODE_MEDIA_REWIND: case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD: + case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD: return true; } return false; } private static class TvOverlayHandler extends WeakHandler<TvOverlayManager> { - public TvOverlayHandler(TvOverlayManager ref) { + TvOverlayHandler(TvOverlayManager ref) { super(ref); } @@ -920,16 +1001,22 @@ public class TvOverlayManager { } } - /** - * Dummny class for the workaround of b/22631964. See {@link #closeFragment}. - */ - public static class DummyFragment extends Fragment { - @Override - public @Nullable View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - final View v = new Space(inflater.getContext()); - v.setVisibility(View.GONE); - return v; + private class PendingDialogAction { + private final String mTag; + private final SafeDismissDialogFragment mDialog; + private final boolean mKeepSidePanelHistory; + private final boolean mKeepProgramGuide; + + PendingDialogAction(String tag, SafeDismissDialogFragment dialog, + boolean keepSidePanelHistory, boolean keepProgramGuide) { + mTag = tag; + mDialog = dialog; + mKeepSidePanelHistory = keepSidePanelHistory; + mKeepProgramGuide = keepProgramGuide; + } + + void run() { + showDialogFragment(mTag, mDialog, mKeepSidePanelHistory, mKeepProgramGuide); } } } diff --git a/src/com/android/tv/ui/TvViewUiManager.java b/src/com/android/tv/ui/TvViewUiManager.java index 5ad89bfa..bf874fc7 100644 --- a/src/com/android/tv/ui/TvViewUiManager.java +++ b/src/com/android/tv/ui/TvViewUiManager.java @@ -25,12 +25,14 @@ import android.animation.TypeEvaluator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Point; import android.hardware.display.DisplayManager; import android.os.Handler; +import android.os.Message; import android.preference.PreferenceManager; import android.util.Log; import android.util.Property; @@ -43,11 +45,11 @@ import android.view.ViewGroup.MarginLayoutParams; import android.view.animation.AnimationUtils; import android.widget.FrameLayout; +import com.android.tv.Features; import com.android.tv.R; import com.android.tv.TvOptionsManager; import com.android.tv.data.DisplayMode; import com.android.tv.util.TvSettings; -import com.android.tv.util.Utils; /** * The TvViewUiManager is responsible for handling UI layouting and animation of main and PIP @@ -61,6 +63,8 @@ public class TvViewUiManager { private static final float DISPLAY_MODE_EPSILON = 0.001f; private static final float DISPLAY_ASPECT_RATIO_EPSILON = 0.01f; + private static final int MSG_SET_LAYOUT_PARAMS = 1000; + private final Context mContext; private final Resources mResources; private final FrameLayout mContentView; @@ -80,7 +84,27 @@ public class TvViewUiManager { private final SharedPreferences mSharedPreferences; private final TimeInterpolator mLinearOutSlowIn; private final TimeInterpolator mFastOutLinearIn; - private final Handler mHandler = new Handler(); + private final Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch(msg.what) { + case MSG_SET_LAYOUT_PARAMS: + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) msg.obj; + if (DEBUG) { + Log.d(TAG, "setFixedSize: w=" + layoutParams.width + " h=" + + layoutParams.height); + } + mTvView.setLayoutParams(layoutParams); + // Smooth PIP size change, we don't change surface size when + // isInPictureInPictureMode is true. + if (!Features.PICTURE_IN_PICTURE.isEnabled(mContext) + || !((Activity) mContext).isInPictureInPictureMode()) { + mTvView.setFixedSurfaceSize(layoutParams.width, layoutParams.height); + } + break; + } + } + }; private int mDisplayMode; // Used to restore the previous state from ShrunkenTvView state. private int mTvViewStartMarginBeforeShrunken; @@ -148,21 +172,16 @@ public class TvViewUiManager { .getDimensionPixelOffset(R.dimen.pipview_margin_horizontal); mPipViewTopMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_top); mPipViewBottomMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_bottom); - mContentView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - int windowWidth = right - left; - int windowHeight = bottom - top; - if (windowWidth > 0 && windowHeight > 0) { - if (mWindowWidth != windowWidth || mWindowHeight != windowHeight) { - mWindowWidth = windowWidth; - mWindowHeight = windowHeight; - applyDisplayMode(mTvView.getVideoDisplayAspectRatio(), false, true); - } - } + } + + public void onConfigurationChanged(final int windowWidth, final int windowHeight) { + if (windowWidth > 0 && windowHeight > 0) { + if (mWindowWidth != windowWidth || mWindowHeight != windowHeight) { + mWindowWidth = windowWidth; + mWindowHeight = windowHeight; + applyDisplayMode(mTvView.getVideoDisplayAspectRatio(), false, true); } - }); + } } /** @@ -514,18 +533,10 @@ public class TvViewUiManager { // This block is also called when animation ends. if (isTvViewFullScreen()) { // When this layout is for full screen, fix the surface size after layout to make - // resize animation smooth. - mTvView.post(new Runnable() { - @Override - public void run() { - if (DEBUG) { - Log.d(TAG, "setFixedSize: w=" + layoutParams.width + " h=" - + layoutParams.height); - } - mTvView.setLayoutParams(layoutParams); - mTvView.setFixedSurfaceSize(layoutParams.width, layoutParams.height); - } - }); + // resize animation smooth. During PIP size change, the multiple messages can be + // queued, if we don't remove MSG_SET_LAYOUT_PARAMS. + mHandler.removeMessages(MSG_SET_LAYOUT_PARAMS); + mHandler.obtainMessage(MSG_SET_LAYOUT_PARAMS, layoutParams).sendToTarget(); } else { mTvView.setLayoutParams(layoutParams); } @@ -715,6 +726,9 @@ public class TvViewUiManager { private void applyDisplayMode(float videoDisplayAspectRatio, boolean animate, boolean forceUpdate) { + if (videoDisplayAspectRatio <= 0f) { + videoDisplayAspectRatio = (float) mWindowWidth / mWindowHeight; + } if (mAppliedDisplayedMode == mDisplayMode && mAppliedTvViewStartMargin == mTvViewStartMargin && mAppliedTvViewEndMargin == mTvViewEndMargin @@ -743,11 +757,7 @@ public class TvViewUiManager { + availableAreaHeight + ")"); } else { availableAreaRatio = (double) availableAreaWidth / availableAreaHeight; - if (videoDisplayAspectRatio <= 0f) { - videoRatio = (double) mWindowWidth / mWindowHeight; - } else { - videoRatio = videoDisplayAspectRatio; - } + videoRatio = videoDisplayAspectRatio; } int tvViewFrameTop = (mWindowHeight - availableAreaHeight) / 2; @@ -764,22 +774,22 @@ public class TvViewUiManager { if (videoRatio < availableAreaRatio) { // Y axis will be clipped. layoutParams.width = availableAreaWidth; - layoutParams.height = (int) (availableAreaWidth / videoRatio); + layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio); } else { // X axis will be clipped. - layoutParams.width = (int) (availableAreaHeight * videoRatio); + layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio); layoutParams.height = availableAreaHeight; } break; case DisplayMode.MODE_NORMAL: if (videoRatio < availableAreaRatio) { // X axis has black area. - layoutParams.width = (int) (availableAreaHeight * videoRatio); + layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio); layoutParams.height = availableAreaHeight; } else { // Y axis has black area. layoutParams.width = availableAreaWidth; - layoutParams.height = (int) (availableAreaWidth / videoRatio); + layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio); } break; } @@ -791,9 +801,9 @@ public class TvViewUiManager { // Set marginEnd as well because setTvViewPosition uses both start/end margin. layoutParams.setMarginEnd(mWindowWidth - layoutParams.width - marginStart); - setBackgroundColor(Utils.getColor(mResources, isTvViewFullScreen() - ? R.color.tvactivity_background : R.color.tvactivity_background_on_shrunken_tvview), - layoutParams, animate); + setBackgroundColor(mResources.getColor(isTvViewFullScreen() + ? R.color.tvactivity_background : R.color.tvactivity_background_on_shrunken_tvview, + null), layoutParams, animate); setTvViewPosition(layoutParams, tvViewFrame, animate); // Update the current display mode. diff --git a/src/com/android/tv/ui/ViewUtils.java b/src/com/android/tv/ui/ViewUtils.java index 5a853dcd..ac181752 100644 --- a/src/com/android/tv/ui/ViewUtils.java +++ b/src/com/android/tv/ui/ViewUtils.java @@ -16,8 +16,11 @@ package com.android.tv.ui; +import android.animation.Animator; +import android.animation.ValueAnimator; import android.util.Log; import android.view.View; +import android.view.ViewGroup.LayoutParams; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -42,4 +45,49 @@ public class ViewUtils { Log.e(TAG, "Fail to call View.setTransitionAlpha", e); } } -} + + /** + * Creates an animator in view's height + * @param target the {@link view} animator performs on. + */ + public static Animator createHeightAnimator( + final View target, int initialHeight, int targetHeight) { + ValueAnimator animator = ValueAnimator.ofInt(initialHeight, targetHeight); + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + int value = (Integer) animation.getAnimatedValue(); + if (value == 0) { + if (target.getVisibility() != View.GONE) { + target.setVisibility(View.GONE); + } + } else { + if (target.getVisibility() != View.VISIBLE) { + target.setVisibility(View.VISIBLE); + } + setLayoutHeight(target, value); + } + } + }); + return animator; + } + + /** + * Gets view's layout height. + */ + public static int getLayoutHeight(View view) { + LayoutParams layoutParams = view.getLayoutParams(); + return layoutParams.height; + } + + /** + * Sets view's layout height. + */ + public static void setLayoutHeight(View view, int height) { + LayoutParams layoutParams = view.getLayoutParams(); + if (height != layoutParams.height) { + layoutParams.height = height; + view.setLayoutParams(layoutParams); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java index b52302b6..9cc54ed2 100644 --- a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java +++ b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java @@ -165,8 +165,7 @@ public class CustomizeChannelListFragment extends SideFragment { if (item instanceof SelectGroupItem) { SelectGroupItem selectGroupItem = (SelectGroupItem) item; if (selectGroupItem.mChannelItemsInGroup.size() == 1) { - ((ChannelItem) selectGroupItem.mChannelItemsInGroup.get(0)) - .mSelectGroupItem = null; + selectGroupItem.mChannelItemsInGroup.get(0).mSelectGroupItem = null; iter.remove(); } } diff --git a/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java b/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java deleted file mode 100644 index 35cc18c2..00000000 --- a/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java +++ /dev/null @@ -1,48 +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.ui.sidepanel; - -import com.android.tv.R; - -import java.util.ArrayList; -import java.util.List; - -public class DebugOptionFragment extends SideFragment { - private static final String TRACKER_LABEL = "debug options"; - - @Override - protected String getTitle() { - return getString(R.string.menu_debug_options); - } - - @Override - public String getTrackerLabel() { - return TRACKER_LABEL; - } - - @Override - protected List<Item> getItemList() { - List<Item> items = new ArrayList<>(); - items.add(new ActionItem(getString(R.string.item_watch_history)) { - @Override - protected void onSelected() { - getMainActivity().getOverlayManager().showRecentlyWatchedDialog(); - } - }); - return items; - } -} diff --git a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java new file mode 100644 index 00000000..0d189cca --- /dev/null +++ b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java @@ -0,0 +1,101 @@ +/* + * 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.ui.sidepanel; + +import android.accounts.Account; +import android.app.Activity; +import android.app.ApplicationErrorReport; +import android.content.Intent; +import android.support.annotation.NonNull; +import android.util.Log; +import android.widget.Toast; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.BuildConfig; +import com.android.tv.data.epg.EpgFetcher; +import com.android.tv.experiments.Experiments; +import com.android.tv.tuner.TunerPreferences; + +import java.util.ArrayList; +import java.util.List; + +/** + * Options for developers only + */ +public class DeveloperOptionFragment extends SideFragment { + private static final String TAG = "DeveloperOptionFragment"; + private static final String TRACKER_LABEL = "debug options"; + + @Override + protected String getTitle() { + return getString(R.string.menu_developer_options); + } + + @Override + public String getTrackerLabel() { + return TRACKER_LABEL; + } + + @Override + protected List<Item> getItemList() { + List<Item> items = new ArrayList<>(); + if (BuildConfig.ENG) { + items.add(new ActionItem(getString(R.string.dev_item_watch_history)) { + @Override + protected void onSelected() { + getMainActivity().getOverlayManager().showRecentlyWatchedDialog(); + } + }); + } + items.add(new ActionItem(getString(R.string.dev_item_send_feedback)) { + @Override + protected void onSelected() { + Intent intent = new Intent(Intent.ACTION_APP_ERROR); + ApplicationErrorReport report = new ApplicationErrorReport(); + report.packageName = report.processName = getContext().getPackageName(); + report.time = System.currentTimeMillis(); + report.type = ApplicationErrorReport.TYPE_NONE; + intent.putExtra(Intent.EXTRA_BUG_REPORT, report); + startActivityForResult(intent, 0); + } + }); + items.add(new SwitchItem(getString(R.string.dev_item_store_ts_on), + getString(R.string.dev_item_store_ts_off), + getString(R.string.dev_item_store_ts_description)) { + @Override + protected void onUpdate() { + super.onUpdate(); + setChecked(TunerPreferences.getStoreTsStream(getContext())); + } + + @Override + protected void onSelected() { + super.onSelected(); + TunerPreferences.setStoreTsStream(getContext(), isChecked()); + } + }); + return items; + } + + + /** True if there is the dev options menu */ + public static boolean shouldShow() { + return Experiments.ENABLE_DEVELOPER_FEATURES.get() || BuildConfig.ENG; + } + +} diff --git a/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java b/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java index b084a115..29792757 100644 --- a/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java +++ b/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java @@ -16,7 +16,7 @@ package com.android.tv.ui.sidepanel; -import android.app.Activity; +import android.content.Context; import com.android.tv.R; import com.android.tv.data.DisplayMode; @@ -49,8 +49,8 @@ public class DisplayModeFragment extends SideFragment { } @Override - public void onAttach(Activity activity) { - super.onAttach(activity); + public void onAttach(Context context) { + super.onAttach(context); mTvViewUiManager = getMainActivity().getTvViewUiManager(); } diff --git a/src/com/android/tv/ui/sidepanel/SettingsFragment.java b/src/com/android/tv/ui/sidepanel/SettingsFragment.java index 6d606014..e8033a22 100644 --- a/src/com/android/tv/ui/sidepanel/SettingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/SettingsFragment.java @@ -65,7 +65,7 @@ public class SettingsFragment extends SideFragment { } } - @Override + @Override protected String getTitle() { return getResources().getString(R.string.side_panel_title_settings); } @@ -80,8 +80,8 @@ public class SettingsFragment extends SideFragment { List<Item> items = new ArrayList<>(); final Item customizeChannelListItem = new SubMenuItem( getString(R.string.settings_channel_source_item_customize_channels), - getString(R.string.settings_channel_source_item_customize_channels_description), - 0, getMainActivity().getOverlayManager().getSideFragmentManager()) { + getString(R.string.settings_channel_source_item_customize_channels_description), 0, + getMainActivity().getOverlayManager().getSideFragmentManager()) { @Override protected SideFragment getFragment() { return new CustomizeChannelListFragment(mCurrentChannelId); @@ -102,8 +102,8 @@ public class SettingsFragment extends SideFragment { customizeChannelListItem.setEnabled(false); items.add(customizeChannelListItem); final MainActivity activity = getMainActivity(); - boolean hasNewInput = SetupUtils.getInstance(activity).hasNewInput( - activity.getTvInputManagerHelper()); + boolean hasNewInput = SetupUtils.getInstance(activity) + .hasNewInput(activity.getTvInputManagerHelper()); items.add(new ActionItem( getString(R.string.settings_channel_source_item_setup), hasNewInput ? getString(R.string.settings_channel_source_item_setup_new_inputs) @@ -115,8 +115,9 @@ public class SettingsFragment extends SideFragment { } }); if (PermissionUtils.hasModifyParentalControls(getMainActivity())) { - items.add(new ActionItem(getString(R.string.settings_parental_controls), - getString(activity.getParentalControlSettings().isParentalControlsEnabled() + items.add(new ActionItem( + getString(R.string.settings_parental_controls), getString( + activity.getParentalControlSettings().isParentalControlsEnabled() ? R.string.option_toggle_parental_controls_on : R.string.option_toggle_parental_controls_off)) { @Override @@ -131,16 +132,16 @@ public class SettingsFragment extends SideFragment { @Override public void done(boolean success) { if (success) { - sideFragmentManager.show(new ParentalControlsFragment(), - false); + sideFragmentManager + .show(new ParentalControlsFragment(), false); sideFragmentManager.showSidePanel(true); } else { sideFragmentManager.hideAll(false); } } }); - tvActivity.getOverlayManager().showDialogFragment(PinDialogFragment.DIALOG_TAG, - fragment, true); + tvActivity.getOverlayManager() + .showDialogFragment(PinDialogFragment.DIALOG_TAG, fragment, true); } }); } else { diff --git a/src/com/android/tv/ui/sidepanel/SideFragment.java b/src/com/android/tv/ui/sidepanel/SideFragment.java index 8c37f40f..8df56cd2 100644 --- a/src/com/android/tv/ui/sidepanel/SideFragment.java +++ b/src/com/android/tv/ui/sidepanel/SideFragment.java @@ -16,7 +16,6 @@ package com.android.tv.ui.sidepanel; -import android.app.Activity; import android.app.Fragment; import android.content.Context; import android.graphics.drawable.RippleDrawable; @@ -80,11 +79,11 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { } @Override - public void onAttach(Activity activity) { - super.onAttach(activity); + public void onAttach(Context context) { + super.onAttach(context); mChannelDataManager = getMainActivity().getChannelDataManager(); mProgramDataManager = getMainActivity().getProgramDataManager(); - mTracker = TvApplication.getSingletons(activity).getTracker(); + mTracker = TvApplication.getSingletons(context).getTracker(); } @Override @@ -236,6 +235,9 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { void onSideFragmentViewDestroyed(); } + /** + * Preloads the view holders. + */ public static void preloadRecycledViews(Context context) { if (sRecycledViewPool != null) { return; @@ -253,6 +255,13 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { } } + /** + * Releases the pre-loaded view holders. + */ + public static void releasePreloadedRecycledViews() { + sRecycledViewPool = null; + } + private static class ItemAdapter extends RecyclerView.Adapter<ItemAdapter.ViewHolder> { private final LayoutInflater mLayoutInflater; private List<Item> mItems; diff --git a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java index faccbc66..553cd9d7 100644 --- a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java +++ b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java @@ -22,6 +22,7 @@ import android.animation.AnimatorListenerAdapter; import android.app.Activity; import android.app.FragmentManager; import android.app.FragmentTransaction; +import android.os.Handler; import android.view.View; import com.android.tv.R; @@ -42,6 +43,7 @@ public class SideFragmentManager { private final Animator mShowAnimator; private final Animator mHideAnimator; + private final Handler mHandler = new Handler(); private final Runnable mHideAllRunnable = new Runnable() { @Override public void run() { @@ -154,6 +156,7 @@ public class SideFragmentManager { } private void hideAllInternal() { + mHandler.removeCallbacksAndMessages(null); if (mFragmentCount == 0) { return; } @@ -192,8 +195,8 @@ public class SideFragmentManager { * stack. If you want to empty the back stack, call {@link #hideAll}. */ public void hideSidePanel(boolean withAnimation) { + mHandler.removeCallbacks(mHideAllRunnable); if (withAnimation) { - mPanel.removeCallbacks(mHideAllRunnable); Animator hideAnimator = AnimatorInflater.loadAnimator(mActivity, R.animator.side_panel_exit); hideAnimator.setTarget(mPanel); @@ -213,9 +216,12 @@ public class SideFragmentManager { return mPanel.getVisibility() == View.VISIBLE; } + /** + * Resets the timer for hiding side fragment. + */ public void scheduleHideAll() { - mPanel.removeCallbacks(mHideAllRunnable); - mPanel.postDelayed(mHideAllRunnable, mShowDurationMillis); + mHandler.removeCallbacks(mHideAllRunnable); + mHandler.postDelayed(mHideAllRunnable, mShowDurationMillis); } /** diff --git a/src/com/android/tv/util/AccountHelper.java b/src/com/android/tv/util/AccountHelper.java new file mode 100644 index 00000000..ece13de1 --- /dev/null +++ b/src/com/android/tv/util/AccountHelper.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.util; + +import android.accounts.Account; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.RemoteException; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.util.Log; + + +import java.util.Arrays; + +/** + * Helper methods for getting and selecting a user account. + */ +public class AccountHelper { + private static final String TAG = "AccountHelper"; + private static final boolean DEBUG = false; + private static final String SELECTED_ACCOUNT = "android.tv.livechannels.selected_account"; + + private final Context mContext; + private final SharedPreferences mDefaultPreferences; + + @Nullable + private Account mSelectedAccount; + + public AccountHelper(Context context) { + mContext = context.getApplicationContext(); + mDefaultPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); + } + + /** + * Returns the currently selected account or {@code null} if none is selected. + */ + @Nullable + public Account getSelectedAccount() { + String accountId = mDefaultPreferences.getString(SELECTED_ACCOUNT, null); + if (accountId == null) { + return null; + } + if (mSelectedAccount == null || !mSelectedAccount.name.equals((accountId))) { + mSelectedAccount = null; + for (Account account : getEligibleAccounts()) { + if (account.name.equals(accountId)) { + mSelectedAccount = account; + break; + } + } + } + return mSelectedAccount; + } + + /** + * Returns all eligible accounts . + */ + private Account[] getEligibleAccounts() { + return new Account[0]; + } + + /** + * Selects the first account available. + * + * @return selected account or {@code null} if none is selected. + */ + @Nullable + public Account selectFirstAccount() { + Account account = getFirstEligibleAccount(); + if (account != null) { + selectAccount(account); + } + return account; + } + + /** + * Gets the first account eligible. + * + * @return first account or {@code null} if none is eligible. + */ + @Nullable + public Account getFirstEligibleAccount() { + Account[] accounts = getEligibleAccounts(); + return accounts.length == 0 ? null : accounts[0]; + } + + /** + * Sets the given account as the selected account. + */ + private void selectAccount(Account account) { + SharedPreferences defaultPreferences = PreferenceManager + .getDefaultSharedPreferences(mContext); + defaultPreferences.edit().putString(SELECTED_ACCOUNT, account.name).commit(); + } +} + diff --git a/src/com/android/tv/util/AsyncDbTask.java b/src/com/android/tv/util/AsyncDbTask.java index 7ac293fc..78243642 100644 --- a/src/com/android/tv/util/AsyncDbTask.java +++ b/src/com/android/tv/util/AsyncDbTask.java @@ -19,6 +19,7 @@ package com.android.tv.util; import android.content.ContentResolver; 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.support.annotation.MainThread; @@ -30,6 +31,7 @@ import android.util.Range; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.Program; +import com.android.tv.dvr.RecordedProgram; import java.util.ArrayList; import java.util.List; @@ -52,7 +54,7 @@ public abstract class AsyncDbTask<Params, Progress, Result> private static final String TAG = "AsyncDbTask"; private static final boolean DEBUG = false; - public static final NamedThreadFactory THREAD_FACTORY = new NamedThreadFactory( + private static final NamedThreadFactory THREAD_FACTORY = new NamedThreadFactory( AsyncDbTask.class.getSimpleName()); private static final ExecutorService DB_EXECUTOR = Executors .newSingleThreadExecutor(THREAD_FACTORY); @@ -160,7 +162,7 @@ public abstract class AsyncDbTask<Params, Progress, Result> @Override public String toString() { - return this.getClass().getSimpleName() + "(" + mUri + ")"; + return this.getClass().getName() + "(" + mUri + ")"; } } @@ -172,10 +174,17 @@ public abstract class AsyncDbTask<Params, Progress, Result> * @param <T> the type of result returned in a list by {@link #onQuery(Cursor)} */ public abstract static class AsyncQueryListTask<T> extends AsyncQueryTask<List<T>> { + private final CursorFilter mFilter; public AsyncQueryListTask(ContentResolver contentResolver, Uri uri, String[] projection, String selection, String[] selectionArgs, String orderBy) { + this(contentResolver, uri, projection, selection, selectionArgs, orderBy, null); + } + + public AsyncQueryListTask(ContentResolver contentResolver, Uri uri, String[] projection, + String selection, String[] selectionArgs, String orderBy, CursorFilter filter) { super(contentResolver, uri, projection, selection, selectionArgs, orderBy); + mFilter = filter; } @Override @@ -186,6 +195,9 @@ public abstract class AsyncDbTask<Params, Progress, Result> // This is guaranteed to never call onPostExecute because the task is canceled. return null; } + if (mFilter != null && !mFilter.filter(c)) { + continue; + } T t = fromCursor(c); result.add(t); } @@ -273,6 +285,41 @@ public abstract class AsyncDbTask<Params, Progress, Result> } /** + * Gets an {@link List} of {@link Program}s from {@link TvContract.Programs#CONTENT_URI}. + */ + public abstract static class AsyncProgramQueryTask extends AsyncQueryListTask<Program> { + public AsyncProgramQueryTask(ContentResolver contentResolver) { + super(contentResolver, Programs.CONTENT_URI, Program.PROJECTION, null, null, null); + } + + public AsyncProgramQueryTask(ContentResolver contentResolver, Uri uri, String selection, + String[] selectionArgs, String sortOrder, CursorFilter filter) { + super(contentResolver, uri, Program.PROJECTION, selection, selectionArgs, sortOrder, + filter); + } + + @Override + protected final Program fromCursor(Cursor c) { + return Program.fromCursor(c); + } + } + + /** + * Gets an {@link List} of {@link TvContract.RecordedPrograms}s. + */ + public abstract static class AsyncRecordedProgramQueryTask + extends AsyncQueryListTask<RecordedProgram> { + public AsyncRecordedProgramQueryTask(ContentResolver contentResolver, Uri uri) { + super(contentResolver, uri, RecordedProgram.PROJECTION, null, null, null); + } + + @Override + protected final RecordedProgram fromCursor(Cursor c) { + return RecordedProgram.fromCursor(c); + } + } + + /** * Execute the task on the {@link #DB_EXECUTOR} thread. */ @SafeVarargs @@ -286,7 +333,7 @@ public abstract class AsyncDbTask<Params, Progress, Result> * TvContract#buildProgramsUriForChannel(long, long, long)}. If the {@code period} is * {@code null}, then all the programs is queried. */ - public static class LoadProgramsForChannelTask extends AsyncQueryListTask<Program> { + public static class LoadProgramsForChannelTask extends AsyncProgramQueryTask { protected final Range<Long> mPeriod; protected final long mChannelId; @@ -296,16 +343,11 @@ public abstract class AsyncDbTask<Params, Progress, Result> ? TvContract.buildProgramsUriForChannel(channelId) : TvContract.buildProgramsUriForChannel(channelId, period.getLower(), period.getUpper()), - Program.PROJECTION, null, null, null); + null, null, null, null); mPeriod = period; mChannelId = channelId; } - @Override - protected final Program fromCursor(Cursor c) { - return Program.fromCursor(c); - } - public long getChannelId() { return mChannelId; } @@ -314,4 +356,25 @@ public abstract class AsyncDbTask<Params, Progress, Result> return mPeriod; } } + + /** + * Gets a single {@link Program} from {@link TvContract.Programs#CONTENT_URI}. + */ + public static class AsyncQueryProgramTask extends AsyncQueryItemTask<Program> { + + public AsyncQueryProgramTask(ContentResolver contentResolver, long programId) { + super(contentResolver, TvContract.buildProgramUri(programId), Program.PROJECTION, null, + null, null); + } + + @Override + protected Program fromCursor(Cursor c) { + return Program.fromCursor(c); + } + } + + /** + * An interface which filters the row. + */ + public interface CursorFilter extends Filter<Cursor> { } } diff --git a/src/com/android/tv/util/BitmapUtils.java b/src/com/android/tv/util/BitmapUtils.java index 78b77e65..d45a8dce 100644 --- a/src/com/android/tv/util/BitmapUtils.java +++ b/src/com/android/tv/util/BitmapUtils.java @@ -33,6 +33,7 @@ import java.io.BufferedInputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; @@ -84,9 +85,20 @@ public final class BitmapUtils { return null; } + Uri uri = Uri.parse(uriString).normalizeScheme(); + boolean isResourceUri = isContentResolverUri(uri); + URLConnection urlConnection = null; InputStream inputStream = null; try { - inputStream = new BufferedInputStream(getInputStream(context, uriString)); + if (isResourceUri) { + inputStream = context.getContentResolver().openInputStream(uri); + } else { + // If the URLConnection is HttpURLConnection, disconnect() should be called + // explicitly. + urlConnection = getUrlConnection(uriString); + inputStream = urlConnection.getInputStream(); + } + inputStream = new BufferedInputStream(inputStream); inputStream.mark(MARK_READ_LIMIT); // Check the bitmap dimensions. @@ -98,13 +110,16 @@ public final class BitmapUtils { try { inputStream.reset(); } catch (IOException e) { - if (DEBUG) { - Log.i(TAG, "Failed to rewind stream: " + uriString, e); - } + if (DEBUG) Log.i(TAG, "Failed to rewind stream: " + uriString, e); // Failed to rewind the stream, try to reopen it. - close(inputStream); - inputStream = getInputStream(context, uriString); + close(inputStream, urlConnection); + if (isResourceUri) { + inputStream = context.getContentResolver().openInputStream(uri); + } else { + urlConnection = getUrlConnection(uriString); + inputStream = urlConnection.getInputStream(); + } } // Decode the bitmap possibly resizing it. @@ -126,10 +141,17 @@ public final class BitmapUtils { Log.e(TAG, "Failed to open stream: " + uriString, e); return null; } finally { - close(inputStream); + close(inputStream, urlConnection); } } + private static URLConnection getUrlConnection(String uriString) throws IOException { + URLConnection urlConnection = new URL(uriString).openConnection(); + urlConnection.setConnectTimeout(CONNECTION_TIMEOUT_MS_FOR_URLCONNECTION); + urlConnection.setReadTimeout(READ_TIMEOUT_MS_FOR_URLCONNECTION); + return urlConnection; + } + private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { return calculateInSampleSize(options.outWidth, options.outHeight, reqWidth, reqHeight); @@ -142,20 +164,6 @@ public final class BitmapUtils { return Math.max(1, Integer.highestOneBit(ratio)); } - private static InputStream getInputStream(Context context, String uriString) - throws IOException { - Uri uri = Uri.parse(uriString).normalizeScheme(); - if (isContentResolverUri(uri)) { - return context.getContentResolver().openInputStream(uri); - } else { - // TODO We should disconnect() the URLConnection in order to allow connection reuse. - URLConnection urlConnection = new URL(uriString).openConnection(); - urlConnection.setConnectTimeout(CONNECTION_TIMEOUT_MS_FOR_URLCONNECTION); - urlConnection.setReadTimeout(READ_TIMEOUT_MS_FOR_URLCONNECTION); - return urlConnection.getInputStream(); - } - } - private static boolean isContentResolverUri(Uri uri) { String scheme = uri.getScheme(); return ContentResolver.SCHEME_CONTENT.equals(scheme) @@ -163,7 +171,7 @@ public final class BitmapUtils { || ContentResolver.SCHEME_FILE.equals(scheme); } - private static void close(Closeable closeable) { + private static void close(Closeable closeable, URLConnection urlConnection) { if (closeable != null) { try { closeable.close(); @@ -172,6 +180,9 @@ public final class BitmapUtils { Log.w(TAG,"Error closing " + closeable, e); } } + if (urlConnection instanceof HttpURLConnection) { + ((HttpURLConnection) urlConnection).disconnect(); + } } /** diff --git a/src/com/android/tv/util/CompositeComparator.java b/src/com/android/tv/util/CompositeComparator.java new file mode 100644 index 00000000..47cf50fe --- /dev/null +++ b/src/com/android/tv/util/CompositeComparator.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.util; + +import java.util.Comparator; + +/** + * A comparator which runs multiple comparators sequentially. + */ +public class CompositeComparator<T> implements Comparator<T> { + private final Comparator<T>[] mComparators; + + @SafeVarargs + public CompositeComparator(Comparator<T>... comparators) { + mComparators = comparators; + } + + @Override + public int compare(T lhs, T rhs) { + for (Comparator<T> comparator : mComparators) { + int result = comparator.compare(lhs, rhs); + if (result != 0) { + return result; + } + } + return 0; + } +} diff --git a/src/com/android/tv/util/Filter.java b/src/com/android/tv/util/Filter.java new file mode 100644 index 00000000..d5b356e4 --- /dev/null +++ b/src/com/android/tv/util/Filter.java @@ -0,0 +1,27 @@ +/* + * 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.util; + +/** + * Interface to decide whether an input is filtered out or not. + */ +public interface Filter<T> { + /** + * Returns true, if {@code input} is acceptable. + */ + boolean filter(T input); +} diff --git a/src/com/android/tv/util/ImageCache.java b/src/com/android/tv/util/ImageCache.java index db64d4c9..b413c364 100644 --- a/src/com/android/tv/util/ImageCache.java +++ b/src/com/android/tv/util/ImageCache.java @@ -145,6 +145,16 @@ public class ImageCache implements MemoryManageable { } /** + * Remove from memory cache. + * + * @param key Unique identifier for which item to remove + * @return The previous bitmap mapped by key + */ + public ScaledBitmapInfo remove(String key) { + return mMemoryCache.remove(key); + } + + /** * Calculates the memory cache size based on a percentage of the max available VM memory. Eg. * setting percent to 0.2 would set the memory cache to one fifth of the available memory. * Throws {@link IllegalArgumentException} if percent is < 0.05 or > .8. memCacheSize is stored diff --git a/src/com/android/tv/util/ImageLoader.java b/src/com/android/tv/util/ImageLoader.java index ed0fd54d..04bb478a 100644 --- a/src/com/android/tv/util/ImageLoader.java +++ b/src/com/android/tv/util/ImageLoader.java @@ -64,8 +64,7 @@ public final class ImageLoader { private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader"); - private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<Runnable>( - 128); + private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>(128); /** * An private {@link Executor} that can be used to execute tasks in parallel. @@ -380,7 +379,7 @@ public final class ImageLoader { public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) { super(context, cache, - info.getId() + "-logo", + getTvInputLogoKey(info.getId()), context.getResources() .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size), context.getResources() @@ -402,6 +401,13 @@ public final class ImageLoader { } return BitmapUtils.createScaledBitmapInfo(getKey(), original, mMaxWidth, mMaxHeight); } + + /** + * Returns key of TV input logo. + */ + public static String getTvInputLogoKey(String inputId) { + return inputId + "-logo"; + } } private static synchronized Handler getMainHandler() { diff --git a/src/com/android/tv/util/LocationUtils.java b/src/com/android/tv/util/LocationUtils.java new file mode 100644 index 00000000..8e3b59e9 --- /dev/null +++ b/src/com/android/tv/util/LocationUtils.java @@ -0,0 +1,120 @@ +/* + * 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.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.util.Log; + + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +/** + * A utility class to get the current location. + */ +public class LocationUtils { + private static final String TAG = "LocationUtils"; + private static final boolean DEBUG = false; + + private static Context sApplicationContext; + private static Address sAddress; + private static IOException sError; + + /** + * Checks the current location. + */ + public static synchronized Address getCurrentAddress(Context context) throws IOException, + SecurityException { + if (sAddress != null) { + return sAddress; + } + if (sError != null) { + throw sError; + } + if (sApplicationContext == null) { + sApplicationContext = context.getApplicationContext(); + } + LocationUtilsHelper.startLocationUpdates(); + return null; + } + + private static void updateAddress(Location location) { + if (DEBUG) Log.d(TAG, "Updating address with " + location); + if (location == null) { + return; + } + Geocoder geocoder = new Geocoder(sApplicationContext, Locale.getDefault()); + try { + List<Address> addresses = geocoder.getFromLocation( + location.getLatitude(), location.getLongitude(), 1); + if (addresses != null) { + sAddress = addresses.get(0); + if (DEBUG) Log.d(TAG, "Got " + sAddress); + } else { + if (DEBUG) Log.d(TAG, "No address returned"); + } + sError = null; + } catch (IOException e) { + Log.w(TAG, "Error in updating address", e); + sError = e; + } + } + + private LocationUtils() { } + + private static class LocationUtilsHelper { + private static final LocationListener LOCATION_LISTENER = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + updateAddress(location); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { } + + @Override + public void onProviderEnabled(String provider) { } + + @Override + public void onProviderDisabled(String provider) { } + }; + + private static LocationManager sLocationManager; + + public static void startLocationUpdates() { + if (sLocationManager == null) { + sLocationManager = (LocationManager) sApplicationContext.getSystemService( + Context.LOCATION_SERVICE); + try { + sLocationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 1000, 10, LOCATION_LISTENER, null); + } catch (SecurityException e) { + // Enables requesting the location updates again. + sLocationManager = null; + throw e; + } + } + } + } +} diff --git a/src/com/android/tv/util/OnboardingUtils.java b/src/com/android/tv/util/OnboardingUtils.java index 3dcc324d..3040020e 100644 --- a/src/com/android/tv/util/OnboardingUtils.java +++ b/src/com/android/tv/util/OnboardingUtils.java @@ -36,12 +36,12 @@ public final class OnboardingUtils { private static final String PREF_KEY_ONBOARDING_VERSION_CODE = "pref_onbaording_versionCode"; private static final int ONBOARDING_VERSION = 1; - private static final String MERCHANT_COLLECTION_URL_STRING = - "TODO: put a market link to show TV input apps"; + private static final String MERCHANT_COLLECTION_URL_STRING = getMerchantCollectionUrl(); + /** - * Intent to show merchant collection in play store. + * Intent to show merchant collection in online store. */ - public static final Intent PLAY_STORE_INTENT = new Intent(Intent.ACTION_VIEW, + public static final Intent ONLINE_STORE_INTENT = new Intent(Intent.ACTION_VIEW, Uri.parse(MERCHANT_COLLECTION_URL_STRING)); /** @@ -112,4 +112,11 @@ public final class OnboardingUtils { return TvApplication.getSingletons(context).getTvInputManagerHelper() .getTvInputInfos(true, false).size() > 0; } + + /** + * Returns merchant collection URL. + */ + private static String getMerchantCollectionUrl() { + return "TODO: add a merchant collection url"; + } } diff --git a/src/com/android/tv/util/PermissionUtils.java b/src/com/android/tv/util/PermissionUtils.java index f39dba81..453885a4 100644 --- a/src/com/android/tv/util/PermissionUtils.java +++ b/src/com/android/tv/util/PermissionUtils.java @@ -2,69 +2,49 @@ package com.android.tv.util; import android.content.Context; import android.content.pm.PackageManager; -import android.os.Build; /** * Util class to handle permissions. */ public class PermissionUtils { + /** + * Permission to read the TV listings. + */ + public static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS"; + private static Boolean sHasAccessAllEpgPermission; private static Boolean sHasAccessWatchedHistoryPermission; private static Boolean sHasModifyParentalControlsPermission; public static boolean hasAccessAllEpg(Context context) { if (sHasAccessAllEpgPermission == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - sHasAccessAllEpgPermission = context.checkSelfPermission( - "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA") - == PackageManager.PERMISSION_GRANTED; - } else { - sHasAccessAllEpgPermission = context.getPackageManager().checkPermission( - "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA", - context.getPackageName()) == PackageManager.PERMISSION_GRANTED; - } + sHasAccessAllEpgPermission = context.checkSelfPermission( + "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA") + == PackageManager.PERMISSION_GRANTED; } return sHasAccessAllEpgPermission; } public static boolean hasAccessWatchedHistory(Context context) { if (sHasAccessWatchedHistoryPermission == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - sHasAccessWatchedHistoryPermission = context.checkSelfPermission( - "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS") - == PackageManager.PERMISSION_GRANTED; - } else { - sHasAccessWatchedHistoryPermission = context.getPackageManager().checkPermission( - "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS", - context.getPackageName()) == PackageManager.PERMISSION_GRANTED; - } + sHasAccessWatchedHistoryPermission = context.checkSelfPermission( + "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS") + == PackageManager.PERMISSION_GRANTED; } return sHasAccessWatchedHistoryPermission; } public static boolean hasModifyParentalControls(Context context) { if (sHasModifyParentalControlsPermission == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - sHasModifyParentalControlsPermission = context.checkSelfPermission( - "android.permission.MODIFY_PARENTAL_CONTROLS") - == PackageManager.PERMISSION_GRANTED; - } else { - sHasModifyParentalControlsPermission = context.getPackageManager().checkPermission( - "android.permission.MODIFY_PARENTAL_CONTROLS", - context.getPackageName()) == PackageManager.PERMISSION_GRANTED; - } + sHasModifyParentalControlsPermission = context.checkSelfPermission( + "android.permission.MODIFY_PARENTAL_CONTROLS") + == PackageManager.PERMISSION_GRANTED; } return sHasModifyParentalControlsPermission; } public static boolean hasReadTvListings(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return context.checkSelfPermission("android.permission.READ_TV_LISTINGS") - == PackageManager.PERMISSION_GRANTED; - } else { - return context.getPackageManager().checkPermission( - "android.permission.MODIFY_PARENTAL_CONTROLS", - context.getPackageName()) == PackageManager.PERMISSION_GRANTED; - } + return context.checkSelfPermission(PERMISSION_READ_TV_LISTINGS) + == PackageManager.PERMISSION_GRANTED; } } diff --git a/src/com/android/tv/util/PipInputManager.java b/src/com/android/tv/util/PipInputManager.java index 03bdc681..2c51d5a0 100644 --- a/src/com/android/tv/util/PipInputManager.java +++ b/src/com/android/tv/util/PipInputManager.java @@ -149,6 +149,7 @@ public class PipInputManager { if (mStarted) { return; } + mStarted = true; mInputManager.addCallback(mTvInputCallback); mChannelTuner.addListener(mChannelTunerListener); initializePipInputList(); @@ -161,6 +162,7 @@ public class PipInputManager { if (!mStarted) { return; } + mStarted = false; mInputManager.removeCallback(mTvInputCallback); mChannelTuner.removeListener(mChannelTunerListener); mPipInputMap.clear(); diff --git a/src/com/android/tv/util/RecurringRunner.java b/src/com/android/tv/util/RecurringRunner.java index 5e65715e..4135bd4e 100644 --- a/src/com/android/tv/util/RecurringRunner.java +++ b/src/com/android/tv/util/RecurringRunner.java @@ -52,13 +52,13 @@ public final class RecurringRunner { mRunnable = runnable; mOnStopRunnable = onStopRunnable; mIntervalMs = intervalMs; - if (DEBUG) Log.i(TAG, "Delaying " + (intervalMs / 1000.0) + " seconds"); mName = runnable.getClass().getCanonicalName(); + if (DEBUG) Log.i(TAG, " Delaying " + mName + " " + (intervalMs / 1000.0) + " seconds"); mHandler = new Handler(mContext.getMainLooper()); } public void start() { - SoftPreconditions.checkState(!mRunning, TAG, "start is called twice."); + SoftPreconditions.checkState(!mRunning, TAG, mName + " start is called twice."); if (mRunning) { return; } @@ -107,7 +107,7 @@ public final class RecurringRunner { if (!posted) { Log.w(TAG, "Scheduling a future run of " + mName + " at " + new Date(next) + "failed"); } - if (DEBUG) Log.i(TAG, "Actual delay is " + (delay / 1000.0) + " seconds."); + if (DEBUG) Log.i(TAG, "Actual delay of " + mName + " is " + (delay / 1000.0) + " seconds."); } private SharedPreferences getSharedPreferences() { diff --git a/src/com/android/tv/util/SearchManagerHelper.java b/src/com/android/tv/util/SearchManagerHelper.java index 5ec1b455..b6e34d7a 100644 --- a/src/com/android/tv/util/SearchManagerHelper.java +++ b/src/com/android/tv/util/SearchManagerHelper.java @@ -18,7 +18,6 @@ package com.android.tv.util; import android.app.SearchManager; import android.content.Context; -import android.os.Build; import android.os.Bundle; import android.os.UserHandle; import android.util.Log; @@ -52,15 +51,8 @@ public final class SearchManagerHelper { public void launchAssistAction() { try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - SearchManager.class.getDeclaredMethod( - "launchLegacyAssist", String.class, Integer.TYPE, Bundle.class).invoke( - mSearchManager, null, UserHandle.myUserId(), null); - } else { - SearchManager.class.getDeclaredMethod( - "launchAssistAction", Integer.TYPE, String.class, Integer.TYPE).invoke( - mSearchManager, 0, null, UserHandle.myUserId()); - } + SearchManager.class.getDeclaredMethod("launchLegacyAssist", String.class, Integer.TYPE, + Bundle.class).invoke(mSearchManager, null, UserHandle.myUserId(), null); } catch (NoSuchMethodException | IllegalArgumentException | IllegalAccessException | InvocationTargetException e) { Log.e(TAG, "Fail to call SearchManager.launchAssistAction", e); diff --git a/src/com/android/tv/util/SetupUtils.java b/src/com/android/tv/util/SetupUtils.java index a7d9c423..8223a81c 100644 --- a/src/com/android/tv/util/SetupUtils.java +++ b/src/com/android/tv/util/SetupUtils.java @@ -20,10 +20,11 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; -import android.os.Build; import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.annotation.UiThread; @@ -36,6 +37,9 @@ 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.epg.EpgFetcher; +import com.android.tv.experiments.Experiments; +import com.android.tv.tuner.tvinput.TunerTvInputService; import java.util.Collections; import java.util.HashSet; @@ -64,23 +68,23 @@ public class SetupUtils { private final Set<String> mSetUpInputs; private final Set<String> mRecognizedInputs; private boolean mIsFirstTune; - private final String mUsbTunerInputId; + private final String mTunerInputId; private SetupUtils(TvApplication tvApplication) { mTvApplication = tvApplication; mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(tvApplication); mSetUpInputs = new ArraySet<>(); mSetUpInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_SET_UP_INPUTS, - Collections.<String>emptySet())); + Collections.emptySet())); mKnownInputs = new ArraySet<>(); mKnownInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_KNOWN_INPUTS, - Collections.<String>emptySet())); + Collections.emptySet())); mRecognizedInputs = new ArraySet<>(); mRecognizedInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_RECOGNIZED_INPUTS, mKnownInputs)); mIsFirstTune = mSharedPreferences.getBoolean(PREF_KEY_IS_FIRST_TUNE, true); - mUsbTunerInputId = TvContract.buildInputId(new ComponentName(tvApplication, - com.android.usbtuner.tvinput.UsbTunerTvInputService.class)); + mTunerInputId = TvContract.buildInputId(new ComponentName(tvApplication, + TunerTvInputService.class)); } /** @@ -264,15 +268,11 @@ public class SetupUtils { * @param context The Context used for granting permission. */ public static void grantEpgPermissionToSetUpPackages(Context context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Can't grant permission. - return; - } - // Find all already-verified packages. Set<String> setUpPackages = new HashSet<>(); SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - for (String input : sp.getStringSet(PREF_KEY_SET_UP_INPUTS, Collections.<String>emptySet())) { + for (String input : sp.getStringSet(PREF_KEY_SET_UP_INPUTS, + Collections.<String>emptySet())) { if (!TextUtils.isEmpty(input)) { ComponentName componentName = ComponentName.unflattenFromString(input); if (componentName != null) { @@ -293,21 +293,18 @@ public class SetupUtils { * @param packageName The name of the package to give permission. */ public static void grantEpgPermission(Context context, String packageName) { - // TvProvider allows granting of Uri permissions starting from MNC. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (DEBUG) { - Log.d(TAG, "grantEpgPermission(context=" + context + ", packageName=" + packageName - + ")"); - } - try { - int modeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; - context.grantUriPermission(packageName, TvContract.Channels.CONTENT_URI, modeFlags); - context.grantUriPermission(packageName, TvContract.Programs.CONTENT_URI, modeFlags); - } catch (SecurityException e) { - Log.e(TAG, "Either TvProvider does not allow granting of Uri permissions or the app" - + " does not have permission.", e); - } + if (DEBUG) { + Log.d(TAG, "grantEpgPermission(context=" + context + ", packageName=" + packageName + + ")"); + } + try { + int modeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + context.grantUriPermission(packageName, TvContract.Channels.CONTENT_URI, modeFlags); + context.grantUriPermission(packageName, TvContract.Programs.CONTENT_URI, modeFlags); + } catch (SecurityException e) { + Log.e(TAG, "Either TvProvider does not allow granting of Uri permissions or the app" + + " does not have permission.", e); } } @@ -335,17 +332,31 @@ public class SetupUtils { // A USB tuner device can be temporarily unplugged. We do not remove the USB tuner input // from the known inputs so that the input won't appear as a new input whenever the user // plugs in the USB tuner device again. - removedInputList.remove(mUsbTunerInputId); + removedInputList.remove(mTunerInputId); if (!removedInputList.isEmpty()) { + boolean inputPackageDeleted = false; for (String input : removedInputList) { - mRecognizedInputs.remove(input); - mSetUpInputs.remove(input); - mKnownInputs.remove(input); + try { + // Just after booting, input list from TvInputManager are not reliable. + // So we need to double-check package existence. b/29034900 + mTvApplication.getPackageManager().getPackageInfo( + ComponentName.unflattenFromString(input) + .getPackageName(), PackageManager.GET_ACTIVITIES); + Log.i(TAG, "TV input (" + input + ") is removed but package is not deleted"); + } catch (NameNotFoundException e) { + Log.i(TAG, "TV input (" + input + ") and its package are removed"); + mRecognizedInputs.remove(input); + mSetUpInputs.remove(input); + mKnownInputs.remove(input); + inputPackageDeleted = true; + } + } + if (inputPackageDeleted) { + mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs) + .putStringSet(PREF_KEY_KNOWN_INPUTS, mKnownInputs) + .putStringSet(PREF_KEY_RECOGNIZED_INPUTS, mRecognizedInputs).apply(); } - mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs) - .putStringSet(PREF_KEY_KNOWN_INPUTS, mKnownInputs) - .putStringSet(PREF_KEY_RECOGNIZED_INPUTS, mRecognizedInputs).apply(); } } @@ -353,7 +364,7 @@ public class SetupUtils { * Called when an setup is done. Once it is called, {@link #isSetupDone} returns {@code true} * for {@code inputId}. */ - public void onSetupDone(String inputId) { + private void onSetupDone(String inputId) { SoftPreconditions.checkState(inputId != null); if (DEBUG) Log.d(TAG, "onSetupDone: input=" + inputId); if (!mRecognizedInputs.contains(inputId)) { @@ -371,5 +382,13 @@ public class SetupUtils { mSetUpInputs.add(inputId); mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs).apply(); } + // Start fetching program guide data for internal tuners. + Context context = mTvApplication.getApplicationContext(); + if (Utils.isInternalTvInput(context, inputId)) { + if (context.checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED && Experiments.CLOUD_EPG.get()) { + EpgFetcher.getInstance(context).startImmediately(); + } + } } } diff --git a/src/com/android/tv/util/SystemProperties.java b/src/com/android/tv/util/SystemProperties.java index 235161b6..e737f233 100644 --- a/src/com/android/tv/util/SystemProperties.java +++ b/src/com/android/tv/util/SystemProperties.java @@ -36,12 +36,6 @@ public final class SystemProperties { "tv_allow_strict_mode", true); /** - * Allow Strict death penalty for eng builds. - */ - public static final BooleanSystemProperty ALLOW_DEATH_PENALTY = new BooleanSystemProperty( - "tv_allow_death_penalty", true); - - /** * When true {@link android.view.KeyEvent}s are logged. Defaults to false. */ public static final BooleanSystemProperty LOG_KEYEVENT = new BooleanSystemProperty( diff --git a/src/com/android/tv/util/TimeShiftUtils.java b/src/com/android/tv/util/TimeShiftUtils.java new file mode 100644 index 00000000..238d0e74 --- /dev/null +++ b/src/com/android/tv/util/TimeShiftUtils.java @@ -0,0 +1,63 @@ +/* + * 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.util; + +import java.util.concurrent.TimeUnit; + +// TODO: move related functions in TimeShiftManger here. +/** + * A class that includes convenience methods for time shift plays. + */ +public class TimeShiftUtils { + private static final String TAG = "TimeShiftUtils"; + private static final boolean DEBUG = false; + + private static final long SHORT_PROGRAM_THRESHOLD_MILLIS = TimeUnit.MINUTES.toMillis(46); + private static final int[] SHORT_PROGRAM_SPEED_FACTORS = new int[] {2, 4, 12, 48}; + private static final int[] LONG_PROGRAM_SPEED_FACTORS = new int[] {2, 8, 32, 128}; + + /** + * The maximum play speed level support by time shift play. In other words, the valid + * speed levels are ranged from 0 to MAX_SPEED_LEVEL (included). + */ + public static final int MAX_SPEED_LEVEL = SHORT_PROGRAM_SPEED_FACTORS.length - 1; + + /** + * Returns real speeds used in time shift play. This method is only for fast-forwarding and + * rewinding. The normal play speed is not addressed here. + * + * @param speedLevel the valid value is ranged from 0 to {@link MAX_SPPED_LEVEL}. + * @param programDurationMillis the length of program under playing. + * @throws IndexOutOfBoundsException if speed level is out of its range. + */ + public static int getPlaybackSpeed(int speedLevel, long programDurationMillis) + throws IndexOutOfBoundsException { + return (programDurationMillis > SHORT_PROGRAM_THRESHOLD_MILLIS) ? + LONG_PROGRAM_SPEED_FACTORS[speedLevel] : SHORT_PROGRAM_SPEED_FACTORS[speedLevel]; + } + + /** + * Returns the maxium possible play speed according to the program's length. + * @param programDurationMillis the length of program under playing. + */ + public static int getMaxPlaybackSpeed(long programDurationMillis) { + return (programDurationMillis > SHORT_PROGRAM_THRESHOLD_MILLIS) ? + LONG_PROGRAM_SPEED_FACTORS[MAX_SPEED_LEVEL] + : SHORT_PROGRAM_SPEED_FACTORS[MAX_SPEED_LEVEL]; + } +} + diff --git a/src/com/android/tv/util/ToastUtils.java b/src/com/android/tv/util/ToastUtils.java new file mode 100644 index 00000000..34346b2a --- /dev/null +++ b/src/com/android/tv/util/ToastUtils.java @@ -0,0 +1,43 @@ +/* + * 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.util; + +import android.content.Context; +import android.support.annotation.MainThread; +import android.widget.Toast; + +import java.lang.ref.WeakReference; + +/** + * A utility class for the toast message. + */ +public class ToastUtils { + private static WeakReference<Toast> sToast; + + /** + * Shows the toast message after canceling the previous one. + */ + @MainThread + public static void show(Context context, CharSequence text, int duration) { + if (sToast != null && sToast.get() != null) { + sToast.get().cancel(); + } + Toast toast = Toast.makeText(context, text, duration); + toast.show(); + sToast = new WeakReference<>(toast); + } +} diff --git a/src/com/android/tv/util/TvInputManagerHelper.java b/src/com/android/tv/util/TvInputManagerHelper.java index b4149637..121f56ed 100644 --- a/src/com/android/tv/util/TvInputManagerHelper.java +++ b/src/com/android/tv/util/TvInputManagerHelper.java @@ -26,6 +26,7 @@ import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; +import com.android.tv.Features; import com.android.tv.common.SoftPreconditions; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; @@ -37,21 +38,12 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; public class TvInputManagerHelper { private static final String TAG = "TvInputManagerHelper"; private static final boolean DEBUG = false; - - // Hardcoded list for known bundled inputs not written by OEM/SOCs. - // Bundled (system) inputs not in the list will get the high priority - // so they and their channels come first in the UI. - private static final Set<String> BUNDLED_PACKAGE_SET = new HashSet<>(); - - static { - BUNDLED_PACKAGE_SET.add("com.android.tv"); - BUNDLED_PACKAGE_SET.add("com.android.usbtuner"); - } + private static final String[] PARTNER_TUNER_INPUT_PREFIX_BLACKLIST = { + }; private final Context mContext; private final TvInputManager mTvInputManager; @@ -62,6 +54,9 @@ public class TvInputManagerHelper { @Override public void onInputStateChanged(String inputId, int state) { if (DEBUG) Log.d(TAG, "onInputStateChanged " + inputId + " state=" + state); + if (isInBlackList(inputId)) { + return; + } mInputStateMap.put(inputId, state); for (TvInputCallback callback : mCallbacks) { callback.onInputStateChanged(inputId, state); @@ -71,6 +66,9 @@ public class TvInputManagerHelper { @Override public void onInputAdded(String inputId) { if (DEBUG) Log.d(TAG, "onInputAdded " + inputId); + if (isInBlackList(inputId)) { + return; + } TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); if (info != null) { mInputMap.put(inputId, info); @@ -93,16 +91,34 @@ public class TvInputManagerHelper { for (TvInputCallback callback : mCallbacks) { callback.onInputRemoved(inputId); } + ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey( + inputId)); } @Override public void onInputUpdated(String inputId) { if (DEBUG) Log.d(TAG, "onInputUpdated " + inputId); + if (isInBlackList(inputId)) { + return; + } TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); mInputMap.put(inputId, info); for (TvInputCallback callback : mCallbacks) { callback.onInputUpdated(inputId); } + ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey( + inputId)); + } + + @Override + public void onTvInputInfoUpdated(TvInputInfo inputInfo) { + if (DEBUG) Log.d(TAG, "onTvInputInfoUpdated " + inputInfo); + mInputMap.put(inputInfo.getId(), inputInfo); + for (TvInputCallback callback : mCallbacks) { + callback.onTvInputInfoUpdated(inputInfo); + } + ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey( + inputInfo.getId())); } }; @@ -134,6 +150,9 @@ public class TvInputManagerHelper { for (TvInputInfo input : mTvInputManager.getTvInputList()) { if (DEBUG) Log.d(TAG, "Input detected " + input); String inputId = input.getId(); + if (isInBlackList(inputId)) { + continue; + } mInputMap.put(inputId, input); int state = mTvInputManager.getInputState(inputId); mInputStateMap.put(inputId, state); @@ -204,9 +223,8 @@ public class TvInputManagerHelper { * Is the input one known bundled inputs not written by OEM/SOCs. */ public boolean isBundledInput(TvInputInfo inputInfo) { - return inputInfo != null - && BUNDLED_PACKAGE_SET.contains( - inputInfo.getServiceInfo().applicationInfo.packageName); + return inputInfo != null && Utils.isInBundledPackageSet(inputInfo.getServiceInfo() + .applicationInfo.packageName); } /** @@ -236,10 +254,7 @@ public class TvInputManagerHelper { public boolean hasTvInputInfo(String inputId) { SoftPreconditions.checkState(mStarted, TAG, "hasTvInputInfo() called before TvInputManagerHelper was started."); - if (!mStarted) { - return false; - } - return !TextUtils.isEmpty(inputId) && mInputMap.get(inputId) != null; + return mStarted && !TextUtils.isEmpty(inputId) && mInputMap.get(inputId) != null; } public TvInputInfo getTvInputInfo(String inputId) { @@ -306,6 +321,18 @@ public class TvInputManagerHelper { return mContentRatingsManager; } + private boolean isInBlackList(String inputId) { + if (!Features.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) { + return false; + } + for (String disabledTunerInputPrefix : PARTNER_TUNER_INPUT_PREFIX_BLACKLIST) { + if (inputId.contains(disabledTunerInputPrefix)) { + return true; + } + } + return false; + } + /** * Default comparator for TvInputInfo. * diff --git a/src/com/android/tv/util/TvProviderUriMatcher.java b/src/com/android/tv/util/TvProviderUriMatcher.java new file mode 100644 index 00000000..749e4aa3 --- /dev/null +++ b/src/com/android/tv/util/TvProviderUriMatcher.java @@ -0,0 +1,72 @@ +/* + * 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.util; + +import android.content.UriMatcher; +import android.media.tv.TvContract; +import android.net.Uri; +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Utility class to aid in matching URIs in TvProvider. + */ +public class TvProviderUriMatcher { + private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + @Retention(RetentionPolicy.SOURCE) + @IntDef({MATCH_CHANNEL, MATCH_CHANNEL_ID, MATCH_PROGRAM, MATCH_PROGRAM_ID, + MATCH_RECORDED_PROGRAM, MATCH_RECORDED_PROGRAM_ID, MATCH_WATCHED_PROGRAM_ID}) + private @interface TvProviderUriMatchCode {} + /** The code for the channels URI. */ + public static final int MATCH_CHANNEL = 1; + /** The code for the channel URI. */ + public static final int MATCH_CHANNEL_ID = 2; + /** The code for the programs URI. */ + public static final int MATCH_PROGRAM = 3; + /** The code for the program URI. */ + public static final int MATCH_PROGRAM_ID = 4; + /** The code for the recorded programs URI. */ + public static final int MATCH_RECORDED_PROGRAM = 5; + /** The code for the recorded program URI. */ + public static final int MATCH_RECORDED_PROGRAM_ID = 6; + /** The code for the watched program URI. */ + public static final int MATCH_WATCHED_PROGRAM_ID = 7; + static { + URI_MATCHER.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL); + URI_MATCHER.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID); + URI_MATCHER.addURI(TvContract.AUTHORITY, "program", MATCH_PROGRAM); + URI_MATCHER.addURI(TvContract.AUTHORITY, "program/#", MATCH_PROGRAM_ID); + URI_MATCHER.addURI(TvContract.AUTHORITY, "recorded_program", MATCH_RECORDED_PROGRAM); + URI_MATCHER.addURI(TvContract.AUTHORITY, "recorded_program/#", MATCH_RECORDED_PROGRAM_ID); + URI_MATCHER.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID); + } + + private TvProviderUriMatcher() { } + + /** + * Try to match against the path in a url. + * + * @see UriMatcher#match + */ + @SuppressWarnings("WrongConstant") + @TvProviderUriMatchCode public static int match(Uri uri) { + return URI_MATCHER.match(uri); + } +} diff --git a/src/com/android/tv/util/TvSettings.java b/src/com/android/tv/util/TvSettings.java index 133fdd72..97ff59d6 100644 --- a/src/com/android/tv/util/TvSettings.java +++ b/src/com/android/tv/util/TvSettings.java @@ -34,9 +34,6 @@ import java.util.Set; public final class TvSettings { private TvSettings() {} - public static final String PREFS_FILE = "settings"; - public static final String PREF_TV_WATCH_LOGGING_ENABLED = "tv_watch_logging_enabled"; - public static final String PREF_CLOSED_CAPTION_ENABLED = "is_cc_enabled"; // boolean value public static final String PREF_DISPLAY_MODE = "display_mode"; // int value public static final String PREF_PIP_LAYOUT = "pip_layout"; // int value public static final String PREF_PIP_SIZE = "pip_size"; // int value @@ -49,7 +46,6 @@ public final class TvSettings { public @interface PipSound {} public static final int PIP_SOUND_MAIN = 0; public static final int PIP_SOUND_PIP_WINDOW = PIP_SOUND_MAIN + 1; - public static final int PIP_SOUND_LAST = PIP_SOUND_PIP_WINDOW; // PIP layouts @Retention(RetentionPolicy.SOURCE) @@ -225,7 +221,7 @@ public final class TvSettings { private static Set<String> getContentRatingSystemSet(Context context) { return new HashSet<>(PreferenceManager.getDefaultSharedPreferences(context) - .getStringSet(PREF_CONTENT_RATING_SYSTEMS, Collections.<String>emptySet())); + .getStringSet(PREF_CONTENT_RATING_SYSTEMS, Collections.emptySet())); } @ContentRatingLevel diff --git a/src/com/android/tv/util/TvTrackInfoUtils.java b/src/com/android/tv/util/TvTrackInfoUtils.java index 3006f963..c004f001 100644 --- a/src/com/android/tv/util/TvTrackInfoUtils.java +++ b/src/com/android/tv/util/TvTrackInfoUtils.java @@ -50,8 +50,12 @@ public class TvTrackInfoUtils { if (rhs == null) { return 1; } - boolean rhsLangMatch = Utils.isEqualLanguage(rhs.getLanguage(), language); - boolean lhsLangMatch = Utils.isEqualLanguage(lhs.getLanguage(), language); + // Assumes {@code null} language matches to any language since it means user hasn't + // selected any track before or selected a track without language information. + boolean rhsLangMatch = language == null || Utils.isEqualLanguage(rhs.getLanguage(), + language); + boolean lhsLangMatch = language == null || Utils.isEqualLanguage(lhs.getLanguage(), + language); if (rhsLangMatch) { if (lhsLangMatch) { boolean rhsCountMatch = rhs.getAudioChannelCount() == channelCount; diff --git a/src/com/android/tv/util/Utils.java b/src/com/android/tv/util/Utils.java index a763fe58..99d34431 100644 --- a/src/com/android/tv/util/Utils.java +++ b/src/com/android/tv/util/Utils.java @@ -23,35 +23,37 @@ import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.content.res.ColorStateList; import android.content.res.Configuration; -import android.content.res.Resources; -import android.content.res.Resources.Theme; import android.database.Cursor; import android.media.tv.TvContract; import android.media.tv.TvContract.Channels; +import android.media.tv.TvContract.Programs.Genres; import android.media.tv.TvInputInfo; import android.media.tv.TvTrackInfo; import android.net.Uri; -import android.os.Build; import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.text.format.DateUtils; +import android.util.ArraySet; import android.util.Log; import android.view.View; -import android.widget.Toast; +import com.android.tv.ApplicationSingletons; 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.GenreItems; import com.android.tv.data.Program; import com.android.tv.data.StreamInfo; +import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.Collection; import java.util.Date; @@ -69,13 +71,17 @@ public class Utils { private static final String TAG = "Utils"; private static final boolean DEBUG = false; - private static final SimpleDateFormat ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + private static final SimpleDateFormat ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", + Locale.US); public static final String EXTRA_KEY_KEYCODE = "keycode"; public static final String EXTRA_KEY_ACTION = "action"; public static final String EXTRA_ACTION_SHOW_TV_INPUT ="show_tv_input"; public static final String EXTRA_KEY_FROM_LAUNCHER = "from_launcher"; - public static final String EXTRA_KEY_RECORDING_URI = "recording_uri"; + public static final String EXTRA_KEY_RECORDED_PROGRAM_ID = "recorded_program_id"; + public static final String EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME = "recorded_program_seek_time"; + public static final String EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED = + "recorded_program_pin_checked"; // Query parameter in the intent of starting MainActivity. public static final String PARAM_SOURCE = "source"; @@ -87,6 +93,10 @@ public class Utils { private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT = "last_watched_channel_id_for_input_"; private static final String PREF_KEY_LAST_WATCHED_CHANNEL_URI = "last_watched_channel_uri"; + private static final String PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID = + "last_watched_tuner_input_id"; + private static final String PREF_KEY_RECORDING_FAILED_REASONS = + "recording_failed_reasons"; private static final int VIDEO_SD_WIDTH = 704; private static final int VIDEO_SD_HEIGHT = 480; @@ -103,6 +113,18 @@ public class Utils { private static final int AUDIO_CHANNEL_SURROUND_6 = 6; private static final int AUDIO_CHANNEL_SURROUND_8 = 8; + private static final long RECORDING_FAILED_REASON_NONE = 0; + private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); + + // Hardcoded list for known bundled inputs not written by OEM/SOCs. + // Bundled (system) inputs not in the list will get the high priority + // so they and their channels come first in the UI. + private static final Set<String> BUNDLED_PACKAGE_SET = new ArraySet<>(); + + static { + BUNDLED_PACKAGE_SET.add("com.android.tv"); + } + private enum AspectRatio { ASPECT_RATIO_4_3(4, 3), ASPECT_RATIO_16_9(16, 9), @@ -166,13 +188,34 @@ public class Utils { throw new IllegalArgumentException("channelId should be equal to or larger than 0"); } PreferenceManager.getDefaultSharedPreferences(context).edit() - .putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, channelId).apply(); - PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, channelId) .putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT + channel.getInputId(), - channelId).apply(); + channelId) + .putString(PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID, channel.getInputId()) + .apply(); } } + /** + * Sets recording failed reason. + */ + public static void setRecordingFailedReason(Context context, int reason) { + long reasons = getRecordingFailedReasons(context) | 0x1 << reason; + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(PREF_KEY_RECORDING_FAILED_REASONS, reasons) + .apply(); + } + + /** + * Clears recording failed reason. + */ + public static void clearRecordingFailedReason(Context context, int reason) { + long reasons = getRecordingFailedReasons(context) & ~(0x1 << reason); + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(PREF_KEY_RECORDING_FAILED_REASONS, reasons) + .apply(); + } + public static long getLastWatchedChannelId(Context context) { return PreferenceManager.getDefaultSharedPreferences(context) .getLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, Channel.INVALID_ID); @@ -189,6 +232,28 @@ public class Utils { } /** + * Returns the last watched tuner input id. + */ + public static String getLastWatchedTunerInputId(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID, null); + } + + private static long getRecordingFailedReasons(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getLong(PREF_KEY_RECORDING_FAILED_REASONS, + RECORDING_FAILED_REASON_NONE); + } + + /** + * Checks do recording failed reason exist. + */ + public static boolean hasRecordingFailedReason(Context context, int reason) { + long reasons = getRecordingFailedReasons(context); + return (reasons & 0x1 << reason) != 0; + } + + /** * Returns {@code true}, if {@code uri} specifies an input, which is usually generated * from {@link TvContract#buildChannelsUriForInput}. */ @@ -286,11 +351,25 @@ public class Utils { } @VisibleForTesting - static String getDurationString(Context context, long baseMillis, - long startUtcMillis, long endUtcMillis, boolean useShortFormat, int flag) { - flag |= DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_TIME + static String getDurationString(Context context, long baseMillis, long startUtcMillis, + long endUtcMillis, boolean useShortFormat, int flag) { + return getDurationString(context, startUtcMillis, endUtcMillis, + useShortFormat, !isInGivenDay(baseMillis, startUtcMillis), true, flag); + } + + /** + * Returns duration string according to the time format, may not contain date information. + * Note: At least one of showDate and showTime should be true. + */ + public static String getDurationString(Context context, long startUtcMillis, long endUtcMillis, + boolean useShortFormat, boolean showDate, boolean showTime, int flag) { + flag |= DateUtils.FORMAT_ABBREV_MONTH | ((useShortFormat) ? DateUtils.FORMAT_NUMERIC_DATE : 0); - if (!isInGivenDay(baseMillis, startUtcMillis)) { + SoftPreconditions.checkArgument(showTime || showDate); + if (showTime) { + flag |= DateUtils.FORMAT_SHOW_TIME; + } + if (showDate) { flag |= DateUtils.FORMAT_SHOW_DATE; } if (startUtcMillis != endUtcMillis && useShortFormat) { @@ -300,13 +379,17 @@ public class Utils { if (!isInGivenDay(startUtcMillis, endUtcMillis - 1) && endUtcMillis - startUtcMillis < TimeUnit.HOURS.toMillis(11)) { // Do not show date for short format. - // Extracting a day is needed because {@link DateUtils@formatDateRange} - // adds date if the duration covers multiple days. + // Subtracting one day is needed because {@link DateUtils@formatDateRange} + // automatically shows date if the duration covers multiple days. return DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis - TimeUnit.DAYS.toMillis(1), flag); } } - return DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis, flag); + // Workaround of b/28740989. + // Add 1 msec to endUtcMillis to avoid DateUtils' bug with a duration of 12:00AM~12:00AM. + String dateRange = DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis, flag); + return startUtcMillis == endUtcMillis || dateRange.contains("–") ? dateRange + : DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis + 1, flag); } @VisibleForTesting @@ -321,6 +404,39 @@ public class Utils { == Utils.floorTime(subjectTimeInMillis + offset, DAY_IN_MS); } + /** + * Calculate how many days between two milliseconds. + */ + public static int computeDateDifference(long startTimeMs, long endTimeMs) { + Calendar calFrom = Calendar.getInstance(); + Calendar calTo = Calendar.getInstance(); + calFrom.setTime(new Date(startTimeMs)); + calTo.setTime(new Date(endTimeMs)); + resetCalendar(calFrom); + resetCalendar(calTo); + return (int) ((calTo.getTimeInMillis() - calFrom.getTimeInMillis()) / ONE_DAY_MS); + } + + private static void resetCalendar(Calendar cal) { + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + } + + /** + * Returns the last millisecond of a day which the millis belongs to. + */ + public static long getLastMillisecondOfDay(long millis) { + Calendar calender = Calendar.getInstance(); + calender.setTime(new Date(millis)); + calender.set(Calendar.HOUR_OF_DAY, 23); + calender.set(Calendar.MINUTE, 59); + calender.set(Calendar.SECOND, 59); + calender.set(Calendar.MILLISECOND, 999); + return calender.getTimeInMillis(); + } + public static String getAspectRatioString(int width, int height) { if (width == 0 || height == 0) { return ""; @@ -510,9 +626,27 @@ public class Utils { /** * Converts time in milliseconds to a String. + * + * @param fullFormat {@code true} for returning date string with a full format + * (e.g., Mon Aug 15 20:08:35 GMT 2016). {@code false} for a short format, + * {e.g., [8/15/16] 8:08 AM}, in which date information would only appears + * when the target time is not today. + */ + public static String toTimeString(long timeMillis, boolean fullFormat) { + if (fullFormat) { + return new Date(timeMillis).toString(); + } else { + long currentTime = System.currentTimeMillis(); + return (String) DateUtils.formatSameDayTime(timeMillis, System.currentTimeMillis(), + SimpleDateFormat.SHORT, SimpleDateFormat.SHORT); + } + } + + /** + * Converts time in milliseconds to a String. */ public static String toTimeString(long timeMillis) { - return new Date(timeMillis).toString(); + return toTimeString(timeMillis, true); } /** @@ -566,57 +700,7 @@ public class Utils { * @return index >= 0 && index < collection.size(). */ public static boolean isIndexValid(@Nullable Collection<?> collection, int index) { - return collection == null ? false : index >= 0 && index < collection.size(); - } - - /** - * Returns a color integer associated with a particular resource ID. - * - * @see #getColor(android.content.res.Resources,int,Theme) - */ - public static int getColor(Resources res, int id) { - return getColor(res, id, null); - } - - /** - * Returns a color integer associated with a particular resource ID. - * - * <p>In M version, {@link android.content.res.Resources#getColor(int)} was deprecated and - * {@link android.content.res.Resources#getColor(int,Theme)} was newly added. - * - * @see android.content.res.Resources#getColor(int) - */ - public static int getColor(Resources res, int id, @Nullable Theme theme) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return res.getColor(id, theme); - } else { - return res.getColor(id); - } - } - - /** - * Returns a color state list associated with a particular resource ID. - * - * @see #getColorStateList(android.content.res.Resources,int,Theme) - */ - public static ColorStateList getColorStateList(Resources res, int id) { - return getColorStateList(res, id, null); - } - - /** - * Returns a color state list associated with a particular resource ID. - * - * <p>In M version, {@link android.content.res.Resources#getColorStateList(int)} was deprecated - * and {@link android.content.res.Resources#getColorStateList(int,Theme)} was newly added. - * - * @see android.content.res.Resources#getColorStateList(int) - */ - public static ColorStateList getColorStateList(Resources res, int id, @Nullable Theme theme) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return res.getColorStateList(id, theme); - } else { - return res.getColorStateList(id); - } + return collection != null && (index >= 0 && index < collection.size()); } /** @@ -632,15 +716,26 @@ public class Utils { } /** + * Checks where there is any internal TV input. + */ + public static boolean hasInternalTvInputs(Context context, boolean tunerInputOnly) { + for (TvInputInfo input : TvApplication.getSingletons(context).getTvInputManagerHelper() + .getTvInputInfos(true, tunerInputOnly)) { + if (isInternalTvInput(context, input.getId())) { + return true; + } + } + return false; + } + + /** * Returns the internal TV inputs. */ public static List<TvInputInfo> getInternalTvInputs(Context context, boolean tunerInputOnly) { List<TvInputInfo> inputs = new ArrayList<>(); - String contextPackageName = context.getPackageName(); for (TvInputInfo input : TvApplication.getSingletons(context).getTvInputManagerHelper() .getTvInputInfos(true, tunerInputOnly)) { - if (contextPackageName.equals(ComponentName.unflattenFromString(input.getId()) - .getPackageName())) { + if (isInternalTvInput(context, input.getId())) { inputs.add(input); } } @@ -656,10 +751,113 @@ public class Utils { } /** - * Shows a toast message to notice that the current feature is a developer feature. + * Returns the TV input for the given {@code program}. + */ + @Nullable + public static TvInputInfo getTvInputInfoForProgram(Context context, Program program) { + if (!Program.isValid(program)) { + return null; + } + return getTvInputInfoForChannelId(context, program.getChannelId()); + } + + /** + * Returns the TV input for the given channel ID. + */ + @Nullable + public static TvInputInfo getTvInputInfoForChannelId(Context context, long channelId) { + ApplicationSingletons appSingletons = TvApplication.getSingletons(context); + Channel channel = appSingletons.getChannelDataManager().getChannel(channelId); + if (channel == null) { + return null; + } + return appSingletons.getTvInputManagerHelper().getTvInputInfo(channel.getInputId()); + } + + /** + * Returns the {@link TvInputInfo} for the given input ID. */ - public static void showToastMessageForDeveloperFeature(Context context) { - Toast.makeText(context, "This feature is for developer preview.", Toast.LENGTH_SHORT) - .show(); + @Nullable + public static TvInputInfo getTvInputInfoForInputId(Context context, String inputId) { + return TvApplication.getSingletons(context).getTvInputManagerHelper() + .getTvInputInfo(inputId); + } + + /** + * Deletes a file or a directory. + */ + public static void deleteDirOrFile(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (File child : fileOrDirectory.listFiles()) { + deleteDirOrFile(child); + } + } + fileOrDirectory.delete(); + } + + /** + * Checks whether a given package is in our bundled package set. + */ + public static boolean isInBundledPackageSet(String packageName) { + return BUNDLED_PACKAGE_SET.contains(packageName); + } + + /** + * Checks whether a given input is a bundled input. + */ + public static boolean isBundledInput(String inputId) { + for (String prefix : BUNDLED_PACKAGE_SET) { + if (inputId.startsWith(prefix + "/")) { + return true; + } + } + return false; + } + + /** + * Returns the canonical genre ID's from the {@code genres}. + */ + public static int[] getCanonicalGenreIds(String genres) { + if (TextUtils.isEmpty(genres)) { + return null; + } + return getCanonicalGenreIds(Genres.decode(genres)); + } + + /** + * Returns the canonical genre ID's from the {@code genres}. + */ + public static int[] getCanonicalGenreIds(String[] canonicalGenres) { + if (canonicalGenres != null && canonicalGenres.length > 0) { + int[] results = new int[canonicalGenres.length]; + int i = 0; + for (String canonicalGenre : canonicalGenres) { + int genreId = GenreItems.getId(canonicalGenre); + if (genreId == GenreItems.ID_ALL_CHANNELS) { + // Skip if the genre is unknown. + continue; + } + results[i++] = genreId; + } + if (i < canonicalGenres.length) { + results = Arrays.copyOf(results, i); + } + return results; + } + return null; + } + + /** + * Returns the canonical genres for database. + */ + public static String getCanonicalGenre(int[] canonicalGenreIds) { + if (canonicalGenreIds == null || canonicalGenreIds.length == 0) { + return null; + } + String[] genres = new String[canonicalGenreIds.length]; + for (int i = 0; i < canonicalGenreIds.length; ++i) { + genres[i] = GenreItems.getCanonicalGenre(canonicalGenreIds[i]); + } + return Genres.encode(genres); } } |