diff options
Diffstat (limited to 'src/com')
302 files changed, 19659 insertions, 9932 deletions
diff --git a/src/com/android/exoplayer/text/SubtitleView.java b/src/com/android/exoplayer/text/SubtitleView.java index b1161f22..37926eda 100644 --- a/src/com/android/exoplayer/text/SubtitleView.java +++ b/src/com/android/exoplayer/text/SubtitleView.java @@ -39,14 +39,12 @@ import java.util.ArrayList; /** * Since this class does not exist in recent version of ExoPlayer and used by - * {@link com.google.android.tv.tuner.cc.CaptionWindowLayout}, this class is copied from + * {@link com.android.tv.tuner.cc.CaptionWindowLayout}, this class is copied from * older version of ExoPlayer. * A view for rendering a single caption. */ @Deprecated public class SubtitleView extends View { - // TODO: Change usage of this class to up-to-date class of ExoPlayer. - /** * Ratio of inner padding to font size. */ diff --git a/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java new file mode 100644 index 00000000..2b7817dc --- /dev/null +++ b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegAudioDecoder.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2017 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.google.android.exoplayer2.ext.ffmpeg; + +import android.content.Context; +import android.content.pm.PackageManager; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import com.google.android.exoplayer2.util.MimeTypes; +import com.android.tv.common.SoftPreconditions; + +import java.nio.ByteBuffer; + +/** + * Audio decoder which uses ffmpeg extension of ExoPlayer2. Since {@link FfmpegDecoder} is package + * private, expose the decoder via this class. Supported formats are AC3 and MP2. + */ +public class FfmpegAudioDecoder { + private static final int NUM_DECODER_BUFFERS = 1; + + // The largest AC3 sample size. This is bigger than the largest MP2 sample size (1729). + private static final int INITIAL_INPUT_BUFFER_SIZE = 2560; + private static boolean AVAILABLE; + + static { + AVAILABLE = + FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_AC3) + && FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_MPEG_L2); + } + + private FfmpegDecoder mDecoder; + private DecoderInputBuffer mInputBuffer; + private SimpleOutputBuffer mOutputBuffer; + private boolean mStarted; + + /** Return whether Ffmpeg based software audio decoder is available. */ + public static boolean isAvailable() { + return AVAILABLE; + } + + /** Creates an Ffmpeg based software audio decoder. */ + public FfmpegAudioDecoder(Context context) { + if (context.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED) { + throw new IllegalStateException("This code should run in an isolated process"); + } + } + + /** + * Decodes an audio sample. + * + * @param timeUs presentation timestamp of the sample + * @param sample data + */ + public void decode(long timeUs, byte[] sample) { + SoftPreconditions.checkState(AVAILABLE); + mInputBuffer.data.clear(); + mInputBuffer.data.put(sample); + mInputBuffer.data.flip(); + mInputBuffer.timeUs = timeUs; + mDecoder.decode(mInputBuffer, mOutputBuffer, !mStarted); + if (!mStarted) { + mStarted = true; + } + } + + /** Returns a decoded sample from decoder. */ + public ByteBuffer getDecodedSample() { + return mOutputBuffer.data; + } + + /** Returns the presentation time for the decoded sample. */ + public long getDecodedTimeUs() { + return mOutputBuffer.timeUs; + } + + /** + * Clear previous decode state if any. Prepares to decode samples of the specified encoding. + * This method should be called before using decode. + * + * @param mime audio encoding + */ + public void resetDecoderState(String mime) { + SoftPreconditions.checkState(AVAILABLE); + release(); + try { + mDecoder = + new FfmpegDecoder( + NUM_DECODER_BUFFERS, + NUM_DECODER_BUFFERS, + INITIAL_INPUT_BUFFER_SIZE, + mime, + null); + mStarted = false; + mInputBuffer = mDecoder.createInputBuffer(); + // Since native JNI requires direct buffer, we should allocate it by #allocateDirect. + mInputBuffer.data = ByteBuffer.allocateDirect(INITIAL_INPUT_BUFFER_SIZE); + mOutputBuffer = mDecoder.createOutputBuffer(); + } catch (FfmpegDecoderException e) { + // if AVAILABLE is {@code true}, this will not happen. + } + } + + /** Releases all the resource. */ + public void release() { + SoftPreconditions.checkState(AVAILABLE); + if (mDecoder != null) { + mDecoder.release(); + mInputBuffer = null; + mOutputBuffer = null; + mDecoder = null; + } + } +} diff --git a/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java new file mode 100644 index 00000000..daa77340 --- /dev/null +++ b/src/com/android/exoplayer2/ext/ffmpeg/FfmpegLibrary.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2017 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.google.android.exoplayer2.ext.ffmpeg; + +import com.google.android.exoplayer2.util.LibraryLoader; +import com.google.android.exoplayer2.util.MimeTypes; + +/** + * This class is based on com.google.android.exoplayer2.ext.ffmpeg.FfmpegLibrary from ExoPlayer2 + * in order to support mp2 decoder. + * Configures and queries the underlying native library. + */ +public final class FfmpegLibrary { + + private static final LibraryLoader LOADER = + new LibraryLoader("avutil", "avresample", "avcodec", "ffmpeg"); + + private FfmpegLibrary() {} + + /** + * Overrides the names of the FFmpeg native libraries. If an application wishes to call this + * method, it must do so before calling any other method defined by this class, and before + * instantiating a {@link FfmpegAudioRenderer} instance. + */ + public static void setLibraries(String... libraries) { + LOADER.setLibraries(libraries); + } + + /** + * Returns whether the underlying library is available, loading it if necessary. + */ + public static boolean isAvailable() { + return LOADER.isAvailable(); + } + + /** + * Returns the version of the underlying library if available, or null otherwise. + */ + public static String getVersion() { + return isAvailable() ? ffmpegGetVersion() : null; + } + + /** + * Returns whether the underlying library supports the specified MIME type. + */ + public static boolean supportsFormat(String mimeType) { + if (!isAvailable()) { + return false; + } + String codecName = getCodecName(mimeType); + return codecName != null && ffmpegHasDecoder(codecName); + } + + /** + * Returns the name of the FFmpeg decoder that could be used to decode {@code mimeType}. + */ + /* package */ static String getCodecName(String mimeType) { + switch (mimeType) { + case MimeTypes.AUDIO_MPEG_L2: + return "mp2"; + case MimeTypes.AUDIO_AC3: + return "ac3"; + default: + return null; + } + } + + private static native String ffmpegGetVersion(); + private static native boolean ffmpegHasDecoder(String codecName); + +} diff --git a/src/com/android/tv/ApplicationSingletons.java b/src/com/android/tv/ApplicationSingletons.java index fd125d52..ac7d4c4a 100644 --- a/src/com/android/tv/ApplicationSingletons.java +++ b/src/com/android/tv/ApplicationSingletons.java @@ -20,12 +20,15 @@ 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.PreviewDataManager; import com.android.tv.data.ProgramDataManager; import com.android.tv.dvr.DvrDataManager; import com.android.tv.dvr.DvrManager; import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.DvrStorageStatusManager; import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.recorder.RecordingScheduler; +import com.android.tv.perf.PerformanceMonitor; import com.android.tv.util.AccountHelper; import com.android.tv.util.TvInputManagerHelper; @@ -38,6 +41,22 @@ public interface ApplicationSingletons { ChannelDataManager getChannelDataManager(); + /** + * Checks if the {@link ChannelDataManager} instance has been created and all the channels has + * been loaded. + */ + boolean isChannelDataManagerLoadFinished(); + + ProgramDataManager getProgramDataManager(); + + /** + * Checks if the {@link ProgramDataManager} instance has been created and the current programs + * for all the channels has been loaded. + */ + boolean isProgramDataManagerCurrentProgramsLoadFinished(); + + PreviewDataManager getPreviewDataManager(); + DvrDataManager getDvrDataManager(); DvrStorageStatusManager getDvrStorageStatusManager(); @@ -46,12 +65,12 @@ public interface ApplicationSingletons { DvrManager getDvrManager(); + RecordingScheduler getRecordingScheduler(); + DvrWatchedPositionManager getDvrWatchedPositionManager(); InputSessionManager getInputSessionManager(); - ProgramDataManager getProgramDataManager(); - Tracker getTracker(); TvInputManagerHelper getTvInputManagerHelper(); @@ -61,4 +80,8 @@ public interface ApplicationSingletons { AccountHelper getAccountHelper(); RemoteConfig getRemoteConfig(); + + boolean isRunningInMainProcess(); + + PerformanceMonitor getPerformanceMonitor(); } diff --git a/src/com/android/tv/AudioManagerHelper.java b/src/com/android/tv/AudioManagerHelper.java new file mode 100644 index 00000000..4fca06ac --- /dev/null +++ b/src/com/android/tv/AudioManagerHelper.java @@ -0,0 +1,108 @@ +package com.android.tv; + +import android.app.Activity; +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; + +import com.android.tv.receiver.AudioCapabilitiesReceiver; +import com.android.tv.ui.TunableTvView; + +/** + * A helper class to help {@link MainActivity} to handle audio-related stuffs. + */ +class AudioManagerHelper implements AudioManager.OnAudioFocusChangeListener { + private static final float AUDIO_MAX_VOLUME = 1.0f; + private static final float AUDIO_MIN_VOLUME = 0.0f; + private static final float AUDIO_DUCKING_VOLUME = 0.3f; + + private final Activity mActivity; + private final TunableTvView mTvView; + private final AudioManager mAudioManager; + private final AudioCapabilitiesReceiver mAudioCapabilitiesReceiver; + + private boolean mAc3PassthroughSupported; + private int mAudioFocusStatus = AudioManager.AUDIOFOCUS_LOSS; + + AudioManagerHelper(Activity activity, TunableTvView tvView) { + mActivity = activity; + mTvView = tvView; + mAudioManager = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(activity, + new AudioCapabilitiesReceiver.OnAc3PassthroughCapabilityChangeListener() { + @Override + public void onAc3PassthroughCapabilityChange(boolean capability) { + mAc3PassthroughSupported = capability; + } + }); + mAudioCapabilitiesReceiver.register(); + } + + /** + * Sets suitable volume to {@link TunableTvView} according to the current audio focus. + * If the focus status is {@link AudioManager#AUDIOFOCUS_LOSS} and the activity is under PIP + * mode, this method will finish the activity. + */ + void setVolumeByAudioFocusStatus() { + if (mTvView.isPlaying()) { + switch (mAudioFocusStatus) { + case AudioManager.AUDIOFOCUS_GAIN: + mTvView.setStreamVolume(AUDIO_MAX_VOLUME); + break; + case AudioManager.AUDIOFOCUS_LOSS: + if (Features.PICTURE_IN_PICTURE.isEnabled(mActivity) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && mActivity.isInPictureInPictureMode()) { + mActivity.finish(); + break; + } + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + mTvView.setStreamVolume(AUDIO_MIN_VOLUME); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + mTvView.setStreamVolume(AUDIO_DUCKING_VOLUME); + break; + } + } + } + + /** + * Tries to request audio focus from {@link AudioManager} and set volume according to the + * returned result. + */ + void requestAudioFocus() { + int result = mAudioManager.requestAudioFocus(this, + AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + mAudioFocusStatus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) ? + AudioManager.AUDIOFOCUS_GAIN : AudioManager.AUDIOFOCUS_LOSS; + setVolumeByAudioFocusStatus(); + } + + /** + * Abandons audio focus. + */ + void abandonAudioFocus() { + mAudioFocusStatus = AudioManager.AUDIOFOCUS_LOSS; + mAudioManager.abandonAudioFocus(this); + } + + /** + * Returns {@code true} if the device supports AC3 pass-through. + */ + boolean isAc3PassthroughSupported() { + return mAc3PassthroughSupported; + } + + /** + * Release the resources the helper class may occupied. + */ + void release() { + mAudioCapabilitiesReceiver.unregister(); + } + + @Override + public void onAudioFocusChange(int focusChange) { + mAudioFocusStatus = focusChange; + setVolumeByAudioFocusStatus(); + } +} diff --git a/src/com/android/tv/Features.java b/src/com/android/tv/Features.java index 7e8e3689..2052f2e7 100644 --- a/src/com/android/tv/Features.java +++ b/src/com/android/tv/Features.java @@ -16,22 +16,29 @@ 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 android.text.TextUtils; +import android.util.Log; import com.android.tv.common.feature.Feature; import com.android.tv.common.feature.GServiceFeature; import com.android.tv.common.feature.PropertyFeature; +import com.android.tv.config.RemoteConfig; +import com.android.tv.experiments.Experiments; +import com.android.tv.util.LocationUtils; import com.android.tv.util.PermissionUtils; +import com.android.tv.util.Utils; + +import java.util.Locale; + +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; /** * List of {@link Feature} for the Live TV App. @@ -39,6 +46,9 @@ import com.android.tv.util.PermissionUtils; * <p>Remove the {@code Feature} once it is launched. */ public final class Features { + private static final String TAG = "Features"; + private static final boolean DEBUG = false; + /** * UI for opting in to analytics. * @@ -57,17 +67,40 @@ public final class Features { public static final Feature EPG_SEARCH = new PropertyFeature("feature_tv_use_epg_search", false); - public static final Feature TUNER = new Feature() { - @Override - public boolean isEnabled(Context context) { + 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(); - } + if (Utils.isDeveloper()) { + // we enable tuner for developers to test tuner in any platform. + return true; + } - }; + // 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 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + }; + + /** + * Use network tuner if it is available and there is no other tuner types. + */ + public static final Feature NETWORK_TUNER = + new Feature() { + @Override + public boolean isEnabled(Context context) { + if (!TUNER.isEnabled(context)) { + return false; + } + if (Utils.isDeveloper()) { + // Network tuner will be enabled for developers. + return true; + } + return Locale.US.getCountry().equalsIgnoreCase( + LocationUtils.getCurrentCountry(context)); + } + }; private static final String GSERVICE_KEY_UNHIDE = "live_channels_unhide"; /** @@ -82,22 +115,75 @@ public final class Features { } }); - public static final Feature PICTURE_IN_PICTURE = new Feature() { - private Boolean mEnabled; + public static final Feature PICTURE_IN_PICTURE = + new Feature() { + private Boolean mEnabled; - @Override - public boolean isEnabled(Context context) { - if (mEnabled == null) { - mEnabled = context.getPackageManager().hasSystemFeature( - PackageManager.FEATURE_PICTURE_IN_PICTURE); - } - return mEnabled; - } - }; + @Override + public boolean isEnabled(Context context) { + if (mEnabled == null) { + mEnabled = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && context.getPackageManager() + .hasSystemFeature( + PackageManager.FEATURE_PICTURE_IN_PICTURE); + } + return mEnabled; + } + }; - /** - * Enable a conflict dialog between currently watched channel and upcoming recording. - */ + /** Use AC3 software decode. */ + public static final Feature AC3_SOFTWARE_DECODE = + new Feature() { + private final String[] SUPPORTED_REGIONS = {}; + + private Boolean mEnabled; + + @Override + public boolean isEnabled(Context context) { + if (mEnabled == null) { + if (mEnabled == null) { + // We will not cache the result of fallback solution. + String country = LocationUtils.getCurrentCountry(context); + for (int i = 0; i < SUPPORTED_REGIONS.length; ++i) { + if (SUPPORTED_REGIONS[i].equalsIgnoreCase(country)) { + return true; + } + } + if (DEBUG) Log.d(TAG, "AC3 flag false after country check"); + return false; + } + } + if (DEBUG) Log.d(TAG, "AC3 flag " + mEnabled); + return mEnabled; + } + }; + + /** Show postal code fragment before channel scan. */ + public static final Feature ENABLE_CLOUD_EPG_REGION = + new Feature() { + private final String[] SUPPORTED_REGIONS = { + }; + + + @Override + public boolean isEnabled(Context context) { + if (!Experiments.CLOUD_EPG.get()) { + if (DEBUG) Log.d(TAG, "Experiments.CLOUD_EPG is false"); + return false; + } + String country = LocationUtils.getCurrentCountry(context); + for (int i = 0; i < SUPPORTED_REGIONS.length; i++) { + if (SUPPORTED_REGIONS[i].equalsIgnoreCase(country)) { + return true; + } + } + if (DEBUG) Log.d(TAG, "EPG flag false after country check"); + return false; + } + }; + + /** Enable a conflict dialog between currently watched channel and upcoming recording. */ public static final Feature SHOW_UPCOMING_CONFLICT_DIALOG = OFF; /** @@ -105,6 +191,11 @@ public final class Features { */ public static final Feature USE_PARTNER_INPUT_BLACKLIST = ON; + /** + * Enable Dvb parsers and listeners. + */ + public static final Feature ENABLE_FILE_DVB = OFF; + @VisibleForTesting public static final Feature TEST_FEATURE = new PropertyFeature("test_feature", false); diff --git a/src/com/android/tv/InputSessionManager.java b/src/com/android/tv/InputSessionManager.java index e4b0f456..2978f409 100644 --- a/src/com/android/tv/InputSessionManager.java +++ b/src/com/android/tv/InputSessionManager.java @@ -37,7 +37,6 @@ 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; @@ -73,6 +72,8 @@ public class InputSessionManager { Collections.synchronizedSet(new ArraySet<>()); private final Set<OnTvViewChannelChangeListener> mOnTvViewChannelChangeListeners = new ArraySet<>(); + private final Set<OnRecordingSessionChangeListener> mOnRecordingSessionChangeListeners = + new ArraySet<>(); public InputSessionManager(Context context) { mContext = context.getApplicationContext(); @@ -113,6 +114,9 @@ public class InputSessionManager { RecordingSession session = new RecordingSession(inputId, tag, callback, handler, endTimeMs); mRecordingSessions.add(session); if (DEBUG) Log.d(TAG, "Recording session created: " + session); + for (OnRecordingSessionChangeListener listener : mOnRecordingSessionChangeListeners) { + listener.onRecordingSessionChange(true, mRecordingSessions.size()); + } return session; } @@ -123,6 +127,9 @@ public class InputSessionManager { mRecordingSessions.remove(session); session.release(); if (DEBUG) Log.d(TAG, "Recording session released: " + session); + for (OnRecordingSessionChangeListener listener : mOnRecordingSessionChangeListeners) { + listener.onRecordingSessionChange(false, mRecordingSessions.size()); + } } /** @@ -148,9 +155,17 @@ public class InputSessionManager { } } - /** - * Returns the current {@link TvView} channel. - */ + /** Adds the {@link OnRecordingSessionChangeListener}. */ + public void addOnRecordingSessionChangeListener(OnRecordingSessionChangeListener listener) { + mOnRecordingSessionChangeListeners.add(listener); + } + + /** Removes the {@link OnRecordingSessionChangeListener}. */ + public void removeRecordingSessionChangeListener(OnRecordingSessionChangeListener listener) { + mOnRecordingSessionChangeListeners.remove(listener); + } + + /** Returns the current {@link TvView} channel. */ @MainThread public Uri getCurrentTvViewChannelUri() { for (TvViewSession session : mTvViewSessions) { @@ -249,7 +264,7 @@ public class InputSessionManager { mTvView.setCallback(new DelegateTvInputCallback(mCallback) { @Override public void onConnectionFailed(String inputId) { - if (DEBUG) Log.d(TAG, "TvViewSession: commection failed"); + if (DEBUG) Log.d(TAG, "TvViewSession: connection failed"); mTuned = false; mNeedToBeRetuned = false; super.onConnectionFailed(inputId); @@ -546,4 +561,9 @@ public class InputSessionManager { public interface OnTvViewChannelChangeListener { void onTvViewChannelChange(@Nullable Uri channelUri); } + + /** Called when recording session is created or destroyed. */ + public interface OnRecordingSessionChangeListener { + void onRecordingSessionChange(boolean create, int count); + } } diff --git a/src/com/android/tv/MainActivity.java b/src/com/android/tv/MainActivity.java index 58850b5f..ed5f79a1 100644 --- a/src/com/android/tv/MainActivity.java +++ b/src/com/android/tv/MainActivity.java @@ -27,14 +27,7 @@ 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.Point; import android.hardware.display.DisplayManager; -import android.media.AudioManager; -import android.media.MediaMetadata; -import android.media.session.MediaSession; -import android.media.session.PlaybackState; import android.media.tv.TvContentRating; import android.media.tv.TvContract; import android.media.tv.TvContract.Channels; @@ -44,7 +37,6 @@ 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; @@ -71,7 +63,6 @@ import android.view.accessibility.AccessibilityManager; import android.widget.FrameLayout; import android.widget.Toast; -import com.android.tv.analytics.DurationTimer; import com.android.tv.analytics.SendChannelStatusRunnable; import com.android.tv.analytics.SendConfigInfoRunnable; import com.android.tv.analytics.Tracker; @@ -91,26 +82,30 @@ 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.HalfSizedDialogFragment; import com.android.tv.dialog.PinDialogFragment; +import com.android.tv.dialog.PinDialogFragment.OnPinCheckedListener; import com.android.tv.dialog.SafeDismissDialogFragment; -import com.android.tv.dvr.ConflictChecker; import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.DvrUiHelper; -import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.recorder.ConflictChecker; import com.android.tv.dvr.ui.DvrStopRecordingFragment; -import com.android.tv.dvr.ui.HalfSizedDialogFragment; -import com.android.tv.experiments.Experiments; +import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.menu.Menu; import com.android.tv.onboarding.OnboardingActivity; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; -import com.android.tv.receiver.AudioCapabilitiesReceiver; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.StubPerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.recommendation.ChannelPreviewUpdater; import com.android.tv.recommendation.NotificationService; import com.android.tv.search.ProgramGuideSearchFragment; +import com.android.tv.tuner.TunerInputController; 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; @@ -128,23 +123,22 @@ 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.ui.sidepanel.parentalcontrols.ParentalControlsFragment; import com.android.tv.util.AccountHelper; import com.android.tv.util.CaptionSettings; +import com.android.tv.util.Debug; +import com.android.tv.util.DurationTimer; import com.android.tv.util.ImageCache; -import com.android.tv.util.ImageLoader; import com.android.tv.util.OnboardingUtils; import com.android.tv.util.PermissionUtils; -import com.android.tv.util.PipInputManager; -import com.android.tv.util.PipInputManager.PipInput; import com.android.tv.util.RecurringRunner; -import com.android.tv.util.SearchManagerHelper; import com.android.tv.util.SetupUtils; 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.tv.util.TvTrackInfoUtils; import com.android.tv.util.Utils; +import com.android.tv.util.ViewCache; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -159,8 +153,7 @@ import java.util.concurrent.TimeUnit; /** * The main activity for the Live TV app. */ -public class MainActivity extends Activity implements AudioManager.OnAudioFocusChangeListener, - OnActionClickListener { +public class MainActivity extends Activity implements OnActionClickListener, OnPinCheckedListener { private static final String TAG = "MainActivity"; private static final boolean DEBUG = false; @@ -175,18 +168,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private static final boolean USE_BACK_KEY_LONG_PRESS = false; - private static final float AUDIO_MAX_VOLUME = 1.0f; - private static final float AUDIO_MIN_VOLUME = 0.0f; - private static final float AUDIO_DUCKING_VOLUME = 0.3f; private static final float FRAME_RATE_FOR_FILM = 23.976f; private static final float FRAME_RATE_EPSILON = 0.1f; - private static final float MEDIA_SESSION_STOPPED_SPEED = 0.0f; - private static final float MEDIA_SESSION_PLAYING_SPEED = 1.0f; - 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"; // Tracker screen names. @@ -211,49 +197,26 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } + private static final IntentFilter SYSTEM_INTENT_FILTER = new IntentFilter(); + static { + SYSTEM_INTENT_FILTER.addAction(TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED); + SYSTEM_INTENT_FILTER.addAction(Intent.ACTION_SCREEN_OFF); + SYSTEM_INTENT_FILTER.addAction(Intent.ACTION_SCREEN_ON); + SYSTEM_INTENT_FILTER.addAction(Intent.ACTION_TIME_CHANGED); + } + private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1; - private static final int REQUEST_CODE_START_SYSTEM_CAPTIONING_SETTINGS = 2; private static final String KEY_INIT_CHANNEL_ID = "com.android.tv.init_channel_id"; - private static final String MEDIA_SESSION_TAG = "com.android.tv.mediasession"; - // Change channels with key long press. private static final int CHANNEL_CHANGE_NORMAL_SPEED_DURATION_MS = 3000; private static final int CHANNEL_CHANGE_DELAY_MS_IN_MAX_SPEED = 50; private static final int CHANNEL_CHANGE_DELAY_MS_IN_NORMAL_SPEED = 200; private static final int CHANNEL_CHANGE_INITIAL_DELAY_MILLIS = 500; - private static final int FIRST_STREAM_INFO_UPDATE_DELAY_MILLIS = 500; private static final int MSG_CHANNEL_DOWN_PRESSED = 1000; private static final int MSG_CHANNEL_UP_PRESSED = 1001; - private static final int MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE = 1002; - - @Retention(RetentionPolicy.SOURCE) - @IntDef({UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW, UPDATE_CHANNEL_BANNER_REASON_TUNE, - 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; @@ -261,12 +224,14 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // Delay 1 second in order not to interrupt the first tune. private static final long LAZY_INITIALIZATION_DELAY = TimeUnit.SECONDS.toMillis(1); + private static final int UNDEFINED_TRACK_INDEX = -1; + private static final long START_UP_TIMER_RESET_THRESHOLD_MS = TimeUnit.SECONDS.toMillis(3); + private AccessibilityManager mAccessibilityManager; private ChannelDataManager mChannelDataManager; private ProgramDataManager mProgramDataManager; private TvInputManagerHelper mTvInputManagerHelper; private ChannelTuner mChannelTuner; - private PipInputManager mPipInputManager; private final TvOptionsManager mTvOptionsManager = new TvOptionsManager(this); private TvViewUiManager mTvViewUiManager; private TimeShiftManager mTimeShiftManager; @@ -278,12 +243,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private View mContentView; private TunableTvView mTvView; - private TunableTvView mPipView; private Bundle mTuneParams; - private boolean mChannelBannerHiddenBySideFragment; - // TODO: Move the scene views into TvTransitionManager or TvOverlayManager. - private ChannelBannerView mChannelBannerView; - private KeypadChannelSwitchView mKeypadChannelSwitchView; @Nullable private Uri mInitChannelUri; @Nullable @@ -293,20 +253,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private boolean mShowSelectInputView; private TvInputInfo mInputToSetUp; private final List<MemoryManageable> mMemoryManageables = new ArrayList<>(); - private MediaSession mMediaSession; - private int mNowPlayingCardWidth; - private int mNowPlayingCardHeight; + private MediaSessionWrapper mMediaSessionWrapper; private final MyOnTuneListener mOnTuneListener = new MyOnTuneListener(); private String mInputIdUnderSetup; private boolean mIsSetupActivityCalledByPopup; - private AudioManager mAudioManager; - private int mAudioFocusStatus; + private AudioManagerHelper mAudioManagerHelper; private boolean mTunePending; - private boolean mPipEnabled; - private Channel mPipChannel; - private boolean mPipSwap; - @PipSound private int mPipSound = TvSettings.PIP_SOUND_MAIN; // Default private boolean mDebugNonFullSizeScreen; private boolean mActivityResumed; private boolean mActivityStarted; @@ -316,10 +269,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private boolean mBackKeyPressed; private boolean mNeedShowBackKeyGuide; private boolean mVisibleBehind; - private boolean mAc3PassthroughSupported; private boolean mShowNewSourcesFragment = true; private String mTunerInputId; private boolean mOtherActivityLaunched; + private PerformanceMonitor mPerformanceMonitor; private boolean mIsFilmModeSet; private float mDefaultRefreshRate; @@ -331,11 +284,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private boolean mIsCurrentChannelUnblockedByUser; private boolean mWasChannelUnblockedBeforeShrunkenByUser; private Channel mChannelBeforeShrunkenTvView; - private Channel mPipChannelBeforeShrunkenTvView; private boolean mIsCompletingShrunkenTvView; - // TODO: Need to consider the case that TIS explicitly request PIN code while TV view is - // shrunken. private TvContentRating mLastAllowedRatingForCurrentChannel; private TvContentRating mAllowedRatingBeforeShrunken; @@ -346,42 +296,46 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private static final int MAX_RECENT_CHANNELS = 5; private final ArrayDeque<Long> mRecentChannels = new ArrayDeque<>(MAX_RECENT_CHANNELS); - private AudioCapabilitiesReceiver mAudioCapabilitiesReceiver; private RecurringRunner mSendConfigInfoRecurringRunner; private RecurringRunner mChannelStatusRecurringRunner; - // A caller which started this activity. (e.g. TvSearch) - private String mSource; - private final Handler mHandler = new MainActivityHandler(this); private final Set<OnActionClickListener> mOnActionClickListeners = new ArraySet<>(); private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF)) { - if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_OFF"); - // We need to stop TvView, when the screen is turned off. If not and TIS uses - // MediaPlayer, a device may not go to the sleep mode and audio can be heard, - // because MediaPlayer keeps playing media by its wake lock. - mScreenOffIntentReceived = true; - markCurrentChannelDuringScreenOff(); - stopAll(true); - } else if (intent.getAction().equals(Intent.ACTION_SCREEN_ON)) { - if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_ON"); - if (!mActivityResumed && mVisibleBehind) { - // ACTION_SCREEN_ON is usually called after onResume. But, if media is played - // under launcher with requestVisibleBehind(true), onResume will not be called. - // In this case, we need to resume TvView and PipView explicitly. - resumeTvIfNeeded(); - resumePipIfNeeded(); - } - } else if (intent.getAction().equals( - TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED)) { - if (DEBUG) Log.d(TAG, "Received parental control settings change"); - checkChannelLockNeeded(mTvView); - checkChannelLockNeeded(mPipView); - applyParentalControlSettings(); + switch (intent.getAction()) { + case Intent.ACTION_SCREEN_OFF: + if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_OFF"); + // We need to stop TvView, when the screen is turned off. If not and TIS uses + // MediaPlayer, a device may not go to the sleep mode and audio can be heard, + // because MediaPlayer keeps playing media by its wake lock. + mScreenOffIntentReceived = true; + markCurrentChannelDuringScreenOff(); + stopAll(true); + break; + case Intent.ACTION_SCREEN_ON: + if (DEBUG) Log.d(TAG, "Received ACTION_SCREEN_ON"); + if (!mActivityResumed && mVisibleBehind) { + // ACTION_SCREEN_ON is usually called after onResume. But, if media is + // played under launcher with requestVisibleBehind(true), onResume will + // not be called. In this case, we need to resume TvView explicitly. + resumeTvIfNeeded(); + } + break; + case TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED: + if (DEBUG) Log.d(TAG, "Received parental control settings change"); + applyParentalControlSettings(); + checkChannelLockNeeded(mTvView, null); + break; + case Intent.ACTION_TIME_CHANGED: + // Re-tune the current channel to prevent incorrect behavior of trick-play. + // See: b/37393628 + if (mChannelTuner.getCurrentChannel() != null) { + tune(true); + } + break; } } }; @@ -397,8 +351,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } Channel channel = mTvView.getCurrentChannel(); if (channel != null && channel.getId() == channelId) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); - updateMediaSession(); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); + mMediaSessionWrapper.update(mTvView.isBlocked(), channel, program); } } }; @@ -407,36 +362,39 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC new ChannelTuner.Listener() { @Override public void onLoadFinished() { + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "MainActivity.mChannelTunerListener.onLoadFinished"); SetupUtils.getInstance(MainActivity.this).markNewChannelsBrowsable(); if (mActivityResumed) { resumeTvIfNeeded(); - resumePipIfNeeded(); } - mKeypadChannelSwitchView.setChannels(mChannelTuner.getBrowsableChannelList()); + mOverlayManager.onBrowsableChannelsUpdated(); } @Override public void onBrowsableChannelListChanged() { - mKeypadChannelSwitchView.setChannels(mChannelTuner.getBrowsableChannelList()); + mOverlayManager.onBrowsableChannelsUpdated(); } @Override public void onCurrentChannelUnavailable(Channel channel) { - // TODO: handle the case that a channel is suddenly removed from DB. + if (mChannelTuner.moveToAdjacentBrowsableChannel(true)) { + tune(true); + } else { + stopTv("onCurrentChannelUnavailable()", false); + } } @Override - public void onChannelChanged(Channel previousChannel, Channel currentChannel) { - } + public void onChannelChanged(Channel previousChannel, Channel currentChannel) {} }; - private final Runnable mRestoreMainViewRunnable = - new Runnable() { - @Override - public void run() { - restoreMainTvView(); - } - }; + private final Runnable mRestoreMainViewRunnable = new Runnable() { + @Override + public void run() { + restoreMainTvView(); + } + }; private ProgramGuideSearchFragment mSearchFragment; private final TvInputCallback mTvInputCallback = new TvInputCallback() { @@ -456,54 +414,54 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC boolean parentalControlEnabled = mTvInputManagerHelper.getParentalControlSettings() .isParentalControlsEnabled(); mTvView.onParentalControlChanged(parentalControlEnabled); - mPipView.onParentalControlChanged(parentalControlEnabled); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ChannelPreviewUpdater.getInstance(this).updatePreviewDataForChannelsImmediately(); + } } @Override protected void onCreate(Bundle savedInstanceState) { + TimerEvent timer = StubPerformanceMonitor.startBootstrapTimer(); + DurationTimer startUpDebugTimer = Debug.getTimer(Debug.TAG_START_UP_TIMER); + if (!startUpDebugTimer.isStarted() + || startUpDebugTimer.getDuration() > START_UP_TIMER_RESET_THRESHOLD_MS) { + // TvApplication can start by other reason before MainActivty is launched. + // In this case, we restart the timer. + startUpDebugTimer.start(); + } + startUpDebugTimer.log("MainActivity.onCreate"); if (DEBUG) Log.d(TAG,"onCreate()"); TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(this); + if (!applicationSingletons.getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + finishAndRemoveTask(); + return; + } + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); - boolean isPassthroughInput = TvContract.isChannelUriForPassthroughInput(getIntent() - .getData()); - boolean skipToShowOnboarding = Intent.ACTION_VIEW.equals(getIntent().getAction()) + TvApplication tvApplication = (TvApplication) getApplication(); + mChannelDataManager = tvApplication.getChannelDataManager(); + // In API 23, TvContract.isChannelUriForPassthroughInput is hidden. + boolean isPassthroughInput = + TvContract.isChannelUriForPassthroughInput(getIntent().getData()); + boolean tuneToPassthroughInput = Intent.ACTION_VIEW.equals(getIntent().getAction()) && isPassthroughInput; - if (OnboardingUtils.needToShowOnboarding(this) && !skipToShowOnboarding + boolean channelLoadedAndNoChannelAvailable = mChannelDataManager.isDbLoadFinished() + && mChannelDataManager.getChannelCount() <= 0; + if ((OnboardingUtils.isFirstRunWithCurrentVersion(this) + || channelLoadedAndNoChannelAvailable) + && !tuneToPassthroughInput && !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. - startActivity(OnboardingActivity.buildIntent(this, getIntent())); - finish(); + startOnboardingActivity(); return; } - - // 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); - Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); - Point size = new Point(); - display.getSize(size); - int screenWidth = size.x; - int screenHeight = size.y; - mDefaultRefreshRate = display.getRefreshRate(); - setContentView(R.layout.activity_tv); - mContentView = findViewById(android.R.id.content); + mProgramDataManager = tvApplication.getProgramDataManager(); + mTvInputManagerHelper = tvApplication.getTvInputManagerHelper(); mTvView = (TunableTvView) findViewById(R.id.main_tunable_tv_view); - int shrunkenTvViewHeight = getResources().getDimensionPixelSize( - R.dimen.shrunken_tvview_height); - mTvView.initialize((AppLayerTvView) findViewById(R.id.main_tv_view), false, screenHeight, - shrunkenTvViewHeight); + mTvView.initialize(mProgramDataManager, mTvInputManagerHelper); mTvView.setOnUnhandledInputEventListener(new OnUnhandledInputEventListener() { @Override public boolean onUnhandledInputEvent(InputEvent event) { @@ -526,7 +484,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return false; } }); - long channelId = Utils.getLastWatchedChannelId(this); String inputId = Utils.getLastWatchedTunerInputId(this); if (!isPassthroughInput && inputId != null @@ -534,27 +491,21 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC 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()); @@ -565,28 +516,29 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC new OnCurrentProgramUpdatedListener() { @Override public void onCurrentProgramUpdated(long channelId, Program program) { - updateMediaSession(); + mMediaSessionWrapper.update(mTvView.isBlocked(), getCurrentChannel(), + program); switch (mTimeShiftManager.getLastActionId()) { case TimeShiftManager.TIME_SHIFT_ACTION_ID_REWIND: case TimeShiftManager.TIME_SHIFT_ACTION_ID_FAST_FORWARD: case TimeShiftManager.TIME_SHIFT_ACTION_ID_JUMP_TO_PREVIOUS: case TimeShiftManager.TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT: - updateChannelBannerAndShowIfNeeded( - UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.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); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); break; } } }); - mPipView = (TunableTvView) findViewById(R.id.pip_tunable_tv_view); - mPipView.initialize((AppLayerTvView) findViewById(R.id.pip_tv_view), true, screenHeight, - shrunkenTvViewHeight); + DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + mDefaultRefreshRate = display.getRefreshRate(); if (!PermissionUtils.hasAccessWatchedHistory(this)) { WatchedHistoryManager watchedHistoryManager = new WatchedHistoryManager( @@ -594,17 +546,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC watchedHistoryManager.start(); mTvView.setWatchedHistoryManager(watchedHistoryManager); } - mTvViewUiManager = new TvViewUiManager(this, mTvView, mPipView, + mTvViewUiManager = new TvViewUiManager(this, mTvView, (FrameLayout) findViewById(android.R.id.content), mTvOptionsManager); - mPipView.setFixedSurfaceSize(screenWidth / 2, screenHeight / 2); - mPipView.setBlockScreenType(TunableTvView.BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW); - + mContentView = findViewById(android.R.id.content); ViewGroup sceneContainer = (ViewGroup) findViewById(R.id.scene_container); - mChannelBannerView = (ChannelBannerView) getLayoutInflater().inflate( + ChannelBannerView channelBannerView = (ChannelBannerView) getLayoutInflater().inflate( R.layout.channel_banner, sceneContainer, false); - mKeypadChannelSwitchView = (KeypadChannelSwitchView) getLayoutInflater().inflate( - R.layout.keypad_channel_switch, sceneContainer, false); + KeypadChannelSwitchView keypadChannelSwitchView = (KeypadChannelSwitchView) + getLayoutInflater().inflate(R.layout.keypad_channel_switch, sceneContainer, false); InputBannerView inputBannerView = (InputBannerView) getLayoutInflater() .inflate(R.layout.input_banner, sceneContainer, false); SelectInputView selectInputView = (SelectInputView) getLayoutInflater() @@ -641,26 +591,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } }); mSearchFragment = new ProgramGuideSearchFragment(); - mOverlayManager = new TvOverlayManager(this, mChannelTuner, mTvView, - mKeypadChannelSwitchView, mChannelBannerView, inputBannerView, + mOverlayManager = new TvOverlayManager(this, mChannelTuner, mTvView, mTvOptionsManager, + keypadChannelSwitchView, channelBannerView, inputBannerView, selectInputView, sceneContainer, mSearchFragment); - mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); - mAudioFocusStatus = AudioManager.AUDIOFOCUS_LOSS; - - mMediaSession = new MediaSession(this, MEDIA_SESSION_TAG); - mMediaSession.setCallback(new MediaSession.Callback() { - @Override - public boolean onMediaButtonEvent(@NonNull Intent mediaButtonIntent) { - // Consume the media button event here. Should not send it to other apps. - return true; - } - }); - mMediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS | - MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); - mNowPlayingCardWidth = getResources().getDimensionPixelSize( - R.dimen.notif_card_img_max_width); - mNowPlayingCardHeight = getResources().getDimensionPixelSize(R.dimen.notif_card_img_height); + mAudioManagerHelper = new AudioManagerHelper(this, mTvView); + mMediaSessionWrapper = new MediaSessionWrapper(this); mTvViewUiManager.restoreDisplayMode(false); if (!handleIntent(getIntent())) { @@ -668,15 +604,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return; } - mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(this, - new AudioCapabilitiesReceiver.OnAc3PassthroughCapabilityChangeListener() { - @Override - public void onAc3PassthroughCapabilityChange(boolean capability) { - mAc3PassthroughSupported = capability; - } - }); - mAudioCapabilitiesReceiver.register(); - mAccessibilityManager = (AccessibilityManager) getSystemService(Context.ACCESSIBILITY_SERVICE); mSendConfigInfoRecurringRunner = new RecurringRunner(this, TimeUnit.DAYS.toMillis(1), @@ -692,6 +619,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mDvrConflictChecker = new ConflictChecker(this); } initForTest(); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity.onCreate end"); + mPerformanceMonitor.stopTimer(timer, EventNames.MAIN_ACTIVITY_ONCREATE); + } + + private void startOnboardingActivity() { + startActivity(OnboardingActivity.buildIntent(this, getIntent())); + finish(); } @Override @@ -705,32 +639,21 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override 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; + if (requestCode == 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(); + } } } @@ -781,6 +704,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override protected void onStart() { + TimerEvent timer = mPerformanceMonitor.startTimer(); if (DEBUG) Log.d(TAG,"onStart()"); super.onStart(); mScreenOffIntentReceived = false; @@ -789,23 +713,25 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mMainDurationTimer.start(); applyParentalControlSettings(); - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(TvInputManager.ACTION_PARENTAL_CONTROLS_ENABLED_CHANGED); - intentFilter.addAction(Intent.ACTION_SCREEN_OFF); - intentFilter.addAction(Intent.ACTION_SCREEN_ON); - registerReceiver(mBroadcastReceiver, intentFilter); + registerReceiver(mBroadcastReceiver, SYSTEM_INTENT_FILTER); - Intent notificationIntent = new Intent(this, NotificationService.class); - notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION); - startService(notificationIntent); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Intent notificationIntent = new Intent(this, NotificationService.class); + notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION); + startService(notificationIntent); + } + TunerInputController.executeNetworkTunerDiscoveryAsyncTask(this); + + EpgFetcher.getInstance(this).fetchImmediatelyIfNeeded(); + mPerformanceMonitor.stopTimer(timer, EventNames.MAIN_ACTIVITY_ONSTART); } @Override protected void onResume() { + TimerEvent timer = mPerformanceMonitor.startTimer(); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity.onResume start"); 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) { @@ -819,23 +745,23 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mActivityResumed = true; mShowNewSourcesFragment = true; mOtherActivityLaunched = false; - int result = mAudioManager.requestAudioFocus(MainActivity.this, - AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); - mAudioFocusStatus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) ? - AudioManager.AUDIOFOCUS_GAIN : AudioManager.AUDIOFOCUS_LOSS; - setVolumeByAudioFocusStatus(); + mAudioManagerHelper.requestAudioFocus(); if (mTvView.isPlaying()) { // Every time onResume() is called the activity will be assumed to not have requested // visible behind. requestVisibleBehind(true); } - if (Utils.hasRecordingFailedReason(getApplicationContext(), - TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE)) { + Set<String> failedScheduledRecordingInfoSet = + Utils.getFailedScheduledRecordingInfoSet(getApplicationContext()); + if (Utils.hasRecordingFailedReason( + getApplicationContext(), TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE) + && !failedScheduledRecordingInfoSet.isEmpty()) { runAfterAttachedToWindow(new Runnable() { @Override public void run() { - DvrUiHelper.showDvrInsufficientSpaceErrorDialog(MainActivity.this); + DvrUiHelper.showDvrInsufficientSpaceErrorDialog(MainActivity.this, + failedScheduledRecordingInfoSet); } }); } @@ -843,13 +769,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (mChannelTuner.areAllChannelsLoaded()) { SetupUtils.getInstance(this).markNewChannelsBrowsable(); resumeTvIfNeeded(); - resumePipIfNeeded(); } mOverlayManager.showMenuWithTimeShiftPauseIfNeeded(); - // Note: The following codes are related to pop up an overlay UI after resume. - // When the following code is changed, please check the variable - // willShowOverlayUiAfterResume in updateChannelBannerAndShowIfNeeded. + // NOTE: The following codes are related to pop up an overlay UI after resume. When + // the following code is changed, please modify willShowOverlayUiWhenResume() accordingly. if (mInputToSetUp != null) { startSetupActivity(mInputToSetUp, false); mInputToSetUp = null; @@ -881,6 +805,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (mDvrConflictChecker != null) { mDvrConflictChecker.start(); } + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity.onResume end"); + mPerformanceMonitor.stopTimer(timer, EventNames.MAIN_ACTIVITY_ONRESUME); } @Override @@ -893,18 +819,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mActivityResumed = false; mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_DEFAULT); mTvView.setBlockScreenType(TunableTvView.BLOCK_SCREEN_TYPE_NO_UI); - if (mPipEnabled) { - mTvViewUiManager.hidePipForPause(); - } mBackKeyPressed = false; mShowLockedChannelsTemporarily = false; mShouldTuneToTunerChannel = false; if (!mVisibleBehind) { - mAudioFocusStatus = AudioManager.AUDIOFOCUS_LOSS; - mAudioManager.abandonAudioFocus(this); - if (mMediaSession.isActive()) { - mMediaSession.setActive(false); - } + mAudioManagerHelper.abandonAudioFocus(); + mMediaSessionWrapper.setPlaybackState(false); mTracker.sendScreenView(""); } else { mTracker.sendScreenView(SCREEN_BEHIND_NAME); @@ -933,6 +853,32 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return state; } + @Override + public void onPinChecked(boolean checked, int type, String rating) { + if (checked) { + switch (type) { + case PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_CHANNEL: + blockOrUnblockScreen(mTvView, false); + mIsCurrentChannelUnblockedByUser = true; + break; + case PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM: + TvContentRating unblockedRating = TvContentRating.unflattenFromString(rating); + mLastAllowedRatingForCurrentChannel = unblockedRating; + mTvView.unblockContent(unblockedRating); + break; + case PinDialogFragment.PIN_DIALOG_TYPE_ENTER_PIN: + mOverlayManager.getSideFragmentManager() + .show(new ParentalControlsFragment(), false); + // Pass through. + case PinDialogFragment.PIN_DIALOG_TYPE_NEW_PIN: + mOverlayManager.getSideFragmentManager().showSidePanel(true); + break; + } + } else if (type == PinDialogFragment.PIN_DIALOG_TYPE_ENTER_PIN) { + mOverlayManager.getSideFragmentManager().hideAll(false); + } + } + private void resumeTvIfNeeded() { if (DEBUG) Log.d(TAG, "resumeTvIfNeeded()"); if (!mTvView.isPlaying() || mInitChannelUri != null @@ -962,21 +908,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mTvView.setBlockScreenType(getDesiredBlockScreenType()); } - private void resumePipIfNeeded() { - if (mPipEnabled && !(mPipView.isPlaying() && mPipView.isShown())) { - if (mPipInputManager.areInSamePipInput( - mChannelTuner.getCurrentChannel(), mPipChannel)) { - enablePipView(false, false); - } else { - if (!mPipView.isPlaying()) { - startPip(false); - } else { - mTvViewUiManager.showPipForResume(); - } - } - } - } - private void startTv(Uri channelUri) { if (DEBUG) Log.d(TAG, "startTv Uri=" + channelUri); if ((channelUri == null || !TvContract.isChannelUriForPassthroughInput(channelUri)) @@ -993,7 +924,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // TV has already started. if (channelUri == null || channelUri.equals(mChannelTuner.getCurrentChannelUri())) { // Simply adjust the volume without tune. - setVolumeByAudioFocusStatus(); + mAudioManagerHelper.setVolumeByAudioFocusStatus(); return; } stopTv(); @@ -1027,9 +958,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - mTvView.start(mTvInputManagerHelper); - setVolumeByAudioFocusStatus(); - tune(); + mTvView.start(); + mAudioManagerHelper.setVolumeByAudioFocusStatus(); + tune(true); } @Override @@ -1072,7 +1003,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private void stopAll(boolean keepVisibleBehind) { mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION); stopTv("stopAll()", keepVisibleBehind); - stopPip(); } public TvInputManagerHelper getTvInputManagerHelper() { @@ -1127,10 +1057,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC public void startSystemCaptioningSettingsActivity() { Intent intent = new Intent(Settings.ACTION_CAPTIONING_SETTINGS); - mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION - | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANEL_HISTORY); try { - startActivityForResultSafe(intent, REQUEST_CODE_START_SYSTEM_CAPTIONING_SETTINGS); + startActivitySafe(intent); } catch (ActivityNotFoundException e) { Toast.makeText(this, getString(R.string.msg_unable_to_start_system_captioning_settings), Toast.LENGTH_SHORT).show(); @@ -1145,10 +1073,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mProgramDataManager; } - public PipInputManager getPipInputManager() { - return mPipInputManager; - } - public TvOptionsManager getTvOptionsManager() { return mTvOptionsManager; } @@ -1185,13 +1109,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } /** - * Returns true if the current connected TV supports AC3 passthough. - */ - public boolean isAc3PassthroughSupported() { - return mAc3PassthroughSupported; - } - - /** * Returns the current program which the user is watching right now.<p> * * It might be a live program. If the time shifting is available, it can be a past program, too. @@ -1218,8 +1135,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } 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(); if (curChannel != null && curChannel.isBrowsable()) { return curChannel; @@ -1254,9 +1169,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // Show ChannelSourcesFragment only if all the channels are loaded. return; } - Channel currentChannel = mChannelTuner.getCurrentChannel(); - long channelId = currentChannel == null ? Channel.INVALID_ID : currentChannel.getId(); - mOverlayManager.getSideFragmentManager().show(new SettingsFragment(channelId)); + mOverlayManager.getSideFragmentManager().show(new SettingsFragment()); } public void showMerchantCollection() { @@ -1272,19 +1185,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mChannelBeforeShrunkenTvView = mTvView.getCurrentChannel(); mWasChannelUnblockedBeforeShrunkenByUser = mIsCurrentChannelUnblockedByUser; mAllowedRatingBeforeShrunken = mLastAllowedRatingForCurrentChannel; - - if (willMainViewBeTunerInput && mChannelTuner.isCurrentChannelPassthrough() - && mPipEnabled) { - mPipChannelBeforeShrunkenTvView = mPipChannel; - enablePipView(false, false); - } else { - mPipChannelBeforeShrunkenTvView = null; - } mTvViewUiManager.startShrunkenTvView(); if (showLockedChannelsTemporarily) { mShowLockedChannelsTemporarily = true; - checkChannelLockNeeded(mTvView); + checkChannelLockNeeded(mTvView, null); } mTvView.setBlockScreenType(getDesiredBlockScreenType()); @@ -1320,23 +1225,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mIsCompletingShrunkenTvView = false; mIsCurrentChannelUnblockedByUser = mWasChannelUnblockedBeforeShrunkenByUser; mTvView.setBlockScreenType(getDesiredBlockScreenType()); - if (mPipChannelBeforeShrunkenTvView != null) { - enablePipView(true, false); - mPipChannelBeforeShrunkenTvView = null; - } } }; mTvViewUiManager.fadeOutTvView(tuneAction); // Will automatically fade-in when video becomes available. } else { - checkChannelLockNeeded(mTvView); + checkChannelLockNeeded(mTvView, null); mIsCompletingShrunkenTvView = false; mIsCurrentChannelUnblockedByUser = mWasChannelUnblockedBeforeShrunkenByUser; mTvView.setBlockScreenType(getDesiredBlockScreenType()); - if (mPipChannelBeforeShrunkenTvView != null) { - enablePipView(true, false); - mPipChannelBeforeShrunkenTvView = null; - } } } @@ -1344,37 +1241,41 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mTvViewUiManager.isUnderShrunkenTvView() || mIsCompletingShrunkenTvView; } + /** + * Returns {@code true} if the tunable tv view is blocked by resource conflict or by parental + * control, otherwise {@code false}. + */ + public boolean isScreenBlockedByResourceConflictOrParentalControl() { + return mTvView.getVideoUnavailableReason() + == TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE || mTvView.isBlocked(); + } + @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - switch (requestCode) { - case REQUEST_CODE_START_SETUP_ACTIVITY: - if (resultCode == RESULT_OK) { - int count = mChannelDataManager.getChannelCountForInput(mInputIdUnderSetup); - String text; - if (count > 0) { - text = getResources().getQuantityString(R.plurals.msg_channel_added, - count, count); - } else { - text = getString(R.string.msg_no_channel_added); - } - Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show(); - mInputIdUnderSetup = null; - if (mChannelTuner.getCurrentChannel() == null) { - mChannelTuner.moveToAdjacentBrowsableChannel(true); - } - if (mTunePending) { - tune(); - } + if (requestCode == REQUEST_CODE_START_SETUP_ACTIVITY) { + if (resultCode == RESULT_OK) { + int count = mChannelDataManager.getChannelCountForInput(mInputIdUnderSetup); + String text; + if (count > 0) { + text = getResources().getQuantityString(R.plurals.msg_channel_added, + count, count); } else { - mInputIdUnderSetup = null; + text = getString(R.string.msg_no_channel_added); } - if (!mIsSetupActivityCalledByPopup) { - mOverlayManager.getSideFragmentManager().showSidePanel(false); + Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show(); + mInputIdUnderSetup = null; + if (mChannelTuner.getCurrentChannel() == null) { + mChannelTuner.moveToAdjacentBrowsableChannel(true); } - break; - case REQUEST_CODE_START_SYSTEM_CAPTIONING_SETTINGS: + if (mTunePending) { + tune(true); + } + } else { + mInputIdUnderSetup = null; + } + if (!mIsSetupActivityCalledByPopup) { mOverlayManager.getSideFragmentManager().showSidePanel(false); - break; + } } if (data != null) { String errorMessage = data.getStringExtra(LauncherActivity.ERROR_MESSAGE); @@ -1418,18 +1319,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC // while gamepads support DPAD_CENTER and BACK by fallback. // Since we don't expect that TIS want to handle gamepad buttons now, // blacklist gamepad buttons and wait for next fallback keys. - // TODO) Need to consider other fallback keys (e.g. ESCAPE) + // TODO: Need to consider other fallback keys (e.g. ESCAPE) return super.dispatchKeyEvent(event); } return dispatchKeyEventToSession(event) || super.dispatchKeyEvent(event); } - @Override - public void onAudioFocusChange(int focusChange) { - mAudioFocusStatus = focusChange; - setVolumeByAudioFocusStatus(); - } - /** * Notifies the key input focus is changed to the TV view. */ @@ -1449,18 +1344,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!mTvView.isPlaying()) { mCaptionSettings = new CaptionSettings(this); } - - // Handle the passed key press, if any. Note that only the key codes that are currently - // handled in the TV app will be handled via Intent. - // TODO: Consider defining a separate intent filter as passing data of mime type - // vnd.android.cursor.item/channel isn't really necessary here. - int keyCode = intent.getIntExtra(Utils.EXTRA_KEY_KEYCODE, KeyEvent.KEYCODE_UNKNOWN); - if (keyCode != KeyEvent.KEYCODE_UNKNOWN) { - if (DEBUG) Log.d(TAG, "Got an intent with keycode: " + keyCode); - KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCode); - onKeyUp(keyCode, event); - return true; - } mShouldTuneToTunerChannel = intent.getBooleanExtra(Utils.EXTRA_KEY_FROM_LAUNCHER, false); mInitChannelUri = null; @@ -1476,9 +1359,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - // TODO: remove the checkState once N API is finalized. - SoftPreconditions.checkState(TvInputManager.ACTION_SETUP_INPUTS.equals( - "android.media.tv.action.SETUP_INPUTS")); if (TvInputManager.ACTION_SETUP_INPUTS.equals(intent.getAction())) { runAfterAttachedToWindow(new Runnable() { @Override @@ -1488,17 +1368,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC }); } else if (Intent.ACTION_VIEW.equals(intent.getAction())) { Uri uri = intent.getData(); - try { - mSource = uri.getQueryParameter(Utils.PARAM_SOURCE); - } catch (UnsupportedOperationException e) { - // ignore this exception. - } - // When the URI points to the programs (directory, not an individual item), go to the - // program guide. The intention here is to respond to - // "content://android.media.tv/program", not "content://android.media.tv/program/XXX". - // Later, we might want to add handling of individual programs too. if (Utils.isProgramsUri(uri)) { - // The given data is a programs URI. Open the Program Guide. + // When the URI points to the programs (directory, not an individual item), go to + // the program guide. The intention here is to respond to + // "content://android.media.tv/program", not + // "content://android.media.tv/program/XXX". + // Later, we might want to add handling of individual programs too. mShowProgramGuide = true; return true; } @@ -1541,17 +1416,19 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } else if (mInitChannelUri != null) { // Handle the URI built by TvContract.buildChannelsUriForInput(). - // TODO: Change hard-coded "input" to TvContract.PARAM_INPUT. String inputId = mInitChannelUri.getQueryParameter("input"); long channelId = Utils.getLastWatchedChannelIdForInput(this, inputId); if (channelId == Channel.INVALID_ID) { String[] projection = { Channels._ID }; + long time = System.currentTimeMillis(); try (Cursor cursor = getContentResolver().query(uri, projection, null, null, null)) { if (cursor != null && cursor.moveToNext()) { channelId = cursor.getLong(0); } } + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity queries DB for " + + "last channel check (" + (System.currentTimeMillis() - time) + "ms)"); } if (channelId == Channel.INVALID_ID) { // Couldn't find any channel probably because the input hasn't been set up. @@ -1567,41 +1444,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return true; } - private void setVolumeByAudioFocusStatus() { - if (mPipSound == TvSettings.PIP_SOUND_MAIN) { - setVolumeByAudioFocusStatus(mTvView); - } else { // mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW - setVolumeByAudioFocusStatus(mPipView); - } - } - - private void setVolumeByAudioFocusStatus(TunableTvView tvView) { - SoftPreconditions.checkState(tvView == mTvView || tvView == mPipView); - if (tvView.isPlaying()) { - switch (mAudioFocusStatus) { - case AudioManager.AUDIOFOCUS_GAIN: - tvView.setStreamVolume(AUDIO_MAX_VOLUME); - break; - case AudioManager.AUDIOFOCUS_LOSS: - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - tvView.setStreamVolume(AUDIO_MIN_VOLUME); - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: - tvView.setStreamVolume(AUDIO_DUCKING_VOLUME); - break; - } - } - if (tvView == mTvView) { - if (mPipView != null && mPipView.isPlaying()) { - mPipView.setStreamVolume(AUDIO_MIN_VOLUME); - } - } else { // tvView == mPipView - if (mTvView != null && mTvView.isPlaying()) { - mTvView.setStreamVolume(AUDIO_MIN_VOLUME); - } - } - } - private void stopTv() { stopTv(null, false); } @@ -1617,10 +1459,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!keepVisibleBehind) { requestVisibleBehind(false); } - mAudioManager.abandonAudioFocus(this); - if (mMediaSession.isActive()) { - mMediaSession.setActive(false); - } + mAudioManagerHelper.abandonAudioFocus(); + mMediaSessionWrapper.setPlaybackState(false); } TvApplication.getSingletons(this).getMainActivityWrapper() .notifyCurrentChannelChange(this, null); @@ -1628,99 +1468,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mTunePending = false; } - private boolean isPlaying() { - return mTvView.isPlaying() && mTvView.getCurrentChannel() != null; - } - - private void startPip(final boolean fromUserInteraction) { - if (mPipChannel == null) { - Log.w(TAG, "PIP channel id is an invalid id."); - return; - } - if (DEBUG) Log.d(TAG, "startPip() " + mPipChannel); - mPipView.start(mTvInputManagerHelper); - boolean success = mPipView.tuneTo(mPipChannel, null, new OnTuneListener() { - @Override - public void onUnexpectedStop(Channel channel) { - Log.w(TAG, "The PIP is Unexpectedly stopped"); - enablePipView(false, false); - } - - @Override - public void onTuneFailed(Channel channel) { - Log.w(TAG, "Fail to start the PIP during channel tuning"); - if (fromUserInteraction) { - Toast.makeText(MainActivity.this, R.string.msg_no_pip_support, - Toast.LENGTH_SHORT).show(); - enablePipView(false, false); - } - } - - @Override - public void onStreamInfoChanged(StreamInfo info) { - mTvViewUiManager.updatePipView(); - mHandler.removeCallbacks(mRestoreMainViewRunnable); - restoreMainTvView(); - } - - @Override - public void onChannelRetuned(Uri channel) { - if (channel == null) { - return; - } - Channel currentChannel = - mChannelDataManager.getChannel(ContentUris.parseId(channel)); - if (currentChannel == null) { - Log.e(TAG, "onChannelRetuned is called from PIP input but can't find a channel" - + " with the URI " + channel); - return; - } - if (isChannelChangeKeyDownReceived()) { - // Ignore this message if the user is changing the channel. - return; - } - mPipChannel = currentChannel; - mPipView.setCurrentChannel(mPipChannel); - } - - @Override - public void onContentBlocked() { - updateMediaSession(); - } - - @Override - public void onContentAllowed() { - updateMediaSession(); - } - }); - if (!success) { - Log.w(TAG, "Fail to start the PIP"); - return; - } - if (fromUserInteraction) { - checkChannelLockNeeded(mPipView); - } - // Explicitly make the PIP view main to make the selected input an HDMI-CEC active source. - mPipView.setMain(); - scheduleRestoreMainTvView(); - mTvViewUiManager.onPipStart(); - setVolumeByAudioFocusStatus(); - } - private void scheduleRestoreMainTvView() { mHandler.removeCallbacks(mRestoreMainViewRunnable); mHandler.postDelayed(mRestoreMainViewRunnable, TVVIEW_SET_MAIN_TIMEOUT_MS); } - private void stopPip() { - if (DEBUG) Log.d(TAG, "stopPip"); - if (mPipView.isPlaying()) { - mPipView.stop(); - mPipSwap = false; - mTvViewUiManager.onPipStop(); - } - } - /** * Says {@code text} when accessibility is turned on. */ @@ -1735,7 +1487,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - private void tune() { + private void tune(boolean updateChannelBanner) { if (DEBUG) Log.d(TAG, "tune()"); mTuneDurationTimer.start(); @@ -1748,6 +1500,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } mTunePending = false; final Channel channel = mChannelTuner.getCurrentChannel(); + SoftPreconditions.checkState(channel != null); + if (channel == null) { + return; + } if (!mChannelTuner.isCurrentChannelPassthrough()) { if (mTvInputManagerHelper.getTunerTvInputSize() == 0) { Toast.makeText(this, R.string.msg_no_input, Toast.LENGTH_SHORT).show(); @@ -1763,23 +1519,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } if (mChannelDataManager.getChannelCount() > 0) { mOverlayManager.showIntroDialog(); + } else { + startOnboardingActivity(); + return; } } - if (!TvCommonUtils.isRunningInTest() && mShowNewSourcesFragment - && setupUtils.hasUnrecognizedInput(mTvInputManagerHelper)) { - // Show new channel sources fragment. - runAfterAttachedToWindow(new Runnable() { - @Override - public void run() { - mOverlayManager.runAfterOverlaysAreClosed(new Runnable() { - @Override - public void run() { - mOverlayManager.showNewSourcesFragment(); - } - }); - } - }); - } mShowNewSourcesFragment = false; if (mChannelTuner.getBrowsableChannelCount() == 0 && mChannelDataManager.getChannelCount() > 0 @@ -1791,24 +1535,24 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mOverlayManager.getSideFragmentManager().show( new CustomizeChannelListFragment()); } else { - showSettingsFragment(); + mOverlayManager.showSetupFragment(); } return; } - // TODO: need to refactor the following code to put in startTv. - if (channel == null) { - // There is no channel to tune to. - stopTv("tune()", false); - if (!mChannelDataManager.isDbLoadFinished()) { - // Wait until channel data is loaded in order to know the number of channels. - // tune() will be retried, once the channel data is loaded. - return; - } - if (mOverlayManager.getSideFragmentManager().isActive()) { - return; - } - mOverlayManager.showSetupFragment(); - return; + if (!TvCommonUtils.isRunningInTest() && mShowNewSourcesFragment + && setupUtils.hasUnrecognizedInput(mTvInputManagerHelper)) { + // Show new channel sources fragment. + runAfterAttachedToWindow(new Runnable() { + @Override + public void run() { + mOverlayManager.runAfterOverlaysAreClosed(new Runnable() { + @Override + public void run() { + mOverlayManager.showNewSourcesFragment(); + } + }); + } + }); } setupUtils.onTuned(); if (mTuneParams != null) { @@ -1825,7 +1569,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!isUnderShrunkenTvView()) { mLastAllowedRatingForCurrentChannel = null; } - mHandler.removeMessages(MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE); // For every tune, we need to inform the tuned channel or input to a user, // if Talkback is turned on. sendAccessibilityText(!mChannelTuner.isCurrentChannelPassthrough() ? @@ -1852,15 +1595,21 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC TvApplication.getSingletons(this).getMainActivityWrapper() .notifyCurrentChannelChange(this, channel); } - checkChannelLockNeeded(mTvView); - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_TUNE); + // We have to provide channel here instead of using TvView's channel, because TvView's + // channel might be null when there's tuner conflict. In that case, TvView will resets + // its current channel onConnectionFailed(). + checkChannelLockNeeded(mTvView, channel); + if (updateChannelBanner) { + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE); + } if (mActivityResumed) { // requestVisibleBehind should be called after onResume() is called. But, when // launcher is over the TV app and the screen is turned off and on, tune() can // be called during the pause state by mBroadcastReceiver (Intent.ACTION_SCREEN_ON). requestVisibleBehind(true); } - updateMediaSession(); + mMediaSessionWrapper.update(mTvView.isBlocked(), getCurrentChannel(), getCurrentProgram()); } // Runs the runnable after the activity is attached to window to show the fragment transition @@ -1895,136 +1644,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - private void updateMediaSession() { - if (getCurrentChannel() == null) { - mMediaSession.setActive(false); - return; - } - - // If the channel is blocked, display a lock and a short text on the Now Playing Card - if (mTvView.isScreenBlocked() || mTvView.getBlockedContentRating() != null) { - setMediaSessionPlaybackState(false); - - Bitmap art = BitmapFactory.decodeResource( - getResources(), R.drawable.ic_message_lock_preview); - updateMediaMetadata( - getResources().getString(R.string.channel_banner_locked_channel_title), art); - mMediaSession.setActive(true); - return; - } - - final Program currentProgram = getCurrentProgram(); - String cardTitleText = null; - String posterArtUri = null; - if (currentProgram != null) { - cardTitleText = currentProgram.getTitle(); - posterArtUri = currentProgram.getPosterArtUri(); - } - if (TextUtils.isEmpty(cardTitleText)) { - cardTitleText = getCurrentChannelName(); - } - updateMediaMetadata(cardTitleText, null); - setMediaSessionPlaybackState(true); - - if (posterArtUri == null) { - posterArtUri = TvContract.buildChannelLogoUri(getCurrentChannelId()).toString(); - } - updatePosterArt(getCurrentChannel(), currentProgram, cardTitleText, null, posterArtUri); - mMediaSession.setActive(true); - } - - private void updatePosterArt(Channel currentChannel, Program currentProgram, - String cardTitleText, @Nullable Bitmap posterArt, @Nullable String posterArtUri) { - if (posterArt != null) { - updateMediaMetadata(cardTitleText, posterArt); - } else if (posterArtUri != null) { - ImageLoader.loadBitmap(this, posterArtUri, mNowPlayingCardWidth, mNowPlayingCardHeight, - new ProgramPosterArtCallback(this, currentChannel, - currentProgram, cardTitleText)); - } else { - updateMediaMetadata(cardTitleText, R.drawable.default_now_card); - } - } - - 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; - } - - @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) { + boolean isNowPlayingProgram(Channel channel, Program program) { return program == null ? (channel != null && getCurrentProgram() == null && channel.equals(getCurrentChannel())) : program.equals(getCurrentProgram()); } - 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) { - return ""; - } - if (channel.isPassthrough()) { - TvInputInfo input = getTvInputManagerHelper().getTvInputInfo(channel.getInputId()); - return Utils.loadLabel(this, input); - } else { - return channel.getDisplayName(); - } - } - - private void setMediaSessionPlaybackState(boolean isPlaying) { - PlaybackState.Builder builder = new PlaybackState.Builder(); - builder.setState(isPlaying ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_STOPPED, - PlaybackState.PLAYBACK_POSITION_UNKNOWN, - isPlaying ? MEDIA_SESSION_PLAYING_SPEED : MEDIA_SESSION_STOPPED_SPEED); - mMediaSession.setPlaybackState(builder.build()); - } - private void addToRecentChannels(long channelId) { if (!mRecentChannels.remove(channelId)) { if (mRecentChannels.size() >= MAX_RECENT_CHANNELS) { @@ -2042,106 +1666,35 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mRecentChannels; } - private void checkChannelLockNeeded(TunableTvView tvView) { - Channel channel = tvView.getCurrentChannel(); - if (tvView.isPlaying() && channel != null) { + private void checkChannelLockNeeded(TunableTvView tvView, Channel currentChannel) { + if (currentChannel == null) { + currentChannel = tvView.getCurrentChannel(); + } + if (tvView.isPlaying() && currentChannel != null) { if (getParentalControlSettings().isParentalControlsEnabled() - && channel.isLocked() + && currentChannel.isLocked() && !mShowLockedChannelsTemporarily && !(isUnderShrunkenTvView() - && channel.equals(mChannelBeforeShrunkenTvView) + && currentChannel.equals(mChannelBeforeShrunkenTvView) && mWasChannelUnblockedBeforeShrunkenByUser)) { - if (DEBUG) Log.d(TAG, "Channel " + channel.getId() + " is locked"); - blockScreen(tvView); + if (DEBUG) Log.d(TAG, "Channel " + currentChannel.getId() + " is locked"); + blockOrUnblockScreen(tvView, true); } else { - unblockScreen(tvView); + blockOrUnblockScreen(tvView, false); } } } - private void blockScreen(TunableTvView tvView) { - tvView.blockScreen(); + private void blockOrUnblockScreen(TunableTvView tvView, boolean blockOrUnblock) { + tvView.blockOrUnblockScreen(blockOrUnblock); if (tvView == mTvView) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); - updateMediaSession(); - } - } - - private void unblockScreen(TunableTvView tvView) { - tvView.unblockScreen(); - if (tvView == mTvView) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); - updateMediaSession(); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); + mMediaSessionWrapper.update(blockOrUnblock, getCurrentChannel(), getCurrentProgram()); } } /** - * Shows the channel banner if it was hidden from the side fragment. - * - * <p>When the side fragment is visible, showing the channel banner should be put off until the - * side fragment is closed even though the channel changes. - */ - public void showChannelBannerIfHiddenBySideFragment() { - if (mChannelBannerHiddenBySideFragment) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); - } - } - - private void updateChannelBannerAndShowIfNeeded(@ChannelBannerUpdateReason int reason) { - if(DEBUG) Log.d(TAG, "updateChannelBannerAndShowIfNeeded(reason=" + reason + ")"); - if (!mChannelTuner.isCurrentChannelPassthrough()) { - int lockType = ChannelBannerView.LOCK_NONE; - if (mTvView.isScreenBlocked()) { - lockType = ChannelBannerView.LOCK_CHANNEL_INFO; - } else if (mTvView.getBlockedContentRating() != null - || (getParentalControlSettings().isParentalControlsEnabled() - && !mTvView.isVideoAvailable())) { - // If the parental control is enabled, do not show the program detail until the - // video becomes available. - lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; - } - if (lockType == ChannelBannerView.LOCK_NONE) { - if (reason == UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST) { - // Do not show detailed program information while fast-tuning. - lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; - } else if (reason == UPDATE_CHANNEL_BANNER_REASON_TUNE - && getParentalControlSettings().isParentalControlsEnabled()) { - // If parental control is turned on, - // assumes that program is locked by default and waits for onContentAllowed. - lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; - } - } - // If lock type is not changed, we don't need to update channel banner by parental - // control. - if (!mChannelBannerView.setLockType(lockType) - && reason == UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK) { - return; - } - - mChannelBannerView.updateViews(mTvView); - } - boolean needToShowBanner = (reason == UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW - || reason == UPDATE_CHANNEL_BANNER_REASON_TUNE - || reason == UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST); - boolean noOverlayUiWhenResume = - mInputToSetUp == null && !mShowProgramGuide && !mShowSelectInputView; - if (needToShowBanner && noOverlayUiWhenResume - && mOverlayManager.getCurrentDialog() == null - && !mOverlayManager.isSetupFragmentActive() - && !mOverlayManager.isNewSourcesFragmentActive()) { - if (mChannelTuner.getCurrentChannel() == null) { - mChannelBannerHiddenBySideFragment = false; - } else if (mOverlayManager.getSideFragmentManager().isActive()) { - mChannelBannerHiddenBySideFragment = true; - } else { - mChannelBannerHiddenBySideFragment = false; - mOverlayManager.showBanner(); - } - } - updateAvailabilityToast(); - } - - /** * Hide the overlays when tuning to a channel from the menu (e.g. Channels). */ public void hideOverlaysForTune() { @@ -2197,7 +1750,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (bestTrack != null) { String selectedTrack = getSelectedTrack(TvTrackInfo.TYPE_AUDIO); if (!bestTrack.getId().equals(selectedTrack)) { - selectTrack(TvTrackInfo.TYPE_AUDIO, bestTrack); + selectTrack(TvTrackInfo.TYPE_AUDIO, bestTrack, UNDEFINED_TRACK_INDEX); } else { mTvOptionsManager.onMultiAudioChanged( Utils.getMultiAudioString(this, bestTrack, false)); @@ -2210,7 +1763,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private void applyClosedCaption() { List<TvTrackInfo> tracks = getTracks(TvTrackInfo.TYPE_SUBTITLE); if (tracks == null) { - mTvOptionsManager.onClosedCaptionsChanged(null); + mTvOptionsManager.onClosedCaptionsChanged(null, UNDEFINED_TRACK_INDEX); return; } @@ -2219,17 +1772,19 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC String selectedTrackId = getSelectedTrack(TvTrackInfo.TYPE_SUBTITLE); TvTrackInfo alternativeTrack = null; + int alternativeTrackIndex = UNDEFINED_TRACK_INDEX; if (enabled) { String language = mCaptionSettings.getLanguage(); String trackId = mCaptionSettings.getTrackId(); - for (TvTrackInfo track : tracks) { + for (int i = 0; i < tracks.size(); i++) { + TvTrackInfo track = tracks.get(i); if (Utils.isEqualLanguage(track.getLanguage(), language)) { if (track.getId().equals(trackId)) { if (!track.getId().equals(selectedTrackId)) { - selectTrack(TvTrackInfo.TYPE_SUBTITLE, track); + selectTrack(TvTrackInfo.TYPE_SUBTITLE, track, i); } else { // Already selected. Update the option string only. - mTvOptionsManager.onClosedCaptionsChanged(track); + mTvOptionsManager.onClosedCaptionsChanged(track, i); } if (DEBUG) { Log.d(TAG, "Subtitle Track Selected {id=" + track.getId() @@ -2238,14 +1793,16 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return; } else if (alternativeTrack == null) { alternativeTrack = track; + alternativeTrackIndex = i; } } } if (alternativeTrack != null) { if (!alternativeTrack.getId().equals(selectedTrackId)) { - selectTrack(TvTrackInfo.TYPE_SUBTITLE, alternativeTrack); + selectTrack(TvTrackInfo.TYPE_SUBTITLE, alternativeTrack, alternativeTrackIndex); } else { - mTvOptionsManager.onClosedCaptionsChanged(alternativeTrack); + mTvOptionsManager + .onClosedCaptionsChanged(alternativeTrack, alternativeTrackIndex); } if (DEBUG) { Log.d(TAG, "Subtitle Track Selected {id=" + alternativeTrack.getId() @@ -2255,29 +1812,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } if (selectedTrackId != null) { - selectTrack(TvTrackInfo.TYPE_SUBTITLE, null); + selectTrack(TvTrackInfo.TYPE_SUBTITLE, null, UNDEFINED_TRACK_INDEX); if (DEBUG) Log.d(TAG, "Subtitle Track Unselected"); return; } - mTvOptionsManager.onClosedCaptionsChanged(null); - } - - /** - * Pops up the KeypadChannelSwitchView with the given key input event. - * - * @param keyCode A key code of the key event. - */ - public void showKeypadChannelSwitchView(int keyCode) { - if (mChannelTuner.areAllChannelsLoaded()) { - mOverlayManager.showKeypadChannelSwitch(); - mKeypadChannelSwitchView.onNumberKeyUp(keyCode - KeyEvent.KEYCODE_0); - } - } - - public void showSearchActivity() { - // HACK: Once we moved the window layer to TYPE_APPLICATION_SUB_PANEL, - // the voice button doesn't work. So we directly call the voice action. - SearchManagerHelper.getInstance(this).launchAssistAction(); + mTvOptionsManager.onClosedCaptionsChanged(null, UNDEFINED_TRACK_INDEX); } public void showProgramGuideSearchFragment() { @@ -2295,13 +1834,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC @Override protected void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy()"); - SideFragment.releasePreloadedRecycledViews(); + Debug.getTimer(Debug.TAG_START_UP_TIMER).reset(); + SideFragment.releaseRecycledViewPool(); + ViewCache.getInstance().clear(); if (mTvView != null) { mTvView.release(); } - if (mPipView != null) { - mPipView.release(); - } if (mChannelTuner != null) { mChannelTuner.removeListener(mChannelTunerListener); mChannelTuner.stop(); @@ -2314,21 +1852,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mProgramDataManager.setPrefetchEnabled(false); } } - if (mPipInputManager != null) { - mPipInputManager.stop(); - } if (mOverlayManager != null) { mOverlayManager.release(); } - if (mKeypadChannelSwitchView != null) { - mKeypadChannelSwitchView.setChannels(null); - } mMemoryManageables.clear(); - if (mMediaSession != null) { - mMediaSession.release(); + if (mMediaSessionWrapper != null) { + mMediaSessionWrapper.release(); } - if (mAudioCapabilitiesReceiver != null) { - mAudioCapabilitiesReceiver.unregister(); + if (mAudioManagerHelper != null) { + mAudioManagerHelper.release(); } mHandler.removeCallbacksAndMessages(null); application.getMainActivityWrapper().onMainActivityDestroyed(this); @@ -2340,8 +1872,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mChannelStatusRecurringRunner.stop(); mChannelStatusRecurringRunner = null; } - if (mTvInputManagerHelper != null && Features.TUNER.isEnabled(this)) { - mTvInputManagerHelper.removeCallback(mTvInputCallback); + if (mTvInputManagerHelper != null) { + mTvInputManagerHelper.clearTvInputLabels(); + if (Features.TUNER.isEnabled(this)) { + mTvInputManagerHelper.removeCallback(mTvInputCallback); + } } super.onDestroy(); } @@ -2410,7 +1945,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC * 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 @@ -2422,8 +1956,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC finishChannelChangeIfNeeded(); if (event.getKeyCode() == KeyEvent.KEYCODE_SEARCH) { - showSearchActivity(); - return true; + // Prevent MainActivity from being closed by onVisibleBehindCanceled() + mOtherActivityLaunched = true; + return false; } switch (mOverlayManager.onKeyUp(keyCode, event)) { case KEY_EVENT_HANDLER_RESULT_DISPATCH_TO_OVERLAY: @@ -2467,12 +2002,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } else { if (KeypadChannelSwitchView.isChannelNumberKey(keyCode)) { - showKeypadChannelSwitchView(keyCode); + mOverlayManager.showKeypadChannelSwitch(keyCode); return true; } switch (keyCode) { case KeyEvent.KEYCODE_DPAD_RIGHT: - if (!mTvView.isVideoAvailable() + if (!mTvView.isVideoOrAudioAvailable() && mTvView.getVideoUnavailableReason() == TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE) { DvrUiHelper.startSchedulesActivityForTuneConflict(this, @@ -2480,35 +2015,16 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return true; } if (!PermissionUtils.hasModifyParentalControls(this)) { - // TODO: support this feature for non-system LC app. b/23939816 return true; } PinDialogFragment dialog = null; if (mTvView.isScreenBlocked()) { - dialog = new PinDialogFragment( - PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_CHANNEL, - new PinDialogFragment.ResultListener() { - @Override - public void done(boolean success) { - if (success) { - unblockScreen(mTvView); - mIsCurrentChannelUnblockedByUser = true; - } - } - }); - } else if (mTvView.getBlockedContentRating() != null) { - final TvContentRating rating = mTvView.getBlockedContentRating(); - dialog = new PinDialogFragment( - PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM, - new PinDialogFragment.ResultListener() { - @Override - public void done(boolean success) { - if (success) { - mLastAllowedRatingForCurrentChannel = rating; - mTvView.unblockContent(rating); - } - } - }); + dialog = PinDialogFragment + .create(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_CHANNEL); + } else if (mTvView.isContentBlocked()) { + dialog = PinDialogFragment + .create(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM, + mTvView.getBlockedContentRating().flattenToString()); } if (dialog != null) { mOverlayManager.showDialogFragment(PinDialogFragment.DIALOG_TAG, dialog, @@ -2531,7 +2047,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return true; } if (keyCode != KeyEvent.KEYCODE_MENU) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); } if (keyCode != KeyEvent.KEYCODE_E) { mOverlayManager.showMenu(Menu.REASON_NONE); @@ -2547,6 +2064,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!SystemProperties.USE_DEBUG_KEYS.getValue()) { break; } + // Pass through. case KeyEvent.KEYCODE_CAPTIONS: { mOverlayManager.getSideFragmentManager().show(new ClosedCaptionFragment()); return true; @@ -2555,14 +2073,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!SystemProperties.USE_DEBUG_KEYS.getValue()) { break; } + // Pass through. case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK: { mOverlayManager.getSideFragmentManager().show(new MultiAudioFragment()); return true; } - case KeyEvent.KEYCODE_GUIDE: { - mOverlayManager.showProgramGuide(); - return true; - } case KeyEvent.KEYCODE_INFO: { mOverlayManager.showBanner(); return true; @@ -2578,22 +2093,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC 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(); - } + DvrUiHelper.checkStorageStatusAndShowErrorMessage(this, + currentChannel.getInputId(), new Runnable() { + @Override + public void run() { + DvrUiHelper.requestRecordingCurrentProgram( + MainActivity.this, + currentChannel, program, false); + } + }); } } else { DvrUiHelper.showStopRecordingDialog(this, currentChannel.getId(), @@ -2624,7 +2134,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } if (SystemProperties.USE_DEBUG_KEYS.getValue() || BuildConfig.ENG) { switch (keyCode) { - case KeyEvent.KEYCODE_W: { + case KeyEvent.KEYCODE_W: mDebugNonFullSizeScreen = !mDebugNonFullSizeScreen; if (mDebugNonFullSizeScreen) { FrameLayout.LayoutParams params = @@ -2632,30 +2142,23 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC params.width = 960; params.height = 540; params.gravity = Gravity.START; - mTvView.setLayoutParams(params); + mTvView.setTvViewLayoutParams(params); } else { FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mTvView.getLayoutParams(); params.width = ViewGroup.LayoutParams.MATCH_PARENT; params.height = ViewGroup.LayoutParams.MATCH_PARENT; params.gravity = Gravity.CENTER; - mTvView.setLayoutParams(params); + mTvView.setTvViewLayoutParams(params); } return true; - } - case KeyEvent.KEYCODE_P: { - togglePipView(); - return true; - } case KeyEvent.KEYCODE_CTRL_LEFT: - case KeyEvent.KEYCODE_CTRL_RIGHT: { + case KeyEvent.KEYCODE_CTRL_RIGHT: mUseKeycodeBlacklist = !mUseKeycodeBlacklist; return true; - } - case KeyEvent.KEYCODE_O: { + case KeyEvent.KEYCODE_O: mOverlayManager.getSideFragmentManager().show(new DisplayModeFragment()); return true; - } case KeyEvent.KEYCODE_D: mOverlayManager.getSideFragmentManager().show(new DeveloperOptionFragment()); return true; @@ -2681,22 +2184,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } @Override - public void onBackPressed() { - // The activity should be returned to the caller of this activity - // when the mSource is not null. - if (!mOverlayManager.getSideFragmentManager().isActive() && isPlaying() - && mSource == null) { - // If back key would exit TV app, - // show McLauncher instead so we can get benefit of McLauncher's shyMode. - Intent startMain = new Intent(Intent.ACTION_MAIN); - startMain.addCategory(Intent.CATEGORY_HOME); - startActivity(startMain); - } else { - super.onBackPressed(); - } - } - - @Override public void onUserInteraction() { super.onUserInteraction(); if (mOverlayManager != null) { @@ -2725,66 +2212,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } } - public void togglePipView() { - enablePipView(!mPipEnabled, true); - mOverlayManager.getMenu().update(); - } - - public boolean isPipEnabled() { - return mPipEnabled; - } - - public void tuneToChannelForPip(Channel channel) { - if (!mPipEnabled) { - throw new IllegalStateException("tuneToChannelForPip is called when PIP is off"); - } - if (mPipChannel.equals(channel)) { - return; - } - mPipChannel = channel; - startPip(true); - } - - private void enablePipView(boolean enable, boolean fromUserInteraction) { - if (enable == mPipEnabled) { - return; - } - if (enable) { - List<PipInput> pipAvailableInputs = mPipInputManager.getPipInputList(true); - if (pipAvailableInputs.isEmpty()) { - Toast.makeText(this, R.string.msg_no_available_input_by_pip, Toast.LENGTH_SHORT) - .show(); - return; - } - // TODO: choose the last pip input. - Channel pipChannel = pipAvailableInputs.get(0).getChannel(); - if (pipChannel != null) { - mPipEnabled = true; - mPipChannel = pipChannel; - startPip(fromUserInteraction); - mTvViewUiManager.restorePipSize(); - mTvViewUiManager.restorePipLayout(); - mTvOptionsManager.onPipChanged(mPipEnabled); - } else { - Toast.makeText(this, R.string.msg_no_available_input_by_pip, Toast.LENGTH_SHORT) - .show(); - } - } else { - mPipEnabled = false; - mPipChannel = null; - // Recover the stream volume of the main TV view, if needed. - if (mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW) { - setVolumeByAudioFocusStatus(mTvView); - mPipSound = TvSettings.PIP_SOUND_MAIN; - mTvOptionsManager.onPipSoundChanged(mPipSound); - } - stopPip(); - mTvViewUiManager.restoreDisplayMode(false); - mTvOptionsManager.onPipChanged(mPipEnabled); - } - } - - private boolean isChannelChangeKeyDownReceived() { + /** + * Returns {@code true} if one of the channel changing keys are pressed and not released yet. + */ + public boolean isChannelChangeKeyDownReceived() { return mHandler.hasMessages(MSG_CHANNEL_UP_PRESSED) || mHandler.hasMessages(MSG_CHANNEL_DOWN_PRESSED); } @@ -2811,10 +2242,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (SystemProperties.LOG_KEYEVENT.getValue()) { Log.d(TAG, "dispatchKeyEventToSession(" + event + ")"); } - if (mPipEnabled && mChannelTuner.isCurrentChannelPassthrough()) { - // If PIP is enabled, key events will be used by UI. - return false; - } boolean handled = false; if (mTvView != null) { handled = mTvView.dispatchKeyEvent(event); @@ -2832,21 +2259,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } private boolean isKeyEventBlocked() { - // If the current channel is passthrough channel without a PIP view, - // we always don't handle the key events in TV activity. Instead, the key event will - // be handled by the passthrough TV input. - return mChannelTuner.isCurrentChannelPassthrough() && !mPipEnabled; + // If the current channel is a passthrough channel, we don't handle the key events in TV + // activity. Instead, the key event will be handled by the passthrough TV input. + return mChannelTuner.isCurrentChannelPassthrough(); } private void tuneToLastWatchedChannelForTunerInput() { if (!mChannelTuner.isCurrentChannelPassthrough()) { return; } - if (mPipEnabled) { - if (!mPipChannel.isPassthrough()) { - enablePipView(false, true); - } - } stopTv(); startTv(null); } @@ -2857,16 +2278,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mTvView.reset(); } } else { - if (mPipEnabled && mPipInputManager.areInSamePipInput(channel, mPipChannel)) { - enablePipView(false, true); - } if (!mTvView.isPlaying()) { startTv(channel.getUri()); } else if (channel.equals(mTvView.getCurrentChannel())) { - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_TUNE); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE); + } else if (channel == mChannelTuner.getCurrentChannel()) { + // Channel banner is already updated in moveToAdjacentChannel + tune(false); } else if (mChannelTuner.moveToChannel(channel)) { // Channel banner would be updated inside of tune. - tune(); + tune(true); } else { showSettingsFragment(); } @@ -2883,107 +2305,25 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC */ private void moveToAdjacentChannel(boolean channelUp, boolean fastTuning) { if (mChannelTuner.moveToAdjacentBrowsableChannel(channelUp)) { - updateChannelBannerAndShowIfNeeded(fastTuning ? UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST - : UPDATE_CHANNEL_BANNER_REASON_TUNE); - } - } - - public Channel getPipChannel() { - return mPipChannel; - } - - /** - * Swap the main and the sub screens while in the PIP mode. - */ - public void swapPip() { - if (!mPipEnabled || mTvView == null || mPipView == null) { - Log.e(TAG, "swapPip() - not in PIP"); - mPipSwap = false; - return; - } - - Channel channel = mTvView.getCurrentChannel(); - boolean tvViewBlocked = mTvView.isScreenBlocked(); - boolean pipViewBlocked = mPipView.isScreenBlocked(); - if (channel == null || !mTvView.isPlaying()) { - // If the TV view is not currently playing or its current channel is null, swapping here - // basically means disabling the PIP mode and getting back to the full screen since - // there's no point of keeping a blank PIP screen at the bottom which is not tune-able. - enablePipView(false, true); - mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_DEFAULT); - mPipSwap = false; - return; - } - - // Reset the TV view and tune the PIP view to the previous channel of the TV view. - mTvView.reset(); - mPipView.reset(); - Channel oldPipChannel = mPipChannel; - tuneToChannelForPip(channel); - if (tvViewBlocked) { - mPipView.blockScreen(); - } else { - mPipView.unblockScreen(); + mOverlayManager.updateChannelBannerAndShowIfNeeded(fastTuning ? + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST + : TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE); } - - if (oldPipChannel != null) { - // Tune the TV view to the previous PIP channel. - tuneToChannel(oldPipChannel); - } - if (pipViewBlocked) { - mTvView.blockScreen(); - } else { - mTvView.unblockScreen(); - } - if (mPipSound == TvSettings.PIP_SOUND_MAIN) { - setVolumeByAudioFocusStatus(mTvView); - } else { // mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW - setVolumeByAudioFocusStatus(mPipView); - } - mPipSwap = !mPipSwap; - mTvOptionsManager.onPipSwapChanged(mPipSwap); - } - - /** - * Toggle where the sound is coming from when the user is watching the PIP. - */ - public void togglePipSoundMode() { - if (!mPipEnabled || mTvView == null || mPipView == null) { - Log.e(TAG, "togglePipSoundMode() - not in PIP"); - return; - } - if (mPipSound == TvSettings.PIP_SOUND_MAIN) { - setVolumeByAudioFocusStatus(mPipView); - mPipSound = TvSettings.PIP_SOUND_PIP_WINDOW; - } else { // mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW - setVolumeByAudioFocusStatus(mTvView); - mPipSound = TvSettings.PIP_SOUND_MAIN; - } - restoreMainTvView(); - mTvOptionsManager.onPipSoundChanged(mPipSound); } /** * Set the main TV view which holds HDMI-CEC active source based on the sound mode */ private void restoreMainTvView() { - if (mPipSound == TvSettings.PIP_SOUND_MAIN) { - mTvView.setMain(); - } else { // mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW - mPipView.setMain(); - } + mTvView.setMain(); } @Override public void onVisibleBehindCanceled() { stopTv("onVisibleBehindCanceled()", false); mTracker.sendScreenView(""); - mAudioFocusStatus = AudioManager.AUDIOFOCUS_LOSS; - mAudioManager.abandonAudioFocus(this); - if (mMediaSession.isActive()) { - mMediaSession.setActive(false); - } - stopPip(); + mAudioManagerHelper.abandonAudioFocus(); + mMediaSessionWrapper.setPlaybackState(false); mVisibleBehind = false; if (!mOtherActivityLaunched && Build.VERSION.SDK_INT == Build.VERSION_CODES.M) { // Workaround: in M, onStop is not called, even though it should be called after @@ -3012,13 +2352,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mTvView.getSelectedTrack(type); } - private void selectTrack(int type, TvTrackInfo track) { + private void selectTrack(int type, TvTrackInfo track, int trackIndex) { mTvView.selectTrack(type, track == null ? null : track.getId()); if (type == TvTrackInfo.TYPE_AUDIO) { mTvOptionsManager.onMultiAudioChanged(track == null ? null : Utils.getMultiAudioString(this, track, false)); } else if (type == TvTrackInfo.TYPE_SUBTITLE) { - mTvOptionsManager.onClosedCaptionsChanged(track); + mTvOptionsManager.onClosedCaptionsChanged(track, trackIndex); } } @@ -3073,16 +2413,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } private void updateAvailabilityToast() { - updateAvailabilityToast(mTvView); - } - - private void updateAvailabilityToast(StreamInfo info) { - if (info.isVideoAvailable()) { + if (mTvView.isVideoAvailable() + || mTvView.getCurrentChannel() != mChannelTuner.getCurrentChannel()) { return; } - int stringId; - switch (info.getVideoUnavailableReason()) { + switch (mTvView.getVideoUnavailableReason()) { case TunableTvView.VIDEO_UNAVAILABLE_REASON_NOT_TUNED: case TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE: case TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING: @@ -3092,13 +2428,22 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return; case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN: default: - stringId = R.string.msg_channel_unavailable_unknown; + Toast.makeText(this, R.string.msg_channel_unavailable_unknown, + Toast.LENGTH_SHORT).show(); break; } + } - Toast.makeText(this, stringId, Toast.LENGTH_SHORT).show(); + /** + * Returns {@code true} if some overlay UI will be shown when the activity is resumed. + */ + public boolean willShowOverlayUiWhenResume() { + return mInputToSetUp != null || mShowProgramGuide || mShowSelectInputView; } + /** + * Returns the current parental control settings. + */ public ParentalControlSettings getParentalControlSettings() { return mTvInputManagerHelper.getParentalControlSettings(); } @@ -3110,6 +2455,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC return mTvInputManagerHelper.getContentRatingsManager(); } + /** + * Returns the current captioning settings. + */ public CaptionSettings getCaptionSettings() { return mCaptionSettings; } @@ -3163,6 +2511,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (mActivityStarted) { initAnimations(); initSideFragments(); + initMenuItemViews(); } } }, LAZY_INITIALIZATION_DELAY); @@ -3174,7 +2523,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } private void initSideFragments() { - SideFragment.preloadRecycledViews(this); + SideFragment.preloadItemViews(this); + } + + private void initMenuItemViews() { + mOverlayManager.getMenu().preloadItemViews(); } @Override @@ -3207,10 +2560,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC sendMessageDelayed(Message.obtain(msg), getDelay(startTime)); mainActivity.moveToAdjacentChannel(true, true); break; - case MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE: - mainActivity.updateChannelBannerAndShowIfNeeded( - UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); - break; } } @@ -3225,14 +2574,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC private class MyOnTuneListener implements OnTuneListener { boolean mUnlockAllowedRatingBeforeShrunken = true; boolean mWasUnderShrunkenTvView; - long mStreamInfoUpdateTimeThresholdMs; Channel mChannel; - public MyOnTuneListener() { } - private void onTune(Channel channel, boolean wasUnderShrukenTvView) { - mStreamInfoUpdateTimeThresholdMs = - System.currentTimeMillis() + FIRST_STREAM_INFO_UPDATE_DELAY_MILLIS; + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("MainActivity.MyOnTuneListener.onTune"); mChannel = channel; mWasUnderShrunkenTvView = wasUnderShrukenTvView; } @@ -3249,7 +2594,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (mTvView.isFadedOut()) { mTvView.removeFadeEffect(); } - // TODO: show something to user about this error. + Toast.makeText(MainActivity.this, R.string.msg_channel_unavailable_unknown, + Toast.LENGTH_SHORT).show(); } @Override @@ -3258,29 +2604,21 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mTracker.sendChannelTuneTime(info.getCurrentChannel(), mTuneDurationTimer.reset()); } - // If updateChannelBanner() is called without delay, the stream info seems flickering - // when the channel is quickly changed. - if (!mHandler.hasMessages(MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE) - && info.isVideoAvailable()) { - if (System.currentTimeMillis() > mStreamInfoUpdateTimeThresholdMs) { - updateChannelBannerAndShowIfNeeded( - UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO); - } else { - mHandler.sendMessageDelayed(mHandler.obtainMessage( - MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE), - mStreamInfoUpdateTimeThresholdMs - System.currentTimeMillis()); - } + if (info.isVideoOrAudioAvailable() && mChannel == getCurrentChannel()) { + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_UPDATE_STREAM_INFO); } - applyDisplayRefreshRate(info.getVideoFrameRate()); - mTvViewUiManager.updateTvView(); + mTvViewUiManager.updateTvAspectRatio(); applyMultiAudio(); applyClosedCaption(); - // TODO: Send command to TIS with checking the settings in TV and CaptionManager. mOverlayManager.getMenu().onStreamInfoChanged(); if (mTvView.isVideoAvailable()) { mTvViewUiManager.fadeInTvView(); } + if (!mTvView.isContentBlocked() && !mTvView.isScreenBlocked()) { + updateAvailabilityToast(); + } mHandler.removeCallbacks(mRestoreMainViewRunnable); restoreMainTvView(); } @@ -3303,11 +2641,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC } mChannelTuner.setCurrentChannel(currentChannel); mTvView.setCurrentChannel(currentChannel); - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_TUNE); + mOverlayManager.updateChannelBannerAndShowIfNeeded( + TvOverlayManager.UPDATE_CHANNEL_BANNER_REASON_TUNE); } @Override public void onContentBlocked() { + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "MainActivity.MyOnTuneListener.onContentBlocked removes timer"); + Debug.removeTimer(Debug.TAG_START_UP_TIMER); mTuneDurationTimer.reset(); TvContentRating rating = mTvView.getBlockedContentRating(); // When tuneTo was called while TV view was shrunken, if the channel id is the same @@ -3319,9 +2661,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC mUnlockAllowedRatingBeforeShrunken = isUnderShrunkenTvView(); mTvView.unblockContent(rating); } - mChannelBannerView.setBlockingContentRating(rating); - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); + mOverlayManager.setBlockingContentRating(rating); mTvViewUiManager.fadeInTvView(); + mMediaSessionWrapper.update(true, getCurrentChannel(), getCurrentProgram()); } @Override @@ -3329,8 +2671,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC if (!isUnderShrunkenTvView()) { mUnlockAllowedRatingBeforeShrunken = false; } - mChannelBannerView.setBlockingContentRating(null); - updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); + mOverlayManager.setBlockingContentRating(null); + mMediaSessionWrapper.update(false, getCurrentChannel(), getCurrentProgram()); } } } diff --git a/src/com/android/tv/MainActivityWrapper.java b/src/com/android/tv/MainActivityWrapper.java index 01733255..5af5079f 100644 --- a/src/com/android/tv/MainActivityWrapper.java +++ b/src/com/android/tv/MainActivityWrapper.java @@ -61,7 +61,7 @@ public final class MainActivityWrapper { * Unsets the main activity instance. */ public void onMainActivityDestroyed(@NonNull MainActivity activity) { - if (mActivity != activity) { + if (mActivity == activity) { mActivity = null; } } diff --git a/src/com/android/tv/MediaSessionWrapper.java b/src/com/android/tv/MediaSessionWrapper.java new file mode 100644 index 00000000..da6ad2a4 --- /dev/null +++ b/src/com/android/tv/MediaSessionWrapper.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2017 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.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadata; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.media.tv.TvContract; +import android.media.tv.TvInputInfo; +import android.os.AsyncTask; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.data.Channel; +import com.android.tv.data.Program; +import com.android.tv.util.ImageLoader; +import com.android.tv.util.Utils; + +/** + * A wrapper class for {@link MediaSession} to support common operations on media sessions for + * {@link MainActivity}. + */ +class MediaSessionWrapper { + private static final String MEDIA_SESSION_TAG = "com.android.tv.mediasession"; + private static PlaybackState MEDIA_SESSION_STATE_PLAYING = new PlaybackState.Builder() + .setState(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1.0f) + .build(); + private static PlaybackState MEDIA_SESSION_STATE_STOPPED = new PlaybackState.Builder() + .setState(PlaybackState.STATE_STOPPED, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 0.0f) + .build(); + + private final Context mContext; + private final MediaSession mMediaSession; + private int mNowPlayingCardWidth; + private int mNowPlayingCardHeight; + + MediaSessionWrapper(Context context) { + mContext = context; + mMediaSession = new MediaSession(context, MEDIA_SESSION_TAG); + mMediaSession.setCallback(new MediaSession.Callback() { + @Override + public boolean onMediaButtonEvent(@NonNull Intent mediaButtonIntent) { + // Consume the media button event here. Should not send it to other apps. + return true; + } + }); + mMediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS | + MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + mNowPlayingCardWidth = mContext.getResources().getDimensionPixelSize( + R.dimen.notif_card_img_max_width); + mNowPlayingCardHeight = mContext.getResources().getDimensionPixelSize( + R.dimen.notif_card_img_height); + } + + /** + * Sets playback state. + * + * @param isPlaying {@code true} if TV is playing, otherwise {@code false}. + */ + void setPlaybackState(boolean isPlaying) { + if (isPlaying) { + mMediaSession.setActive(true); + // setPlaybackState() has to be called after calling setActive(). b/31933276 + mMediaSession.setPlaybackState(MEDIA_SESSION_STATE_PLAYING); + } else if (mMediaSession.isActive()) { + mMediaSession.setPlaybackState(MEDIA_SESSION_STATE_STOPPED); + mMediaSession.setActive(false); + } + } + + /** + * Updates media session according to the current TV playback status. + * + * @param blocked {@code true} if the current channel is blocked, either by user settings or + * the current program's content ratings. + * @param currentChannel The currently playing channel. + * @param currentProgram The currently playing program. + */ + void update(boolean blocked, Channel currentChannel, Program currentProgram) { + if (currentChannel == null) { + setPlaybackState(false); + return; + } + + // If the channel is blocked, display a lock and a short text on the Now Playing Card + if (blocked) { + Bitmap art = BitmapFactory.decodeResource(mContext.getResources(), + R.drawable.ic_message_lock_preview); + updateMediaMetadata(mContext.getResources() + .getString(R.string.channel_banner_locked_channel_title), art); + setPlaybackState(true); + return; + } + + String cardTitleText = null; + String posterArtUri = null; + if (currentProgram != null) { + cardTitleText = currentProgram.getTitle(); + posterArtUri = currentProgram.getPosterArtUri(); + } + if (TextUtils.isEmpty(cardTitleText)) { + cardTitleText = getChannelName(currentChannel); + } + updateMediaMetadata(cardTitleText, null); + if (posterArtUri == null) { + posterArtUri = TvContract.buildChannelLogoUri(currentChannel.getId()).toString(); + } + updatePosterArt(currentChannel, currentProgram, cardTitleText, null, posterArtUri); + setPlaybackState(true); + } + + /** + * Releases the media session. + * + * @see MediaSession#release() + */ + void release() { + mMediaSession.release(); + } + + private String getChannelName(Channel channel) { + if (channel.isPassthrough()) { + TvInputInfo input = TvApplication.getSingletons(mContext).getTvInputManagerHelper() + .getTvInputInfo(channel.getInputId()); + return Utils.loadLabel(mContext, input); + } else { + return channel.getDisplayName(); + } + } + + private void updatePosterArt(Channel currentChannel, Program currentProgram, + String cardTitleText, @Nullable Bitmap posterArt, @Nullable String posterArtUri) { + if (posterArt != null) { + updateMediaMetadata(cardTitleText, posterArt); + } else if (posterArtUri != null) { + ImageLoader.loadBitmap(mContext, posterArtUri, mNowPlayingCardWidth, + mNowPlayingCardHeight, new ProgramPosterArtCallback(this, currentChannel, + currentProgram, cardTitleText)); + } else { + updateMediaMetadata(cardTitleText, R.drawable.default_now_card); + } + } + + 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(mContext.getResources(), imageResId); + if (posterArt != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt); + } + mMediaSession.setMetadata(builder.build()); + return null; + } + }.execute(); + } + + private static class ProgramPosterArtCallback extends + ImageLoader.ImageLoaderCallback<MediaSessionWrapper> { + private final Channel mChannel; + private final Program mProgram; + private final String mCardTitleText; + + ProgramPosterArtCallback(MediaSessionWrapper sessionWrapper, Channel channel, + Program program, String cardTitleText) { + super(sessionWrapper); + mChannel = channel; + mProgram = program; + mCardTitleText = cardTitleText; + } + + @Override + public void onBitmapLoaded(MediaSessionWrapper sessionWrapper, @Nullable Bitmap posterArt) { + if (((MainActivity) sessionWrapper.mContext).isNowPlayingProgram(mChannel, mProgram)) { + sessionWrapper.updatePosterArt(mChannel, mProgram, mCardTitleText, posterArt, null); + } + } + } +} diff --git a/src/com/android/tv/SetupPassthroughActivity.java b/src/com/android/tv/SetupPassthroughActivity.java index 8a263a26..f0f54413 100644 --- a/src/com/android/tv/SetupPassthroughActivity.java +++ b/src/com/android/tv/SetupPassthroughActivity.java @@ -18,19 +18,27 @@ package com.android.tv; import android.app.Activity; import android.content.ActivityNotFoundException; +import android.content.Context; import android.content.Intent; import android.media.tv.TvInputInfo; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.MainThread; import android.util.Log; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.TvCommonConstants; +import com.android.tv.data.ChannelDataManager; +import com.android.tv.data.ChannelDataManager.Listener; 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; +import java.util.concurrent.TimeUnit; + /** * An activity to launch a TV input setup activity. * @@ -42,66 +50,83 @@ public class SetupPassthroughActivity extends Activity { private static final int REQUEST_START_SETUP_ACTIVITY = 200; + private static ScanTimeoutMonitor sScanTimeoutMonitor; + private TvInputInfo mTvInputInfo; private Intent mActivityAfterCompletion; + private boolean mEpgFetcherDuringScan; @Override public void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); - Intent intent = getIntent(); - SoftPreconditions.checkState( - intent.getAction().equals(TvCommonConstants.INTENT_ACTION_INPUT_SETUP)); ApplicationSingletons appSingletons = TvApplication.getSingletons(this); TvInputManagerHelper inputManager = appSingletons.getTvInputManagerHelper(); + Intent intent = getIntent(); String inputId = intent.getStringExtra(TvCommonConstants.EXTRA_INPUT_ID); mTvInputInfo = inputManager.getTvInputInfo(inputId); - if (DEBUG) Log.d(TAG, "TvInputId " + inputId + " / TvInputInfo " + mTvInputInfo); - if (mTvInputInfo == null) { - Log.w(TAG, "There is no input with the ID " + inputId + "."); - finish(); - return; - } - Intent setupIntent = intent.getExtras().getParcelable(TvCommonConstants.EXTRA_SETUP_INTENT); - if (DEBUG) Log.d(TAG, "Setup activity launch intent: " + setupIntent); - if (setupIntent == null) { - Log.w(TAG, "The input (" + mTvInputInfo.getId() + ") doesn't have setup."); - finish(); - return; - } - SetupUtils.grantEpgPermission(this, mTvInputInfo.getServiceInfo().packageName); mActivityAfterCompletion = intent.getParcelableExtra( TvCommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION); - if (DEBUG) Log.d(TAG, "Activity after completion " + mActivityAfterCompletion); - // If EXTRA_SETUP_INTENT is not removed, an infinite recursion happens during - // setupIntent.putExtras(intent.getExtras()). - Bundle extras = intent.getExtras(); - extras.remove(TvCommonConstants.EXTRA_SETUP_INTENT); - setupIntent.putExtras(extras); - 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(); + boolean needToFetchEpg = Utils.isInternalTvInput(this, mTvInputInfo.getId()) + && Experiments.CLOUD_EPG.get(); + if (needToFetchEpg) { + // In case when the activity is restored, this flag should be restored as well. + mEpgFetcherDuringScan = true; } - } - - @Override - protected void onDestroy() { - if (mTvInputInfo != null && Utils.isInternalTvInput(this, mTvInputInfo.getId()) - && Experiments.CLOUD_EPG.get()) { - EpgFetcher.getInstance(this).start(); + if (savedInstanceState == null) { + SoftPreconditions.checkState( + intent.getAction().equals(TvCommonConstants.INTENT_ACTION_INPUT_SETUP)); + if (DEBUG) Log.d(TAG, "TvInputId " + inputId + " / TvInputInfo " + mTvInputInfo); + if (mTvInputInfo == null) { + Log.w(TAG, "There is no input with the ID " + inputId + "."); + finish(); + return; + } + Intent setupIntent = + intent.getExtras().getParcelable(TvCommonConstants.EXTRA_SETUP_INTENT); + if (DEBUG) Log.d(TAG, "Setup activity launch intent: " + setupIntent); + if (setupIntent == null) { + Log.w(TAG, "The input (" + mTvInputInfo.getId() + ") doesn't have setup."); + finish(); + return; + } + SetupUtils.grantEpgPermission(this, mTvInputInfo.getServiceInfo().packageName); + if (DEBUG) Log.d(TAG, "Activity after completion " + mActivityAfterCompletion); + // If EXTRA_SETUP_INTENT is not removed, an infinite recursion happens during + // setupIntent.putExtras(intent.getExtras()). + Bundle extras = intent.getExtras(); + extras.remove(TvCommonConstants.EXTRA_SETUP_INTENT); + setupIntent.putExtras(extras); + try { + startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Can't find activity: " + setupIntent.getComponent()); + finish(); + return; + } + if (needToFetchEpg) { + if (sScanTimeoutMonitor == null) { + sScanTimeoutMonitor = new ScanTimeoutMonitor(this); + } + sScanTimeoutMonitor.startMonitoring(); + EpgFetcher.getInstance(this).onChannelScanStarted(); + } } - super.onDestroy(); } @Override public void onActivityResult(int requestCode, final int resultCode, final Intent data) { + if (DEBUG) Log.d(TAG, "onActivityResult"); + if (sScanTimeoutMonitor != null) { + sScanTimeoutMonitor.stopMonitoring(); + } + // Note: It's not guaranteed that this method is always called after scanning. boolean setupComplete = requestCode == REQUEST_START_SETUP_ACTIVITY && resultCode == Activity.RESULT_OK; + // Tells EpgFetcher that channel source setup is finished. + if (mEpgFetcherDuringScan) { + EpgFetcher.getInstance(this).onChannelScanFinished(); + } if (!setupComplete) { setResult(resultCode, data); finish(); @@ -122,4 +147,75 @@ public class SetupPassthroughActivity extends Activity { } }); } + + /** + * Monitors the scan progress and notifies the timeout of the scanning. + * The purpose of this monitor is to call EpgFetcher.onChannelScanFinished() in case when + * SetupPassthroughActivity.onActivityResult() is not called properly. b/36008534 + */ + @MainThread + private static class ScanTimeoutMonitor { + // Set timeout long enough. The message in Sony TV says the scanning takes about 30 minutes. + private static final long SCAN_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(30); + + private final Context mContext; + private final ChannelDataManager mChannelDataManager; + private final Handler mHandler = new Handler(Looper.getMainLooper()); + private final Runnable mScanTimeoutRunnable = new Runnable() { + @Override + public void run() { + Log.w(TAG, "No channels has been added for a while." + + " The scan might have finished unexpectedly."); + onScanTimedOut(); + } + }; + private final Listener mChannelDataManagerListener = new Listener() { + @Override + public void onLoadFinished() { + setupTimer(); + } + + @Override + public void onChannelListUpdated() { + setupTimer(); + } + + @Override + public void onChannelBrowsableChanged() { } + }; + private boolean mStarted; + + private ScanTimeoutMonitor(Context context) { + mContext = context.getApplicationContext(); + mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); + } + + private void startMonitoring() { + if (!mStarted) { + mStarted = true; + mChannelDataManager.addListener(mChannelDataManagerListener); + } + if (mChannelDataManager.isDbLoadFinished()) { + setupTimer(); + } + } + + private void stopMonitoring() { + if (mStarted) { + mStarted = false; + mHandler.removeCallbacks(mScanTimeoutRunnable); + mChannelDataManager.removeListener(mChannelDataManagerListener); + } + } + + private void setupTimer() { + mHandler.removeCallbacks(mScanTimeoutRunnable); + mHandler.postDelayed(mScanTimeoutRunnable, SCAN_TIMEOUT_MS); + } + + private void onScanTimedOut() { + stopMonitoring(); + EpgFetcher.getInstance(mContext).onChannelScanFinished(); + } + } } diff --git a/src/com/android/tv/TimeShiftManager.java b/src/com/android/tv/TimeShiftManager.java index 2d6d45c4..70885936 100644 --- a/src/com/android/tv/TimeShiftManager.java +++ b/src/com/android/tv/TimeShiftManager.java @@ -161,7 +161,6 @@ public class TimeShiftManager { @TimeShiftActionId private int mLastActionId = 0; - // TODO: Remove these variables once API level 23 is available. private final Context mContext; private Program mCurrentProgram; @@ -618,6 +617,15 @@ public class TimeShiftManager { + mAvailablityChangedTimeMs); return; } + if (recordStartTimeMs > System.currentTimeMillis()) { + // The time reported by TvInputService might not consistent with system + // clock,, use system's current time instead. + Log.e(TAG, "The start time should not be earlier than the current time, " + + "reset the start time to the system's current time: {" + + "startTime: " + recordStartTimeMs + ", current time: " + + System.currentTimeMillis()); + recordStartTimeMs = System.currentTimeMillis(); + } if (mRecordStartTimeMs == recordStartTimeMs) { return; } @@ -887,10 +895,12 @@ public class TimeShiftManager { } long fetchStartTimeMs = Utils.floorTime(startTimeMs, MAX_DUMMY_PROGRAM_DURATION); - boolean needToLoad = addDummyPrograms(fetchStartTimeMs, - endTimeMs + PREFETCH_DURATION_FOR_NEXT); + long fetchEndTimeMs = Utils.ceilTime(endTimeMs + PREFETCH_DURATION_FOR_NEXT, + MAX_DUMMY_PROGRAM_DURATION); + removeOutdatedPrograms(fetchStartTimeMs); + boolean needToLoad = addDummyPrograms(fetchStartTimeMs, fetchEndTimeMs); if (needToLoad) { - Range<Long> period = Range.create(fetchStartTimeMs, endTimeMs); + Range<Long> period = Range.create(fetchStartTimeMs, fetchEndTimeMs); mProgramLoadQueue.add(period); startTaskIfNeeded(); } @@ -996,6 +1006,12 @@ public class TimeShiftManager { return added; } + private void removeOutdatedPrograms(long startTimeMs) { + while (mPrograms.size() > 0 && mPrograms.get(0).getEndTimeUtcMillis() <= startTimeMs) { + mPrograms.remove(0); + } + } + private void removeDummyPrograms() { for (Iterator<Program> it = mPrograms.listIterator(); it.hasNext(); ) { if (!it.next().isValid()) { @@ -1012,7 +1028,7 @@ public class TimeShiftManager { for (int i = 0, j = 0; i < mPrograms.size() && j < loadedPrograms.size(); ++j) { Program loadedProgram = loadedPrograms.get(j); // Skip previous programs. - while (program.getEndTimeUtcMillis() < loadedProgram.getStartTimeUtcMillis()) { + while (program.getEndTimeUtcMillis() <= loadedProgram.getStartTimeUtcMillis()) { // Reached end of mPrograms. if (++i == mPrograms.size()) { return; diff --git a/src/com/android/tv/TvApplication.java b/src/com/android/tv/TvApplication.java index 0e18a259..0c7c0fd1 100644 --- a/src/com/android/tv/TvApplication.java +++ b/src/com/android/tv/TvApplication.java @@ -22,16 +22,20 @@ import android.app.Application; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; 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; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.StrictMode; import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; @@ -49,19 +53,30 @@ 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.PreviewDataManager; import com.android.tv.data.ProgramDataManager; +import com.android.tv.data.epg.EpgFetcher; 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.DvrScheduleManager; import com.android.tv.dvr.DvrStorageStatusManager; import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.recorder.RecordingScheduler; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.StubPerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.recommendation.ChannelPreviewUpdater; +import com.android.tv.recommendation.RecordedProgramPreviewUpdater; +import com.android.tv.tuner.TunerInputController; 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.Debug; +import com.android.tv.util.PermissionUtils; import com.android.tv.util.SetupUtils; import com.android.tv.util.SystemProperties; import com.android.tv.util.TvInputManagerHelper; @@ -72,17 +87,25 @@ 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; + private static final TimerEvent sAppStartTimer = StubPerformanceMonitor.startBootstrapTimer(); + + /** + * An instance of {@link ApplicationSingletons}. Note that this can be set directly only for the + * test purpose. + */ + @VisibleForTesting + public static ApplicationSingletons sAppSingletons; /** * 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. + * {@link com.android.tv.tuner.TunerInputController} will recevice this intent to check + * the existence of tuner input when the new version is first launched. */ 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 RemoteConfig mRemoteConfig; private String mVersionName = ""; private final MainActivityWrapper mMainActivityWrapper = new MainActivityWrapper(); @@ -92,21 +115,30 @@ public class TvApplication extends Application implements ApplicationSingletons private Tracker mTracker; private TvInputManagerHelper mTvInputManagerHelper; private ChannelDataManager mChannelDataManager; - private ProgramDataManager mProgramDataManager; + private volatile ProgramDataManager mProgramDataManager; + private PreviewDataManager mPreviewDataManager; private DvrManager mDvrManager; private DvrScheduleManager mDvrScheduleManager; private DvrDataManager mDvrDataManager; private DvrStorageStatusManager mDvrStorageStatusManager; private DvrWatchedPositionManager mDvrWatchedPositionManager; + private RecordingScheduler mRecordingScheduler; @Nullable private InputSessionManager mInputSessionManager; private AccountHelper mAccountHelper; // When this variable is null, we don't know in which process TvApplication runs. private Boolean mRunningInMainProcess; + private PerformanceMonitor mPerformanceMonitor; @Override public void onCreate() { super.onCreate(); + if (!PermissionUtils.hasInternet(this)) { + // When an isolated process starts, just skip all the initialization. + return; + } + Debug.getTimer(Debug.TAG_START_UP_TIMER).start(); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("Start TvApplication.onCreate"); SharedPreferencesUtils.initialize(this, new Runnable() { @Override public void run() { @@ -127,18 +159,15 @@ public class TvApplication extends Application implements ApplicationSingletons } 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.ThreadPolicy.Builder threadPolicyBuilder = new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog(); StrictMode.VmPolicy.Builder vmPolicyBuilder = - new StrictMode.VmPolicy.Builder().detectAll().penaltyLog(); + new StrictMode.VmPolicy.Builder().detectAll().penaltyDeath(); if (!TvCommonUtils.isRunningInTest()) { threadPolicyBuilder.penaltyDialog(); - // Turn off death penalty for tests b/23355898 - vmPolicyBuilder.penaltyDeath(); } StrictMode.setThreadPolicy(threadPolicyBuilder.build()); StrictMode.setVmPolicy(vmPolicyBuilder.build()); @@ -149,13 +178,16 @@ public class TvApplication extends Application implements ApplicationSingletons mAnalytics = StubAnalytics.getInstance(this); } mTracker = mAnalytics.getDefaultTracker(); - mTvInputManagerHelper = new TvInputManagerHelper(this); - mTvInputManagerHelper.start(); + getTvInputManagerHelper(); // 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); + + Log.i(TAG, "Started Live TV " + mVersionName); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("finish TvApplication.onCreate"); + getPerformanceMonitor().stopTimer(sAppStartTimer, EventNames.APPLICATION_ONCREATE); } private void setCurrentRunningProcess(boolean isMainProcess) { @@ -163,12 +195,22 @@ public class TvApplication extends Application implements ApplicationSingletons SoftPreconditions.checkState(isMainProcess == mRunningInMainProcess); return; } + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "start TvApplication.setCurrentRunningProcess"); mRunningInMainProcess = isMainProcess; if (CommonFeatures.DVR.isEnabled(this)) { mDvrStorageStatusManager = new DvrStorageStatusManager(this, mRunningInMainProcess); } + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + // Fetch remote config + getRemoteConfig().fetch(null); + return null; + } + }.execute(); if (mRunningInMainProcess) { - mTvInputManagerHelper.addCallback(new TvInputCallback() { + getTvInputManagerHelper().addCallback(new TvInputCallback() { @Override public void onInputAdded(String inputId) { if (Features.TUNER.isEnabled(TvApplication.this) && TextUtils.equals(inputId, @@ -186,15 +228,22 @@ public class TvApplication extends Application implements ApplicationSingletons 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); + TunerInputInfoUtils.updateTunerInputInfo(TvApplication.this); } if (CommonFeatures.DVR.isEnabled(this)) { mDvrScheduleManager = new DvrScheduleManager(this); mDvrManager = new DvrManager(this); - //NOTE: DvrRecordingService just keeps running. - DvrRecordingService.startService(this); + mRecordingScheduler = RecordingScheduler.createScheduler(this); + } + EpgFetcher.getInstance(this).startRoutineService(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ChannelPreviewUpdater.getInstance(this).startRoutineService(); + RecordedProgramPreviewUpdater.getInstance(this) + .updatePreviewDataForRecordedPrograms(); } } + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "finish TvApplication.setCurrentRunningProcess"); } private void checkTunerServiceOnFirstLaunch() { @@ -203,7 +252,7 @@ public class TvApplication extends Application implements ApplicationSingletons 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)); + TunerInputController.onCheckingUsbTunerStatus(this, ACTION_APPLICATION_FIRST_LAUNCHED); SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putBoolean(PREFERENCE_IS_FIRST_LAUNCH, false); editor.apply(); @@ -227,6 +276,15 @@ public class TvApplication extends Application implements ApplicationSingletons } /** + * Returns the {@link RecordingScheduler}. + */ + @Override + @Nullable + public RecordingScheduler getRecordingScheduler() { + return mRecordingScheduler; + } + + /** * Returns the {@link DvrWatchedPositionManager}. */ @Override @@ -268,24 +326,55 @@ public class TvApplication extends Application implements ApplicationSingletons @Override public ChannelDataManager getChannelDataManager() { if (mChannelDataManager == null) { - mChannelDataManager = new ChannelDataManager(this, mTvInputManagerHelper); + mChannelDataManager = new ChannelDataManager(this, getTvInputManagerHelper()); mChannelDataManager.start(); } return mChannelDataManager; } + @Override + public boolean isChannelDataManagerLoadFinished() { + return mChannelDataManager != null && mChannelDataManager.isDbLoadFinished(); + } + /** * Returns {@link ProgramDataManager}. */ @Override public ProgramDataManager getProgramDataManager() { - if (mProgramDataManager == null) { - mProgramDataManager = new ProgramDataManager(this); - mProgramDataManager.start(); + if (mProgramDataManager != null) { + return mProgramDataManager; } + Utils.runInMainThreadAndWait(new Runnable() { + @Override + public void run() { + if (mProgramDataManager == null) { + mProgramDataManager = new ProgramDataManager(TvApplication.this); + mProgramDataManager.start(); + } + } + }); return mProgramDataManager; } + @Override + public boolean isProgramDataManagerCurrentProgramsLoadFinished() { + return mProgramDataManager != null && mProgramDataManager.isCurrentProgramsLoadFinished(); + } + + /** + * Returns {@link PreviewDataManager}. + */ + @TargetApi(Build.VERSION_CODES.O) + @Override + public PreviewDataManager getPreviewDataManager() { + if (mPreviewDataManager == null) { + mPreviewDataManager = new PreviewDataManager(this); + mPreviewDataManager.start(); + } + return mPreviewDataManager; + } + /** * Returns {@link DvrDataManager}. */ @@ -314,6 +403,10 @@ public class TvApplication extends Application implements ApplicationSingletons */ @Override public TvInputManagerHelper getTvInputManagerHelper() { + if (mTvInputManagerHelper == null) { + mTvInputManagerHelper = new TvInputManagerHelper(this); + mTvInputManagerHelper.start(); + } return mTvInputManagerHelper; } @@ -345,6 +438,19 @@ public class TvApplication extends Application implements ApplicationSingletons return mRemoteConfig; } + @Override + public boolean isRunningInMainProcess() { + return mRunningInMainProcess != null && mRunningInMainProcess; + } + + @Override + public PerformanceMonitor getPerformanceMonitor() { + if (mPerformanceMonitor == null) { + mPerformanceMonitor = StubPerformanceMonitor.initialize(this); + } + return mPerformanceMonitor; + } + /** * SelectInputActivity is set in {@link SelectInputActivity#onCreate} and cleared in * {@link SelectInputActivity#onDestroy}. @@ -353,6 +459,14 @@ public class TvApplication extends Application implements ApplicationSingletons mSelectInputActivity = activity; } + public void handleGuideKey() { + if (!mMainActivityWrapper.isResumed()) { + startActivity(new Intent(Intent.ACTION_VIEW, TvContract.Programs.CONTENT_URI)); + } else { + mMainActivityWrapper.getMainActivity().getOverlayManager().toggleProgramGuide(); + } + } + /** * Handles the global key KEYCODE_TV. */ @@ -471,6 +585,7 @@ public class TvApplication extends Application implements ApplicationSingletons if (packageManager.getComponentEnabledSetting(name) != newState) { packageManager.setComponentEnabledSetting(name, newState, dontKillApp ? PackageManager.DONT_KILL_APP : 0); + Log.i(TAG, (enable ? "Un-hide" : "Hide") + " Live TV."); } SetupUtils.getInstance(TvApplication.this).onInputListUpdated(inputManager); } @@ -479,7 +594,11 @@ public class TvApplication extends Application implements ApplicationSingletons * Returns the @{@link ApplicationSingletons} using the application context. */ public static ApplicationSingletons getSingletons(Context context) { - return (ApplicationSingletons) context.getApplicationContext(); + // No need to be "synchronized" because this doesn't create any instance. + if (sAppSingletons == null) { + sAppSingletons = (ApplicationSingletons) context.getApplicationContext(); + } + return sAppSingletons; } /** @@ -491,6 +610,7 @@ public class TvApplication extends Application implements ApplicationSingletons * specific initializations. */ public static void setCurrentRunningProcess(Context context, boolean isMainProcess) { + // TODO(b/63064354) TvApplication should not have to know if it is "the main process" if (context.getApplicationContext() instanceof TvApplication) { TvApplication tvApplication = (TvApplication) context.getApplicationContext(); tvApplication.setCurrentRunningProcess(isMainProcess); diff --git a/src/com/android/tv/TvOptionsManager.java b/src/com/android/tv/TvOptionsManager.java index 7871cbe7..493e039c 100644 --- a/src/com/android/tv/TvOptionsManager.java +++ b/src/com/android/tv/TvOptionsManager.java @@ -18,14 +18,13 @@ package com.android.tv; import android.content.Context; import android.media.tv.TvTrackInfo; +import android.support.annotation.IntDef; import android.util.SparseArray; import com.android.tv.data.DisplayMode; -import com.android.tv.util.TvSettings; -import com.android.tv.util.TvSettings.PipLayout; -import com.android.tv.util.TvSettings.PipSize; -import com.android.tv.util.TvSettings.PipSound; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Locale; /** @@ -33,39 +32,34 @@ import java.util.Locale; * captions and display mode. Can be also used to create MenuAction items to control such options. */ public class TvOptionsManager { + @Retention(RetentionPolicy.SOURCE) + @IntDef({OPTION_CLOSED_CAPTIONS, OPTION_DISPLAY_MODE, OPTION_SYSTEMWIDE_PIP, OPTION_MULTI_AUDIO, + OPTION_MORE_CHANNELS, OPTION_DEVELOPER, OPTION_SETTINGS}) + public @interface OptionType {} public static final int OPTION_CLOSED_CAPTIONS = 0; public static final int OPTION_DISPLAY_MODE = 1; - public static final int OPTION_IN_APP_PIP = 2; - 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_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; - public static final int OPTION_PIP_SOUND = 102; - public static final int OPTION_PIP_LAYOUT = 103 ; - public static final int OPTION_PIP_SIZE = 104; + public static final int OPTION_SYSTEMWIDE_PIP = 2; + public static final int OPTION_MULTI_AUDIO = 3; + public static final int OPTION_MORE_CHANNELS = 4; + public static final int OPTION_DEVELOPER = 5; + public static final int OPTION_SETTINGS = 6; private final Context mContext; private final SparseArray<OptionChangedListener> mOptionChangedListeners = new SparseArray<>(); private String mClosedCaptionsLanguage; private int mDisplayMode; - private boolean mPip; private String mMultiAudio; - private String mPipInput; - private boolean mPipSwap; - @PipSound private int mPipSound; - @PipLayout private int mPipLayout; - @PipSize private int mPipSize; public TvOptionsManager(Context context) { mContext = context; } - public String getOptionString(int option) { + /** + * Returns a suitable displayed string for the given option type under current settings. + * @param option the type of option, should be one of {@link OptionType}. + */ + public String getOptionString(@OptionType int option) { switch (option) { case OPTION_CLOSED_CAPTIONS: if (mClosedCaptionsLanguage == null) { @@ -77,101 +71,48 @@ public class TvOptionsManager { .isDisplayModeAvailable(mDisplayMode) ? DisplayMode.getLabel(mDisplayMode, mContext) : DisplayMode.getLabel(DisplayMode.MODE_NORMAL, mContext); - case OPTION_IN_APP_PIP: - return mContext.getString( - mPip ? R.string.options_item_pip_on : R.string.options_item_pip_off); case OPTION_MULTI_AUDIO: return mMultiAudio; - case OPTION_PIP_INPUT: - return mPipInput; - case OPTION_PIP_SWAP: - return mContext.getString(mPipSwap ? R.string.pip_options_item_swap_on - : R.string.pip_options_item_swap_off); - case OPTION_PIP_SOUND: - if (mPipSound == TvSettings.PIP_SOUND_MAIN) { - return mContext.getString(R.string.pip_options_item_sound_main); - } else if (mPipSound == TvSettings.PIP_SOUND_PIP_WINDOW) { - return mContext.getString(R.string.pip_options_item_sound_pip_window); - } - break; - case OPTION_PIP_LAYOUT: - if (mPipLayout == TvSettings.PIP_LAYOUT_BOTTOM_RIGHT) { - return mContext.getString(R.string.pip_options_item_layout_bottom_right); - } else if (mPipLayout == TvSettings.PIP_LAYOUT_TOP_RIGHT) { - return mContext.getString(R.string.pip_options_item_layout_top_right); - } else if (mPipLayout == TvSettings.PIP_LAYOUT_TOP_LEFT) { - return mContext.getString(R.string.pip_options_item_layout_top_left); - } else if (mPipLayout == TvSettings.PIP_LAYOUT_BOTTOM_LEFT) { - return mContext.getString(R.string.pip_options_item_layout_bottom_left); - } else if (mPipLayout == TvSettings.PIP_LAYOUT_SIDE_BY_SIDE) { - return mContext.getString(R.string.pip_options_item_layout_side_by_side); - } - break; - case OPTION_PIP_SIZE: - if (mPipSize == TvSettings.PIP_SIZE_BIG) { - return mContext.getString(R.string.pip_options_item_size_big); - } else if (mPipSize == TvSettings.PIP_SIZE_SMALL) { - return mContext.getString(R.string.pip_options_item_size_small); - } - break; } return ""; } - public void onClosedCaptionsChanged(TvTrackInfo track) { - mClosedCaptionsLanguage = (track == null) ? null - : (track.getLanguage() != null) ? track.getLanguage() - : mContext.getString(R.string.default_language); + /** + * Handles changing selection of closed caption. + */ + public void onClosedCaptionsChanged(TvTrackInfo track, int trackIndex) { + mClosedCaptionsLanguage = (track == null) ? + null : (track.getLanguage() != null) ? track.getLanguage() + : mContext.getString(R.string.closed_caption_unknown_language, trackIndex + 1); notifyOptionChanged(OPTION_CLOSED_CAPTIONS); } + /** + * Handles changing selection of display mode. + */ public void onDisplayModeChanged(int displayMode) { mDisplayMode = displayMode; notifyOptionChanged(OPTION_DISPLAY_MODE); } - public void onPipChanged(boolean pip) { - mPip = pip; - notifyOptionChanged(OPTION_IN_APP_PIP); - } - + /** + * Handles changing selection of multi-audio. + */ public void onMultiAudioChanged(String multiAudio) { mMultiAudio = multiAudio; notifyOptionChanged(OPTION_MULTI_AUDIO); } - public void onPipInputChanged(String pipInput) { - mPipInput = pipInput; - notifyOptionChanged(OPTION_PIP_INPUT); - } - - public void onPipSwapChanged(boolean pipSwap) { - mPipSwap = pipSwap; - notifyOptionChanged(OPTION_PIP_SWAP); - } - - public void onPipSoundChanged(@PipSound int pipSound) { - mPipSound = pipSound; - notifyOptionChanged(OPTION_PIP_SOUND); - } - - public void onPipLayoutChanged(@PipLayout int pipLayout) { - mPipLayout = pipLayout; - notifyOptionChanged(OPTION_PIP_LAYOUT); - } - - public void onPipSizeChanged(@PipSize int pipSize) { - mPipSize = pipSize; - notifyOptionChanged(OPTION_PIP_SIZE); - } - - private void notifyOptionChanged(int option) { + private void notifyOptionChanged(@OptionType int option) { OptionChangedListener listener = mOptionChangedListeners.get(option); if (listener != null) { - listener.onOptionChanged(getOptionString(option)); + listener.onOptionChanged(option, getOptionString(option)); } } + /** + * Sets listeners to changes of the given option type. + */ public void setOptionChangedListener(int option, OptionChangedListener listener) { mOptionChangedListeners.put(option, listener); } @@ -180,6 +121,6 @@ public class TvOptionsManager { * An interface used to monitor option changes. */ public interface OptionChangedListener { - void onOptionChanged(String newOption); + void onOptionChanged(@OptionType int optionType, String newString); } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/config/DefaultConfigManager.java b/src/com/android/tv/config/DefaultConfigManager.java index f5a6e959..bbabc6d4 100644 --- a/src/com/android/tv/config/DefaultConfigManager.java +++ b/src/com/android/tv/config/DefaultConfigManager.java @@ -22,6 +22,7 @@ import android.content.Context; * Stub Remote Config. */ public class DefaultConfigManager { + public static final long DEFAULT_LONG_VALUE = 0; public static DefaultConfigManager createInstance(Context context) { return new DefaultConfigManager(); } @@ -47,6 +48,11 @@ public class DefaultConfigManager { public boolean getBoolean(String key) { return false; } + + @Override + public long getLong(String key) { + return DEFAULT_LONG_VALUE; + } } } diff --git a/src/com/android/tv/config/RemoteConfig.java b/src/com/android/tv/config/RemoteConfig.java index 0f7d2c53..f7ae87e7 100644 --- a/src/com/android/tv/config/RemoteConfig.java +++ b/src/com/android/tv/config/RemoteConfig.java @@ -45,4 +45,7 @@ public interface RemoteConfig { * Gets value as a boolean corresponding to the specified key. */ boolean getBoolean(String key); + + /** Gets value as a long corresponding to the specified key. */ + long getLong(String key); } diff --git a/src/com/android/tv/config/RemoteConfigUtils.java b/src/com/android/tv/config/RemoteConfigUtils.java new file mode 100644 index 00000000..09d85239 --- /dev/null +++ b/src/com/android/tv/config/RemoteConfigUtils.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2017 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 android.util.Log; +import com.android.tv.TvApplication; + +/** A utility class to get the remote config. */ +public class RemoteConfigUtils { + private static final String TAG = "RemoteConfigUtils"; + private static final boolean DEBUG = false; + + private RemoteConfigUtils() {} + + public static long getRemoteConfig(Context context, String key, long defaultValue) { + RemoteConfig remoteConfig = TvApplication.getSingletons(context).getRemoteConfig(); + try { + long remoteValue = remoteConfig.getLong(key); + if (DEBUG) Log.d(TAG, "Got " + key + " from remote: " + remoteValue); + return remoteValue; + } catch (Exception e) { + Log.w(TAG, "Cannot get " + key + " from RemoteConfig", e); + } + if (DEBUG) Log.d(TAG, "Use default value " + defaultValue); + return defaultValue; + } +} diff --git a/src/com/android/tv/customization/TvCustomizationManager.java b/src/com/android/tv/customization/TvCustomizationManager.java index 22298a10..ed6b98ca 100644 --- a/src/com/android/tv/customization/TvCustomizationManager.java +++ b/src/com/android/tv/customization/TvCustomizationManager.java @@ -18,15 +18,18 @@ package com.android.tv.customization; import android.content.Context; import android.content.Intent; -import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.graphics.drawable.Drawable; +import android.support.annotation.IntDef; import android.text.TextUtils; import android.util.Log; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -37,6 +40,10 @@ public class TvCustomizationManager { private static final String TAG = "TvCustomizationManager"; private static final boolean DEBUG = false; + private static final String[] CUSTOMIZE_PERMISSIONS = { + "com.android.tv.permission.CUSTOMIZE_TV_APP" + }; + private static final String CATEGORY_TV_CUSTOMIZATION = "com.android.tv.category"; @@ -47,6 +54,19 @@ public class TvCustomizationManager { public static final String ID_OPTIONS_ROW = "options_row"; public static final String ID_PARTNER_ROW = "partner_row"; + @IntDef({TRICKPLAY_MODE_ENABLED, TRICKPLAY_MODE_DISABLED, TRICKPLAY_MODE_USE_EXTERNAL_STORAGE}) + @Retention(RetentionPolicy.SOURCE) + public @interface TRICKPLAY_MODE {} + public static final int TRICKPLAY_MODE_ENABLED = 0; + public static final int TRICKPLAY_MODE_DISABLED = 1; + public static final int TRICKPLAY_MODE_USE_EXTERNAL_STORAGE = 2; + + private static final String[] TRICKPLAY_MODE_STRINGS = { + "enabled", + "disabled", + "use_external_storage_only" + }; + private static final HashMap<String, String> INTENT_CATEGORY_TO_ROW_ID; static { INTENT_CATEGORY_TO_ROW_ID = new HashMap<>(); @@ -55,12 +75,19 @@ public class TvCustomizationManager { } private static final String RES_ID_PARTNER_ROW_TITLE = "partner_row_title"; + private static final String RES_ID_HAS_LINUX_DVB_BUILT_IN_TUNER = + "has_linux_dvb_built_in_tuner"; + private static final String RES_ID_TRICKPLAY_MODE = "trickplay_mode"; private static final String RES_TYPE_STRING = "string"; + private static final String RES_TYPE_BOOLEAN = "bool"; + + private static String sCustomizationPackage; + private static Boolean sHasLinuxDvbBuiltInTuner; + private static @TRICKPLAY_MODE Integer sTrickplayMode; private final Context mContext; private boolean mInitialized; - private String mCustomizationPackage; private String mPartnerRowTitle; private final Map<String, List<CustomAction>> mRowIdToCustomActionsMap = new HashMap<>(); @@ -71,6 +98,68 @@ public class TvCustomizationManager { } /** + * Returns {@code true} if there's a customization package installed and it specifies built-in + * tuner devices are available. The built-in tuner should support DVB API to be recognized by + * Live TV. + */ + public static boolean hasLinuxDvbBuiltInTuner(Context context) { + if (sHasLinuxDvbBuiltInTuner == null) { + if (TextUtils.isEmpty(getCustomizationPackageName(context))) { + sHasLinuxDvbBuiltInTuner = false; + } else { + try { + Resources res = context.getPackageManager() + .getResourcesForApplication(sCustomizationPackage); + int resId = res.getIdentifier(RES_ID_HAS_LINUX_DVB_BUILT_IN_TUNER, + RES_TYPE_BOOLEAN, sCustomizationPackage); + sHasLinuxDvbBuiltInTuner = resId != 0 && res.getBoolean(resId); + } catch (NameNotFoundException e) { + sHasLinuxDvbBuiltInTuner = false; + } + } + } + return sHasLinuxDvbBuiltInTuner; + } + + public static @TRICKPLAY_MODE int getTrickplayMode(Context context) { + if (sTrickplayMode == null) { + if (TextUtils.isEmpty(getCustomizationPackageName(context))) { + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + } else { + try { + String customization = null; + Resources res = context.getPackageManager() + .getResourcesForApplication(sCustomizationPackage); + int resId = res.getIdentifier(RES_ID_TRICKPLAY_MODE, + RES_TYPE_STRING, sCustomizationPackage); + customization = resId == 0 ? null : res.getString(resId); + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + if (customization != null) { + for (int i = 0; i < TRICKPLAY_MODE_STRINGS.length; ++i) { + if (TRICKPLAY_MODE_STRINGS[i].equalsIgnoreCase(customization)) { + sTrickplayMode = i; + break; + } + } + } + } catch (NameNotFoundException e) { + sTrickplayMode = TRICKPLAY_MODE_ENABLED; + } + } + } + return sTrickplayMode; + } + + private static String getCustomizationPackageName(Context context) { + if (sCustomizationPackage == null) { + List<PackageInfo> packageInfos = context.getPackageManager() + .getPackagesHoldingPermissions(CUSTOMIZE_PERMISSIONS, 0); + sCustomizationPackage = packageInfos.size() == 0 ? "" : packageInfos.get(0).packageName; + } + return sCustomizationPackage; + } + + /** * Initialize TV customization options. * Run this API only on the main thread. */ @@ -79,14 +168,13 @@ public class TvCustomizationManager { return; } mInitialized = true; - buildCustomActions(); - if (!TextUtils.isEmpty(mCustomizationPackage)) { + if (!TextUtils.isEmpty(getCustomizationPackageName(mContext))) { + buildCustomActions(); buildPartnerRow(); } } private void buildCustomActions() { - mCustomizationPackage = null; mRowIdToCustomActionsMap.clear(); PackageManager pm = mContext.getPackageManager(); for (String intentCategory : INTENT_CATEGORY_TO_ROW_ID.keySet()) { @@ -98,16 +186,8 @@ public class TvCustomizationManager { | PackageManager.GET_META_DATA); for (ResolveInfo info : activities) { String packageName = info.activityInfo.packageName; - if (TextUtils.isEmpty(mCustomizationPackage)) { - if (DEBUG) Log.d(TAG, "Found TV customization package " + packageName); - if ((info.activityInfo.applicationInfo.flags - & ApplicationInfo.FLAG_SYSTEM) == 0) { - Log.w(TAG, "Only system app can customize TV. Ignoring " + packageName); - continue; - } - mCustomizationPackage = packageName; - } else if (!packageName.equals(mCustomizationPackage)) { - Log.w(TAG, "A customization package " + mCustomizationPackage + if (!TextUtils.equals(packageName, sCustomizationPackage)) { + Log.w(TAG, "A customization package " + sCustomizationPackage + " already exist. Ignoring " + packageName); continue; } @@ -117,7 +197,7 @@ public class TvCustomizationManager { Drawable drawable = info.loadIcon(pm); Intent intent = new Intent(Intent.ACTION_MAIN); intent.addCategory(intentCategory); - intent.setClassName(mCustomizationPackage, info.activityInfo.name); + intent.setClassName(sCustomizationPackage, info.activityInfo.name); String rowId = INTENT_CATEGORY_TO_ROW_ID.get(intentCategory); List<CustomAction> actions = mRowIdToCustomActionsMap.get(rowId); @@ -159,13 +239,13 @@ public class TvCustomizationManager { Resources res; try { res = mContext.getPackageManager() - .getResourcesForApplication(mCustomizationPackage); + .getResourcesForApplication(sCustomizationPackage); } catch (NameNotFoundException e) { - Log.w(TAG, "Could not get resources for package " + mCustomizationPackage); + Log.w(TAG, "Could not get resources for package " + sCustomizationPackage); return; } int resId = res.getIdentifier( - RES_ID_PARTNER_ROW_TITLE, RES_TYPE_STRING, mCustomizationPackage); + RES_ID_PARTNER_ROW_TITLE, RES_TYPE_STRING, sCustomizationPackage); if (resId != 0) { mPartnerRowTitle = res.getString(resId); } diff --git a/src/com/android/tv/data/BaseProgram.java b/src/com/android/tv/data/BaseProgram.java index f420de02..4e36c80a 100644 --- a/src/com/android/tv/data/BaseProgram.java +++ b/src/com/android/tv/data/BaseProgram.java @@ -17,12 +17,17 @@ package com.android.tv.data; import android.content.Context; +import android.media.tv.TvContentRating; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.tv.R; import java.util.Comparator; /** * Base class for {@link com.android.tv.data.Program} and - * {@link com.android.tv.dvr.RecordedProgram}. + * {@link com.android.tv.dvr.data.RecordedProgram}. */ public abstract class BaseProgram { /** @@ -94,14 +99,29 @@ public abstract class BaseProgram { abstract public String getTitle(); /** - * Returns the program's title withe its season and episode number. + * Returns the episode title. */ - abstract public String getTitleWithEpisodeNumber(Context context); + abstract public String getEpisodeTitle(); /** * Returns the displayed title of the program episode. */ - abstract public String getEpisodeDisplayTitle(Context context); + public String getEpisodeDisplayTitle(Context context) { + if (!TextUtils.isEmpty(getEpisodeNumber())) { + String episodeTitle = getEpisodeTitle() == null ? "" : getEpisodeTitle(); + if (TextUtils.equals(getSeasonNumber(), "0")) { + // Do not show "S0: ". + return String.format(context.getResources().getString( + R.string.display_episode_title_format_no_season_number), + getEpisodeNumber(), episodeTitle); + } else { + return String.format(context.getResources().getString( + R.string.display_episode_title_format), + getSeasonNumber(), getEpisodeNumber(), episodeTitle); + } + } + return getEpisodeTitle(); + } /** * Returns the description of the program. @@ -158,6 +178,10 @@ public abstract class BaseProgram { */ abstract public int[] getCanonicalGenreIds(); + /** Returns the array of content ratings. */ + @Nullable + abstract public TvContentRating[] getContentRatings(); + /** * Returns channel's ID of the program. */ @@ -169,6 +193,13 @@ public abstract class BaseProgram { abstract public boolean isValid(); /** + * Checks whether the program is episodic or not. + */ + public boolean isEpisodic() { + return getSeriesId() != null; + } + + /** * Generates the series ID for the other inputs than the tuner TV input. */ public static String generateSeriesId(String packageName, String title) { diff --git a/src/com/android/tv/data/Channel.java b/src/com/android/tv/data/Channel.java index 30f84236..4a391ae7 100644 --- a/src/com/android/tv/data/Channel.java +++ b/src/com/android/tv/data/Channel.java @@ -52,6 +52,16 @@ public final class Channel { public static final int LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART = 3; /** + * Compares the channel numbers of channels which belong to the same input. + */ + public static final Comparator<Channel> CHANNEL_NUMBER_COMPARATOR = new Comparator<Channel>() { + @Override + public int compare(Channel lhs, Channel rhs) { + return ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber()); + } + }; + + /** * When a TIS doesn't provide any information about app link, and it doesn't have a leanback * launch intent, there will be no app link card for the TIS. */ @@ -81,15 +91,22 @@ public final class Channel { TvContract.Channels.COLUMN_DESCRIPTION, TvContract.Channels.COLUMN_VIDEO_FORMAT, TvContract.Channels.COLUMN_BROWSABLE, + TvContract.Channels.COLUMN_SEARCHABLE, TvContract.Channels.COLUMN_LOCKED, TvContract.Channels.COLUMN_APP_LINK_TEXT, TvContract.Channels.COLUMN_APP_LINK_COLOR, TvContract.Channels.COLUMN_APP_LINK_ICON_URI, TvContract.Channels.COLUMN_APP_LINK_POSTER_ART_URI, TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, + TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2, // Only used in bundled input }; /** + * Channel number delimiter between major and minor parts. + */ + public static final char CHANNEL_NUMBER_DELIMITER = '-'; + + /** * Creates {@code Channel} object from cursor. * * <p>The query that created the cursor MUST use {@link #PROJECTION} @@ -103,28 +120,41 @@ public final class Channel { channel.mPackageName = Utils.intern(cursor.getString(index++)); channel.mInputId = Utils.intern(cursor.getString(index++)); channel.mType = Utils.intern(cursor.getString(index++)); - channel.mDisplayNumber = cursor.getString(index++); + channel.mDisplayNumber = normalizeDisplayNumber(cursor.getString(index++)); channel.mDisplayName = cursor.getString(index++); channel.mDescription = cursor.getString(index++); channel.mVideoFormat = Utils.intern(cursor.getString(index++)); channel.mBrowsable = cursor.getInt(index++) == 1; + channel.mSearchable = cursor.getInt(index++) == 1; channel.mLocked = cursor.getInt(index++) == 1; 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++); + if (Utils.isBundledInput(channel.mInputId)) { + channel.mRecordingProhibited = cursor.getInt(index++) != 0; + } return channel; } /** - * Creates a {@link Channel} object from the DVR database. + * Replaces the channel number separator with dash('-'). */ - public static Channel fromDvrCursor(Cursor c) { - Channel channel = new Channel(); - int index = -1; - channel.mDvrId = c.getLong(++index); - return channel; + public static String normalizeDisplayNumber(String string) { + if (!TextUtils.isEmpty(string)) { + int length = string.length(); + for (int i = 0; i < length; i++) { + char c = string.charAt(i); + if (c == '.' || Character.isWhitespace(c) + || Character.getType(c) == Character.DASH_PUNCTUATION) { + StringBuilder sb = new StringBuilder(string); + sb.setCharAt(i, CHANNEL_NUMBER_DELIMITER); + return sb.toString(); + } + } + } + return string; } /** ID of this channel. Matches to BaseColumns._ID. */ @@ -138,6 +168,7 @@ public final class Channel { private String mDescription; private String mVideoFormat; private boolean mBrowsable; + private boolean mSearchable; private boolean mLocked; private boolean mIsPassthrough; private String mAppLinkText; @@ -147,8 +178,10 @@ public final class Channel { private String mAppLinkIntentUri; private Intent mAppLinkIntent; private int mAppLinkType; + private String mLogoUri; + private boolean mRecordingProhibited; - private long mDvrId; + private boolean mChannelLogoExist; private Channel() { // Do nothing. @@ -187,7 +220,6 @@ public final class Channel { return mDisplayName; } - @VisibleForTesting public String getDescription() { return mDescription; } @@ -230,10 +262,14 @@ public final class Channel { } /** - * Returns an ID in DVR database. + * Returns channel logo uri which is got from cloud, it's used only for ChannelLogoFetcher. */ - public long getDvrId() { - return mDvrId; + public String getLogoUri() { + return mLogoUri; + } + + public boolean isRecordingProhibited() { + return mRecordingProhibited; } /** @@ -266,6 +302,11 @@ public final class Channel { return mBrowsable; } + /** Checks whether this channel is searchable or not. */ + public boolean isSearchable() { + return mSearchable; + } + public boolean isLocked() { return mLocked; } @@ -279,6 +320,13 @@ public final class Channel { } /** + * Sets channel logo uri which is got from cloud. + */ + public void setLogoUri(String logoUri) { + mLogoUri = logoUri; + } + + /** * Check whether {@code other} has same read-only channel info as this. But, it cannot check two * channels have same logos. It also excludes browsable and locked, because two fields are * changed by TV app. @@ -298,7 +346,8 @@ public final class Channel { && mAppLinkColor == other.mAppLinkColor && Objects.equals(mAppLinkIconUri, other.mAppLinkIconUri) && Objects.equals(mAppLinkPosterArtUri, other.mAppLinkPosterArtUri) - && Objects.equals(mAppLinkIntentUri, other.mAppLinkIntentUri); + && Objects.equals(mAppLinkIntentUri, other.mAppLinkIntentUri) + && Objects.equals(mRecordingProhibited, other.mRecordingProhibited); } @Override @@ -314,8 +363,10 @@ public final class Channel { + ", videoFormat=" + mVideoFormat + ", isPassthrough=" + mIsPassthrough + ", browsable=" + mBrowsable + + ", searchable=" + mSearchable + ", locked=" + mLocked - + ", appLinkText=" + mAppLinkText + "}"; + + ", appLinkText=" + mAppLinkText + + ", recordingProhibited=" + mRecordingProhibited + "}"; } void copyFrom(Channel other) { @@ -332,6 +383,7 @@ public final class Channel { mVideoFormat = other.mVideoFormat; mIsPassthrough = other.mIsPassthrough; mBrowsable = other.mBrowsable; + mSearchable = other.mSearchable; mLocked = other.mLocked; mAppLinkText = other.mAppLinkText; mAppLinkColor = other.mAppLinkColor; @@ -340,6 +392,8 @@ public final class Channel { mAppLinkIntentUri = other.mAppLinkIntentUri; mAppLinkIntent = other.mAppLinkIntent; mAppLinkType = other.mAppLinkType; + mRecordingProhibited = other.mRecordingProhibited; + mChannelLogoExist = other.mChannelLogoExist; } /** @@ -389,8 +443,7 @@ public final class Channel { mChannel.mDisplayName = "name"; mChannel.mDescription = "description"; mChannel.mBrowsable = true; - mChannel.mLocked = false; - mChannel.mIsPassthrough = false; + mChannel.mSearchable = true; } public Builder(Channel other) { @@ -422,7 +475,7 @@ public final class Channel { @VisibleForTesting public Builder setDisplayNumber(String displayNumber) { - mChannel.mDisplayNumber = displayNumber; + mChannel.mDisplayNumber = normalizeDisplayNumber(displayNumber); return this; } @@ -448,6 +501,11 @@ public final class Channel { return this; } + public Builder setSearchable(boolean searchable) { + mChannel.mSearchable = searchable; + return this; + } + public Builder setLocked(boolean locked) { mChannel.mLocked = locked; return this; @@ -485,6 +543,11 @@ public final class Channel { return this; } + public Builder setRecordingProhibited(boolean recordingProhibited) { + mChannel.mRecordingProhibited = recordingProhibited; + return this; + } + public Channel build() { Channel channel = new Channel(); channel.copyFrom(mChannel); @@ -524,6 +587,21 @@ public final class Channel { } /** + * Sets if the channel logo exists. This method should be only called from + * {@link ChannelDataManager}. + */ + void setChannelLogoExist(boolean exist) { + mChannelLogoExist = exist; + } + + /** + * Returns if channel logo exists. + */ + public boolean channelLogoExists() { + return mChannelLogoExist; + } + + /** * Returns the type of app link for this channel. * It returns {@link #APP_LINK_TYPE_CHANNEL} if the channel has a non null app link text and * a valid app link intent, it returns {@link #APP_LINK_TYPE_APP} if the input service which @@ -655,4 +733,4 @@ public final class Channel { return label; } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/data/ChannelDataManager.java b/src/com/android/tv/data/ChannelDataManager.java index 6f9ea6d7..6f93fbd1 100644 --- a/src/com/android/tv/data/ChannelDataManager.java +++ b/src/com/android/tv/data/ChannelDataManager.java @@ -21,13 +21,17 @@ import android.content.ContentValues; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; +import android.content.res.AssetFileDescriptor; import android.database.ContentObserver; +import android.database.sqlite.SQLiteException; import android.media.tv.TvContract; import android.media.tv.TvContract.Channels; import android.media.tv.TvInputManager.TvInputCallback; +import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; @@ -43,6 +47,7 @@ import com.android.tv.util.PermissionUtils; 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.HashMap; @@ -59,7 +64,7 @@ import java.util.concurrent.CopyOnWriteArraySet; * This class is not thread-safe and under an assumption that its public methods are called in * only the main thread. */ -@MainThread +@AnyThread public class ChannelDataManager { private static final String TAG = "ChannelDataManager"; private static final boolean DEBUG = false; @@ -74,10 +79,10 @@ public class ChannelDataManager { private final List<Runnable> mPostRunnablesAfterChannelUpdate = new ArrayList<>(); private final Set<Listener> mListeners = new CopyOnWriteArraySet<>(); - private final Map<Long, ChannelWrapper> mChannelWrapperMap = new HashMap<>(); - private final Map<String, MutableInt> mChannelCountMap = new HashMap<>(); + // Use container class to support multi-thread safety. This value can be set only on the main + // thread. + volatile private UnmodifiableChannelData mData = new UnmodifiableChannelData(); private final Channel.DefaultComparator mChannelComparator; - private final List<Channel> mChannels = new ArrayList<>(); private final Handler mHandler; private final Set<Long> mBrowsableUpdateChannelIds = new HashSet<>(); @@ -92,15 +97,17 @@ public class ChannelDataManager { @Override public void onInputAdded(String inputId) { boolean channelAdded = false; - for (ChannelWrapper channel : mChannelWrapperMap.values()) { + ChannelData data = new ChannelData(mData); + for (ChannelWrapper channel : mData.channelWrapperMap.values()) { if (channel.mChannel.getInputId().equals(inputId)) { channel.mInputRemoved = false; - addChannel(channel.mChannel); + addChannel(data, channel.mChannel); channelAdded = true; } } if (channelAdded) { - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); notifyChannelListUpdated(); } } @@ -109,7 +116,7 @@ public class ChannelDataManager { public void onInputRemoved(String inputId) { boolean channelRemoved = false; ArrayList<ChannelWrapper> removedChannels = new ArrayList<>(); - for (ChannelWrapper channel : mChannelWrapperMap.values()) { + for (ChannelWrapper channel : mData.channelWrapperMap.values()) { if (channel.mChannel.getInputId().equals(inputId)) { channel.mInputRemoved = true; channelRemoved = true; @@ -117,13 +124,15 @@ public class ChannelDataManager { } } if (channelRemoved) { - clearChannels(); - for (ChannelWrapper channelWrapper : mChannelWrapperMap.values()) { + ChannelData data = new ChannelData(); + data.channelWrapperMap.putAll(mData.channelWrapperMap); + for (ChannelWrapper channelWrapper : data.channelWrapperMap.values()) { if (!channelWrapper.mInputRemoved) { - addChannel(channelWrapper.mChannel); + addChannel(data, channelWrapper.mChannel); } } - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); notifyChannelListUpdated(); for (ChannelWrapper channel : removedChannels) { channel.notifyChannelRemoved(); @@ -132,10 +141,12 @@ public class ChannelDataManager { } }; + @MainThread public ChannelDataManager(Context context, TvInputManagerHelper inputManager) { this(context, inputManager, context.getContentResolver()); } + @MainThread @VisibleForTesting ChannelDataManager(Context context, TvInputManagerHelper inputManager, ContentResolver contentResolver) { @@ -167,6 +178,7 @@ public class ChannelDataManager { /** * Starts the manager. If data is ready, {@link Listener#onLoadFinished()} will be called. */ + @MainThread public void start() { if (mStarted) { return; @@ -184,6 +196,7 @@ public class ChannelDataManager { * Stops the manager. It clears manager states and runs pending DB operations. Added listeners * aren't automatically removed by this method. */ + @MainThread @VisibleForTesting public void stop() { if (!mStarted) { @@ -192,12 +205,10 @@ public class ChannelDataManager { mStarted = false; mDbLoadFinished = false; - ChannelLogoFetcher.stopFetchingChannelLogos(); mInputManager.removeCallback(mTvInputCallback); mContentResolver.unregisterContentObserver(mChannelObserver); mHandler.removeCallbacksAndMessages(null); - mChannelWrapperMap.clear(); clearChannels(); mPostRunnablesAfterChannelUpdate.clear(); if (mChannelsUpdateTask != null) { @@ -233,7 +244,7 @@ public class ChannelDataManager { * Adds a {@link ChannelListener} for a specific channel with the channel ID {@code channelId}. */ public void addChannelListener(Long channelId, ChannelListener listener) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -245,7 +256,7 @@ public class ChannelDataManager { * {@code channelId}. */ public void removeChannelListener(Long channelId, ChannelListener listener) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -263,14 +274,14 @@ public class ChannelDataManager { * Returns the number of channels. */ public int getChannelCount() { - return mChannels.size(); + return mData.channels.size(); } /** * Returns a list of channels. */ public List<Channel> getChannelList() { - return Collections.unmodifiableList(mChannels); + return new ArrayList<>(mData.channels); } /** @@ -278,7 +289,7 @@ public class ChannelDataManager { */ public List<Channel> getBrowsableChannelList() { List<Channel> channels = new ArrayList<>(); - for (Channel channel : mChannels) { + for (Channel channel : mData.channels) { if (channel.isBrowsable()) { channels.add(channel); } @@ -292,7 +303,7 @@ public class ChannelDataManager { * @param inputId The ID of the input. */ public int getChannelCountForInput(String inputId) { - MutableInt count = mChannelCountMap.get(inputId); + MutableInt count = mData.channelCountMap.get(inputId); return count == null ? 0 : count.value; } @@ -303,17 +314,14 @@ public class ChannelDataManager { * 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; + return mData.channelWrapperMap.get(channelId) != null; } /** * Returns true if and only if there exists at least one channel and all channels are hidden. */ public boolean areAllChannelsHidden() { - if (mChannels.isEmpty()) { - return false; - } - for (Channel channel : mChannels) { + for (Channel channel : mData.channels) { if (channel.isBrowsable()) { return false; } @@ -325,7 +333,7 @@ public class ChannelDataManager { * Gets the channel with the channel ID {@code channelId}. */ public Channel getChannel(Long channelId) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null || channelWrapper.mInputRemoved) { return null; } @@ -349,7 +357,7 @@ public class ChannelDataManager { */ public void updateBrowsable(Long channelId, boolean browsable, boolean skipNotifyChannelBrowsableChanged) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -407,7 +415,7 @@ public class ChannelDataManager { * The value change will be applied to DB when applyPendingDbOperation is called. */ public void updateLocked(Long channelId, boolean locked) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(channelId); + ChannelWrapper channelWrapper = mData.channelWrapperMap.get(channelId); if (channelWrapper == null) { return; } @@ -427,10 +435,11 @@ public class ChannelDataManager { * to DB. */ public void applyUpdatedValuesToDb() { + ChannelData data = mData; ArrayList<Long> browsableIds = new ArrayList<>(); ArrayList<Long> unbrowsableIds = new ArrayList<>(); for (Long id : mBrowsableUpdateChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.get(id); if (channelWrapper == null) { continue; } @@ -452,10 +461,10 @@ public class ChannelDataManager { } editor.apply(); } else { - if (browsableIds.size() != 0) { + if (!browsableIds.isEmpty()) { updateOneColumnValue(column, 1, browsableIds); } - if (unbrowsableIds.size() != 0) { + if (!unbrowsableIds.isEmpty()) { updateOneColumnValue(column, 0, unbrowsableIds); } } @@ -464,7 +473,7 @@ public class ChannelDataManager { ArrayList<Long> lockedIds = new ArrayList<>(); ArrayList<Long> unlockedIds = new ArrayList<>(); for (Long id : mLockedUpdateChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.get(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.get(id); if (channelWrapper == null) { continue; } @@ -476,10 +485,10 @@ public class ChannelDataManager { channelWrapper.mLockedInDb = channelWrapper.mChannel.isLocked(); } column = TvContract.Channels.COLUMN_LOCKED; - if (lockedIds.size() != 0) { + if (!lockedIds.isEmpty()) { updateOneColumnValue(column, 1, lockedIds); } - if (unlockedIds.size() != 0) { + if (!unlockedIds.isEmpty()) { updateOneColumnValue(column, 0, unlockedIds); } mLockedUpdateChannelIds.clear(); @@ -492,22 +501,24 @@ public class ChannelDataManager { } } - private void addChannel(Channel channel) { - mChannels.add(channel); + @MainThread + private void addChannel(ChannelData data, Channel channel) { + data.channels.add(channel); String inputId = channel.getInputId(); - MutableInt count = mChannelCountMap.get(inputId); + MutableInt count = data.channelCountMap.get(inputId); if (count == null) { - mChannelCountMap.put(inputId, new MutableInt(1)); + data.channelCountMap.put(inputId, new MutableInt(1)); } else { count.value++; } } + @MainThread private void clearChannels() { - mChannels.clear(); - mChannelCountMap.clear(); + mData = new UnmodifiableChannelData(); } + @MainThread private void handleUpdateChannels() { if (mChannelsUpdateTask != null) { mChannelsUpdateTask.cancel(true); @@ -525,6 +536,9 @@ public class ChannelDataManager { } } + /** + * A listener for ChannelDataManager. The callbacks are called on the main thread. + */ public interface Listener { /** * Called when data load is finished. @@ -543,6 +557,9 @@ public class ChannelDataManager { void onChannelBrowsableChanged(); } + /** + * A listener for individual channel change. The callbacks are called on the main thread. + */ public interface ChannelListener { /** * Called when the channel has been removed in DB. @@ -590,9 +607,36 @@ public class ChannelDataManager { } } + private class CheckChannelLogoExistTask extends AsyncTask<Void, Void, Boolean> { + private final Channel mChannel; + + CheckChannelLogoExistTask(Channel channel) { + mChannel = channel; + } + + @Override + protected Boolean doInBackground(Void... params) { + try (AssetFileDescriptor f = mContext.getContentResolver().openAssetFileDescriptor( + TvContract.buildChannelLogoUri(mChannel.getId()), "r")) { + return true; + } catch (SQLiteException | IOException | NullPointerException e) { + // File not found or asset file not found. + } + return false; + } + + @Override + protected void onPostExecute(Boolean result) { + ChannelWrapper wrapper = mData.channelWrapperMap.get(mChannel.getId()); + if (wrapper != null) { + wrapper.mChannel.setChannelLogoExist(result); + } + } + } + private final class QueryAllChannelsTask extends AsyncDbTask.AsyncChannelQueryTask { - public QueryAllChannelsTask(ContentResolver contentResolver) { + QueryAllChannelsTask(ContentResolver contentResolver) { super(contentResolver); } @@ -603,7 +647,9 @@ public class ChannelDataManager { if (DEBUG) Log.e(TAG, "onPostExecute with null channels"); return; } - Set<Long> removedChannelIds = new HashSet<>(mChannelWrapperMap.keySet()); + ChannelData data = new ChannelData(); + data.channelWrapperMap.putAll(mData.channelWrapperMap); + Set<Long> removedChannelIds = new HashSet<>(data.channelWrapperMap.keySet()); List<ChannelWrapper> removedChannelWrappers = new ArrayList<>(); List<ChannelWrapper> updatedChannelWrappers = new ArrayList<>(); @@ -625,13 +671,15 @@ public class ChannelDataManager { boolean newlyAdded = !removedChannelIds.remove(channelId); ChannelWrapper channelWrapper; if (newlyAdded) { + new CheckChannelLogoExistTask(channel) + .executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); channelWrapper = new ChannelWrapper(channel); - mChannelWrapperMap.put(channel.getId(), channelWrapper); + data.channelWrapperMap.put(channel.getId(), channelWrapper); if (!channelWrapper.mInputRemoved) { channelAdded = true; } } else { - channelWrapper = mChannelWrapperMap.get(channelId); + channelWrapper = data.channelWrapperMap.get(channelId); if (!channelWrapper.mChannel.hasSameReadOnlyInfo(channel)) { // Channel data updated Channel oldChannel = channelWrapper.mChannel; @@ -640,9 +688,9 @@ public class ChannelDataManager { // {@link #applyUpdatedValuesToDb} is called. Therefore, the value // between DB and ChannelDataManager could be different for a while. // Therefore, we'll keep the values in ChannelDataManager. - channelWrapper.mChannel.copyFrom(channel); channel.setBrowsable(oldChannel.isBrowsable()); channel.setLocked(oldChannel.isLocked()); + channelWrapper.mChannel.copyFrom(channel); if (!channelWrapper.mInputRemoved) { channelUpdated = true; updatedChannelWrappers.add(channelWrapper); @@ -663,19 +711,19 @@ public class ChannelDataManager { } for (long id : removedChannelIds) { - ChannelWrapper channelWrapper = mChannelWrapperMap.remove(id); + ChannelWrapper channelWrapper = data.channelWrapperMap.remove(id); if (!channelWrapper.mInputRemoved) { channelRemoved = true; removedChannelWrappers.add(channelWrapper); } } - clearChannels(); - for (ChannelWrapper channelWrapper : mChannelWrapperMap.values()) { + for (ChannelWrapper channelWrapper : data.channelWrapperMap.values()) { if (!channelWrapper.mInputRemoved) { - addChannel(channelWrapper.mChannel); + addChannel(data, channelWrapper.mChannel); } } - Collections.sort(mChannels, mChannelComparator); + Collections.sort(data.channels, mChannelComparator); + mData = new UnmodifiableChannelData(data); if (!mDbLoadFinished) { mDbLoadFinished = true; @@ -693,7 +741,6 @@ public class ChannelDataManager { r.run(); } mPostRunnablesAfterChannelUpdate.clear(); - ChannelLogoFetcher.startFetchingChannelLogos(mContext); } } @@ -705,10 +752,9 @@ public class ChannelDataManager { private void updateOneColumnValue( final String columnName, final int columnValue, final List<Long> ids) { if (!PermissionUtils.hasAccessAllEpg(mContext)) { - // TODO: support this feature for non-system LC app. b/23939816 return; } - AsyncDbTask.execute(new Runnable() { + AsyncDbTask.executeOnDbThread(new Runnable() { @Override public void run() { String selection = Utils.buildSelectionForIds(Channels._ID, ids); @@ -723,6 +769,7 @@ public class ChannelDataManager { return channel.getInputId() + "|" + channel.getId(); } + @MainThread private static class ChannelDataManagerHandler extends WeakHandler<ChannelDataManager> { public ChannelDataManagerHandler(ChannelDataManager channelDataManager) { super(Looper.getMainLooper(), channelDataManager); @@ -735,4 +782,51 @@ public class ChannelDataManager { } } } + + /** + * Container class which includes channel data that needs to be synced. This class is + * modifiable and used for changing channel data. + * e.g. TvInputCallback, or AsyncDbTask.onPostExecute. + */ + @MainThread + private static class ChannelData { + final Map<Long, ChannelWrapper> channelWrapperMap; + final Map<String, MutableInt> channelCountMap; + final List<Channel> channels; + + ChannelData() { + channelWrapperMap = new HashMap<>(); + channelCountMap = new HashMap<>(); + channels = new ArrayList<>(); + } + + ChannelData(ChannelData data) { + channelWrapperMap = new HashMap<>(data.channelWrapperMap); + channelCountMap = new HashMap<>(data.channelCountMap); + channels = new ArrayList<>(data.channels); + } + + ChannelData(Map<Long, ChannelWrapper> channelWrapperMap, + Map<String, MutableInt> channelCountMap, List<Channel> channels) { + this.channelWrapperMap = channelWrapperMap; + this.channelCountMap = channelCountMap; + this.channels = channels; + } + } + + /** Unmodifiable channel data. */ + @MainThread + private static class UnmodifiableChannelData extends ChannelData { + UnmodifiableChannelData() { + super(Collections.unmodifiableMap(new HashMap<>()), + Collections.unmodifiableMap(new HashMap<>()), + Collections.unmodifiableList(new ArrayList<>())); + } + + UnmodifiableChannelData(ChannelData data) { + super(Collections.unmodifiableMap(data.channelWrapperMap), + Collections.unmodifiableMap(data.channelCountMap), + Collections.unmodifiableList(data.channels)); + } + } } diff --git a/src/com/android/tv/data/ChannelLogoFetcher.java b/src/com/android/tv/data/ChannelLogoFetcher.java index 5a549f83..132cab7a 100644 --- a/src/com/android/tv/data/ChannelLogoFetcher.java +++ b/src/com/android/tv/data/ChannelLogoFetcher.java @@ -16,160 +16,74 @@ package com.android.tv.data; +import android.content.ContentProviderOperation; import android.content.Context; -import android.database.Cursor; +import android.content.OperationApplicationException; +import android.content.SharedPreferences; import android.graphics.Bitmap.CompressFormat; import android.media.tv.TvContract; -import android.media.tv.TvContract.Channels; import android.net.Uri; import android.os.AsyncTask; -import android.support.annotation.WorkerThread; +import android.os.RemoteException; +import android.support.annotation.MainThread; import android.text.TextUtils; import android.util.Log; -import com.android.tv.util.AsyncDbTask; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.util.BitmapUtils; import com.android.tv.util.BitmapUtils.ScaledBitmapInfo; import com.android.tv.util.PermissionUtils; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; import java.io.OutputStream; import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; +import java.util.List; /** - * Utility class for TMS data. - * This class is thread safe. + * Fetches channel logos from the cloud into the database. It's for the channels which have no logos + * or need update logos. This class is thread safe. */ public class ChannelLogoFetcher { private static final String TAG = "ChannelLogoFetcher"; private static final boolean DEBUG = false; - /** - * The name of the file which contains the TMS data. - * The file has multiple records and each of them is a string separated by '|' like - * STATION_NAME|SHORT_NAME|CALL_SIGN|LOGO_URI. - */ - private static final String TMS_US_TABLE_FILE = "tms_us.table"; - private static final String TMS_KR_TABLE_FILE = "tms_kr.table"; - private static final String FIELD_SEPARATOR = "\\|"; - private static final String NAME_SEPARATOR_FOR_TMS = "\\(|\\)|\\{|\\}|\\[|\\]"; - private static final String NAME_SEPARATOR_FOR_DB = "\\W"; - private static final int INDEX_NAME = 0; - private static final int INDEX_SHORT_NAME = 1; - private static final int INDEX_CALL_SIGN = 2; - private static final int INDEX_LOGO_URI = 3; - - private static final String COLUMN_CHANNEL_LOGO = "logo"; + private static final String PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO = + "is_first_time_fetch_channel_logo"; - private static final Object sLock = new Object(); - private static final Set<Long> sChannelIdBlackListSet = new HashSet<>(); - private static LoadChannelTask sQueryTask; private static FetchLogoTask sFetchTask; /** - * Fetch the channel logos from TMS data and insert them into TvProvider. + * Fetches the channel logos from the cloud data and insert them into TvProvider. * The previous task is canceled and a new task starts. */ - public static void startFetchingChannelLogos(Context context) { + @MainThread + public static void startFetchingChannelLogos( + Context context, List<Channel> channels) { if (!PermissionUtils.hasAccessAllEpg(context)) { // TODO: support this feature for non-system LC app. b/23939816 return; } - synchronized (sLock) { - stopFetchingChannelLogos(); - if (DEBUG) Log.d(TAG, "Request to start fetching logos."); - sQueryTask = new LoadChannelTask(context); - sQueryTask.executeOnDbThread(); + if (sFetchTask != null) { + sFetchTask.cancel(true); + sFetchTask = null; } - } - - /** - * Stops the current fetching tasks. This can be called when the Activity pauses. - */ - public static void stopFetchingChannelLogos() { - synchronized (sLock) { - if (DEBUG) Log.d(TAG, "Request to stop fetching logos."); - if (sQueryTask != null) { - sQueryTask.cancel(true); - sQueryTask = null; - } - if (sFetchTask != null) { - sFetchTask.cancel(true); - sFetchTask = null; - } + if (DEBUG) Log.d(TAG, "Request to start fetching logos."); + if (channels == null || channels.isEmpty()) { + return; } + sFetchTask = new FetchLogoTask(context.getApplicationContext(), channels); + sFetchTask.execute(); } private ChannelLogoFetcher() { } - private static final class LoadChannelTask extends AsyncDbTask<Void, Void, List<Channel>> { - private final Context mContext; - - public LoadChannelTask(Context context) { - mContext = context; - } - - @Override - protected List<Channel> doInBackground(Void... arg) { - // Load channels which doesn't have channel logos. - if (DEBUG) Log.d(TAG, "Starts loading the channels from DB"); - String[] projection = - new String[] { Channels._ID, Channels.COLUMN_DISPLAY_NAME }; - String selection = COLUMN_CHANNEL_LOGO + " IS NULL AND " - + Channels.COLUMN_PACKAGE_NAME + "=?"; - String[] selectionArgs = new String[] { mContext.getPackageName() }; - try (Cursor c = mContext.getContentResolver().query(Channels.CONTENT_URI, - projection, selection, selectionArgs, null)) { - if (c == null) { - Log.e(TAG, "Query returns null cursor", new RuntimeException()); - return null; - } - List<Channel> channels = new ArrayList<>(); - while (!isCancelled() && c.moveToNext()) { - long channelId = c.getLong(0); - if (sChannelIdBlackListSet.contains(channelId)) { - continue; - } - channels.add(new Channel.Builder().setId(c.getLong(0)) - .setDisplayName(c.getString(1).toUpperCase(Locale.getDefault())) - .build()); - } - return channels; - } - } - - @Override - protected void onPostExecute(List<Channel> channels) { - synchronized (sLock) { - if (DEBUG) { - int count = channels == null ? 0 : channels.size(); - Log.d(TAG, count + " channels are loaded"); - } - if (sQueryTask == this) { - sQueryTask = null; - if (channels != null && !channels.isEmpty()) { - sFetchTask = new FetchLogoTask(mContext, channels); - sFetchTask.execute(); - } - } - } - } - } - private static final class FetchLogoTask extends AsyncTask<Void, Void, Void> { private final Context mContext; private final List<Channel> mChannels; - public FetchLogoTask(Context context, List<Channel> channels) { + private FetchLogoTask(Context context, List<Channel> channels) { mContext = context; mChannels = channels; } @@ -180,83 +94,53 @@ public class ChannelLogoFetcher { if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); return null; } - // Load the TMS table data. - if (DEBUG) Log.d(TAG, "Loads TMS data"); - Map<String, String> channelNameLogoUriMap = new HashMap<>(); - try { - channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_US_TABLE_FILE)); - if (isCancelled()) { - if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); - return null; + List<Channel> channelsToUpdate = new ArrayList<>(); + List<Channel> channelsToRemove = new ArrayList<>(); + // Updates or removes the logo by comparing the logo uri which is got from the cloud + // and the stored one. And we assume that the data got form the cloud is 100% + // correct and completed. + SharedPreferences sharedPreferences = + mContext.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_CHANNEL_LOGO_URIS, + Context.MODE_PRIVATE); + SharedPreferences.Editor sharedPreferencesEditor = sharedPreferences.edit(); + Map<String, ?> uncheckedChannels = sharedPreferences.getAll(); + boolean isFirstTimeFetchChannelLogo = sharedPreferences.getBoolean( + PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO, true); + // Iterating channels. + for (Channel channel : mChannels) { + String channelIdString = Long.toString(channel.getId()); + String storedChannelLogoUri = (String) uncheckedChannels.remove(channelIdString); + if (!TextUtils.isEmpty(channel.getLogoUri()) + && !TextUtils.equals(storedChannelLogoUri, channel.getLogoUri())) { + channelsToUpdate.add(channel); + sharedPreferencesEditor.putString(channelIdString, channel.getLogoUri()); + } else if (TextUtils.isEmpty(channel.getLogoUri()) + && (!TextUtils.isEmpty(storedChannelLogoUri) + || isFirstTimeFetchChannelLogo)) { + channelsToRemove.add(channel); + sharedPreferencesEditor.remove(channelIdString); } - channelNameLogoUriMap.putAll(readTmsFile(mContext, TMS_KR_TABLE_FILE)); - } catch (IOException e) { - Log.e(TAG, "Loading TMS data failed.", e); - return null; } - if (isCancelled()) { - if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); - return null; + + // Removes non existing channels from SharedPreferences. + for (String channelId : uncheckedChannels.keySet()) { + sharedPreferencesEditor.remove(channelId); } - // Iterating channels. - for (Channel channel : mChannels) { + // Updates channel logos. + for (Channel channel : channelsToUpdate) { if (isCancelled()) { if (DEBUG) Log.d(TAG, "Fetching the channel logos has been canceled"); return null; } - // Download the channel logo. - if (TextUtils.isEmpty(channel.getDisplayName())) { - if (DEBUG) { - Log.d(TAG, "The channel with ID (" + channel.getId() - + ") doesn't have the display name."); - } - sChannelIdBlackListSet.add(channel.getId()); - continue; - } - String channelName = channel.getDisplayName().trim(); - String logoUri = channelNameLogoUriMap.get(channelName); - if (TextUtils.isEmpty(logoUri)) { - if (DEBUG) { - Log.d(TAG, "Can't find a logo URI for channel '" + channelName + "'"); - } - // Find the candidate names. If the channel name is CNN-HD, then find CNNHD - // and CNN. Or if the channel name is KQED+, then find KQED. - String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_DB); - if (splitNames.length > 1) { - StringBuilder sb = new StringBuilder(); - for (String splitName : splitNames) { - sb.append(splitName); - } - logoUri = channelNameLogoUriMap.get(sb.toString()); - if (DEBUG) { - if (TextUtils.isEmpty(logoUri)) { - Log.d(TAG, "Can't find a logo URI for channel '" + sb.toString() - + "'"); - } - } - } - if (TextUtils.isEmpty(logoUri) - && splitNames[0].length() != channelName.length()) { - logoUri = channelNameLogoUriMap.get(splitNames[0]); - if (DEBUG) { - if (TextUtils.isEmpty(logoUri)) { - Log.d(TAG, "Can't find a logo URI for channel '" + splitNames[0] - + "'"); - } - } - } - } - if (TextUtils.isEmpty(logoUri)) { - sChannelIdBlackListSet.add(channel.getId()); - continue; - } + // Downloads the channel logo. + String logoUri = channel.getLogoUri(); ScaledBitmapInfo bitmapInfo = BitmapUtils.decodeSampledBitmapFromUriString( mContext, logoUri, Integer.MAX_VALUE, Integer.MAX_VALUE); if (bitmapInfo == null) { Log.e(TAG, "Failed to load bitmap. {channelName=" + channel.getDisplayName() + ", " + "logoUri=" + logoUri + "}"); - sChannelIdBlackListSet.add(channel.getId()); continue; } if (isCancelled()) { @@ -264,12 +148,15 @@ public class ChannelLogoFetcher { return null; } - // Insert the logo to DB. + // Inserts the logo to DB. Uri dstLogoUri = TvContract.buildChannelLogoUri(channel.getId()); try (OutputStream os = mContext.getContentResolver().openOutputStream(dstLogoUri)) { bitmapInfo.bitmap.compress(CompressFormat.PNG, 100, os); } catch (IOException e) { Log.e(TAG, "Failed to write " + logoUri + " to " + dstLogoUri, e); + // Removes it from the shared preference for the failed channels to make it + // retry next time. + sharedPreferencesEditor.remove(Long.toString(channel.getId())); continue; } if (DEBUG) { @@ -277,63 +164,35 @@ public class ChannelLogoFetcher { + dstLogoUri + "}"); } } - if (DEBUG) Log.d(TAG, "Fetching logos has been finished successfully."); - return null; - } - @WorkerThread - private Map<String, String> readTmsFile(Context context, String fileName) - throws IOException { - try (BufferedReader reader = new BufferedReader(new InputStreamReader( - context.getAssets().open(fileName)))) { - Map<String, String> channelNameLogoUriMap = new HashMap<>(); - String line; - while ((line = reader.readLine()) != null && !isCancelled()) { - String[] data = line.split(FIELD_SEPARATOR); - if (data.length != INDEX_LOGO_URI + 1) { - if (DEBUG) Log.d(TAG, "Invalid or comment row: " + line); - continue; - } - addChannelNames(channelNameLogoUriMap, - data[INDEX_NAME].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); - addChannelNames(channelNameLogoUriMap, - data[INDEX_SHORT_NAME].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); - addChannelNames(channelNameLogoUriMap, - data[INDEX_CALL_SIGN].toUpperCase(Locale.getDefault()), - data[INDEX_LOGO_URI]); + // Removes the logos for the channels that have logos before but now + // their logo uris are null. + boolean deleteChannelLogoFailed = false; + if (!channelsToRemove.isEmpty()) { + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + for (Channel channel : channelsToRemove) { + ops.add(ContentProviderOperation.newDelete( + TvContract.buildChannelLogoUri(channel.getId())).build()); + } + try { + mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + } catch (RemoteException | OperationApplicationException e) { + deleteChannelLogoFailed = true; + Log.e(TAG, "Error deleting obsolete channels", e); } - return channelNameLogoUriMap; } - } - - private void addChannelNames(Map<String, String> channelNameLogoUriMap, String channelName, - String logoUri) { - if (!TextUtils.isEmpty(channelName)) { - channelNameLogoUriMap.put(channelName, logoUri); - // Find the candidate names. - // If the name is like "W05AAD (W05AA-D)", then split the names into "W05AAD" and - // "W05AA-D" - String[] splitNames = channelName.split(NAME_SEPARATOR_FOR_TMS); - if (splitNames.length > 1) { - for (String name : splitNames) { - name = name.trim(); - if (channelNameLogoUriMap.get(name) == null) { - channelNameLogoUriMap.put(name, logoUri); - } - } - } + if (isFirstTimeFetchChannelLogo && !deleteChannelLogoFailed) { + sharedPreferencesEditor.putBoolean( + PREF_KEY_IS_FIRST_TIME_FETCH_CHANNEL_LOGO, false); } + sharedPreferencesEditor.commit(); + if (DEBUG) Log.d(TAG, "Fetching logos has been finished successfully."); + return null; } @Override protected void onPostExecute(Void result) { - synchronized (sLock) { - if (sFetchTask == this) { - sFetchTask = null; - } - } + sFetchTask = null; } } } diff --git a/src/com/android/tv/data/ChannelNumber.java b/src/com/android/tv/data/ChannelNumber.java index 59021609..29054aa5 100644 --- a/src/com/android/tv/data/ChannelNumber.java +++ b/src/com/android/tv/data/ChannelNumber.java @@ -17,37 +17,38 @@ package com.android.tv.data; import android.support.annotation.NonNull; +import android.text.TextUtils; import android.view.KeyEvent; +import com.android.tv.util.StringUtils; + +import java.util.Objects; + /** * A convenience class to handle channel number. */ public final class ChannelNumber implements Comparable<ChannelNumber> { - public static final String PRIMARY_CHANNEL_DELIMITER = "-"; - public static final String[] CHANNEL_DELIMITERS = {"-", ".", " "}; - private static final int[] CHANNEL_DELIMITER_KEYCODES = { KeyEvent.KEYCODE_MINUS, KeyEvent.KEYCODE_NUMPAD_SUBTRACT, KeyEvent.KEYCODE_PERIOD, KeyEvent.KEYCODE_NUMPAD_DOT, KeyEvent.KEYCODE_SPACE }; + /** The major part of the channel number. */ public String majorNumber; + /** The flag which indicates whether it has a delimiter or not. */ public boolean hasDelimiter; + /** The major part of the channel number. */ public String minorNumber; public ChannelNumber() { reset(); } - public ChannelNumber(String major, boolean hasDelimiter, String minor) { - setChannelNumber(major, hasDelimiter, minor); - } - public void reset() { setChannelNumber("", false, ""); } - public void setChannelNumber(String majorNumber, boolean hasDelimiter, String minorNumber) { + private void setChannelNumber(String majorNumber, boolean hasDelimiter, String minorNumber) { this.majorNumber = majorNumber; this.hasDelimiter = hasDelimiter; this.minorNumber = minorNumber; @@ -56,7 +57,7 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { @Override public String toString() { if (hasDelimiter) { - return majorNumber + PRIMARY_CHANNEL_DELIMITER + minorNumber; + return majorNumber + Channel.CHANNEL_NUMBER_DELIMITER + minorNumber; } return majorNumber; } @@ -75,6 +76,22 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return major - opponentMajor; } + @Override + public boolean equals(Object obj) { + if (obj instanceof ChannelNumber) { + ChannelNumber channelNumber = (ChannelNumber) obj; + return TextUtils.equals(majorNumber, channelNumber.majorNumber) + && TextUtils.equals(minorNumber, channelNumber.minorNumber) + && hasDelimiter == channelNumber.hasDelimiter; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + return Objects.hash(majorNumber, hasDelimiter, minorNumber); + } + public static boolean isChannelNumberDelimiterKey(int keyCode) { for (int delimiterKeyCode : CHANNEL_DELIMITER_KEYCODES) { if (delimiterKeyCode == keyCode) { @@ -84,22 +101,22 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return false; } + /** + * Returns the ChannelNumber instance. + * <p> + * Note that all the channel number argument should be normalized by + * {@link Channel#normalizeDisplayNumber}. The channels retrieved from + * {@link ChannelDataManager} are already normalized. + */ public static ChannelNumber parseChannelNumber(String number) { if (number == null) { return null; } ChannelNumber ret = new ChannelNumber(); - int indexOfDelimiter = -1; - for (String delimiter : CHANNEL_DELIMITERS) { - indexOfDelimiter = number.indexOf(delimiter); - if (indexOfDelimiter >= 0) { - break; - } - } + int indexOfDelimiter = number.indexOf(Channel.CHANNEL_NUMBER_DELIMITER); if (indexOfDelimiter == 0 || indexOfDelimiter == number.length() - 1) { return null; - } - if (indexOfDelimiter < 0) { + } else if (indexOfDelimiter < 0) { ret.majorNumber = number; if (!isInteger(ret.majorNumber)) { return null; @@ -115,25 +132,31 @@ public final class ChannelNumber implements Comparable<ChannelNumber> { return ret; } + /** + * Compares the channel numbers. + * <p> + * Note that all the channel number arguments should be normalized by + * {@link Channel#normalizeDisplayNumber}. The channels retrieved from + * {@link ChannelDataManager} are already normalized. + */ public static int compare(String lhs, String rhs) { ChannelNumber lhsNumber = parseChannelNumber(lhs); ChannelNumber rhsNumber = parseChannelNumber(rhs); + // Null first if (lhsNumber == null && rhsNumber == null) { - return 0; + return StringUtils.compare(lhs, rhs); } else if (lhsNumber == null /* && rhsNumber != null */) { return -1; - } else if (lhsNumber != null && rhsNumber == null) { + } else if (rhsNumber == null) { return 1; } return lhsNumber.compareTo(rhsNumber); } - public static boolean isInteger(String string) { + private static boolean isInteger(String string) { try { Integer.parseInt(string); - } catch(NumberFormatException e) { - return false; - } catch(NullPointerException e) { + } catch(NumberFormatException | NullPointerException e) { return false; } return true; diff --git a/src/com/android/tv/data/InternalDataUtils.java b/src/com/android/tv/data/InternalDataUtils.java index 6054f089..e33ca18f 100644 --- a/src/com/android/tv/data/InternalDataUtils.java +++ b/src/com/android/tv/data/InternalDataUtils.java @@ -21,7 +21,7 @@ import android.text.TextUtils; import android.util.Log; import com.android.tv.data.Program.CriticScore; -import com.android.tv.dvr.RecordedProgram; +import com.android.tv.dvr.data.RecordedProgram; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; diff --git a/src/com/android/tv/data/PreviewDataManager.java b/src/com/android/tv/data/PreviewDataManager.java new file mode 100644 index 00000000..01a58520 --- /dev/null +++ b/src/com/android/tv/data/PreviewDataManager.java @@ -0,0 +1,636 @@ +/* + * Copyright (C) 2017 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.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.support.annotation.IntDef; +import android.support.annotation.MainThread; +import android.support.media.tv.ChannelLogoUtils; +import android.support.media.tv.PreviewProgram; +import android.util.Log; +import android.util.Pair; + +import com.android.tv.R; +import com.android.tv.util.PermissionUtils; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * Class to manage the preview data. + */ +@TargetApi(Build.VERSION_CODES.O) +@MainThread +public class PreviewDataManager { + private static final String TAG = "PreviewDataManager"; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; + + /** + * Invalid preview channel ID. + */ + public static final long INVALID_PREVIEW_CHANNEL_ID = -1; + @IntDef({TYPE_DEFAULT_PREVIEW_CHANNEL, TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL}) + @Retention(RetentionPolicy.SOURCE) + public @interface PreviewChannelType{} + + /** + * Type of default preview channel + */ + public static final long TYPE_DEFAULT_PREVIEW_CHANNEL = 1; + /** + * Type of recorded program channel + */ + public static final long TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL = 2; + + private final Context mContext; + private final ContentResolver mContentResolver; + private boolean mLoadFinished; + private PreviewData mPreviewData = new PreviewData(); + private final Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); + + private QueryPreviewDataTask mQueryPreviewTask; + private final Map<Long, CreatePreviewChannelTask> mCreatePreviewChannelTasks = + new HashMap<>(); + private final Map<Long, UpdatePreviewProgramTask> mUpdatePreviewProgramTasks = new HashMap<>(); + + private final int mPreviewChannelLogoWidth; + private final int mPreviewChannelLogoHeight; + + public PreviewDataManager(Context context) { + mContext = context.getApplicationContext(); + mContentResolver = context.getContentResolver(); + mPreviewChannelLogoWidth = mContext.getResources().getDimensionPixelSize( + R.dimen.preview_channel_logo_width); + mPreviewChannelLogoHeight = mContext.getResources().getDimensionPixelSize( + R.dimen.preview_channel_logo_height); + } + + /** + * Starts the preview data manager. + */ + public void start() { + if (mQueryPreviewTask == null) { + mQueryPreviewTask = new QueryPreviewDataTask(); + mQueryPreviewTask.execute(); + } + } + + /** + * Stops the preview data manager. + */ + public void stop() { + if (mQueryPreviewTask != null) { + mQueryPreviewTask.cancel(true); + } + for (CreatePreviewChannelTask createPreviewChannelTask + : mCreatePreviewChannelTasks.values()) { + createPreviewChannelTask.cancel(true); + } + for (UpdatePreviewProgramTask updatePreviewProgramTask + : mUpdatePreviewProgramTasks.values()) { + updatePreviewProgramTask.cancel(true); + } + + mQueryPreviewTask = null; + mCreatePreviewChannelTasks.clear(); + mUpdatePreviewProgramTasks.clear(); + } + + /** + * Gets preview channel ID from the preview channel type. + */ + public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { + return mPreviewData.getPreviewChannelId(previewChannelType); + } + + /** + * Creates default preview channel. + */ + public void createDefaultPreviewChannel( + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + createPreviewChannel(TYPE_DEFAULT_PREVIEW_CHANNEL, onPreviewChannelCreationResultListener); + } + + /** + * Creates a preview channel for specific channel type. + */ + public void createPreviewChannel(@PreviewChannelType long previewChannelType, + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + CreatePreviewChannelTask currentRunningCreateTask = + mCreatePreviewChannelTasks.get(previewChannelType); + if (currentRunningCreateTask == null) { + CreatePreviewChannelTask createPreviewChannelTask = new CreatePreviewChannelTask( + previewChannelType); + createPreviewChannelTask.addOnPreviewChannelCreationResultListener( + onPreviewChannelCreationResultListener); + createPreviewChannelTask.execute(); + mCreatePreviewChannelTasks.put(previewChannelType, createPreviewChannelTask); + } else { + currentRunningCreateTask.addOnPreviewChannelCreationResultListener( + onPreviewChannelCreationResultListener); + } + } + + /** + * Returns {@code true} if the preview data is loaded. + */ + public boolean isLoadFinished() { + return mLoadFinished; + } + + /** + * Adds listener. + */ + public void addListener(PreviewDataListener previewDataListener) { + mPreviewDataListeners.add(previewDataListener); + } + + /** + * Removes listener. + */ + public void removeListener(PreviewDataListener previewDataListener) { + mPreviewDataListeners.remove(previewDataListener); + } + + /** + * Updates the preview programs table for a specific preview channel. + */ + public void updatePreviewProgramsForChannel(long previewChannelId, + Set<PreviewProgramContent> programs, PreviewDataListener previewDataListener) { + UpdatePreviewProgramTask currentRunningUpdateTask = + mUpdatePreviewProgramTasks.get(previewChannelId); + if (currentRunningUpdateTask != null + && currentRunningUpdateTask.getPrograms().equals(programs)) { + currentRunningUpdateTask.addPreviewDataListener(previewDataListener); + return; + } + UpdatePreviewProgramTask updatePreviewProgramTask = + new UpdatePreviewProgramTask(previewChannelId, programs); + updatePreviewProgramTask.addPreviewDataListener(previewDataListener); + if (currentRunningUpdateTask != null) { + currentRunningUpdateTask.cancel(true); + currentRunningUpdateTask.saveStatus(); + updatePreviewProgramTask.addPreviewDataListeners( + currentRunningUpdateTask.getPreviewDataListeners()); + } + updatePreviewProgramTask.execute(); + mUpdatePreviewProgramTasks.put(previewChannelId, updatePreviewProgramTask); + } + + private void notifyPreviewDataLoadFinished() { + for (PreviewDataListener l : mPreviewDataListeners) { + l.onPreviewDataLoadFinished(); + } + } + + public interface PreviewDataListener { + /** + * Called when the preview data is loaded. + */ + void onPreviewDataLoadFinished(); + + /** + * Called when the preview data is updated. + */ + void onPreviewDataUpdateFinished(); + } + + public interface OnPreviewChannelCreationResultListener { + /** + * Called when the creation of preview channel is finished. + * @param createdPreviewChannelId The preview channel ID if created successfully, + * otherwise it's {@value #INVALID_PREVIEW_CHANNEL_ID}. + */ + void onPreviewChannelCreationResult(long createdPreviewChannelId); + } + + private final class QueryPreviewDataTask extends AsyncTask<Void, Void, PreviewData> { + private final String PARAM_PREVIEW = "preview"; + private final String mChannelSelection = TvContract.Channels.COLUMN_PACKAGE_NAME + "=?"; + + @Override + protected PreviewData doInBackground(Void... voids) { + // Query preview channels and programs. + if (DEBUG) Log.d(TAG, "QueryPreviewDataTask.doInBackground"); + PreviewData previewData = new PreviewData(); + try { + Uri previewChannelsUri = + PreviewDataUtils.addQueryParamToUri( + TvContract.Channels.CONTENT_URI, + new Pair<>(PARAM_PREVIEW, String.valueOf(true))); + String packageName = mContext.getPackageName(); + if (PermissionUtils.hasAccessAllEpg(mContext)) { + try (Cursor cursor = + mContentResolver.query( + previewChannelsUri, + android.support.media.tv.Channel.PROJECTION, + mChannelSelection, + new String[] {packageName}, + null)) { + if (cursor != null) { + while (cursor.moveToNext()) { + android.support.media.tv.Channel previewChannel = + android.support.media.tv.Channel.fromCursor(cursor); + Long previewChannelType = previewChannel.getInternalProviderFlag1(); + if (previewChannelType != null) { + previewData.addPreviewChannelId( + previewChannelType, previewChannel.getId()); + } + } + } + } + } else { + try (Cursor cursor = + mContentResolver.query( + previewChannelsUri, + android.support.media.tv.Channel.PROJECTION, + null, + null, + null)) { + if (cursor != null) { + while (cursor.moveToNext()) { + android.support.media.tv.Channel previewChannel = + android.support.media.tv.Channel.fromCursor(cursor); + Long previewChannelType = previewChannel.getInternalProviderFlag1(); + if (previewChannel.getPackageName() == packageName + && previewChannelType != null) { + previewData.addPreviewChannelId( + previewChannelType, previewChannel.getId()); + } + } + } + } + } + + for (long previewChannelId : previewData.getAllPreviewChannelIds().values()) { + Uri previewProgramsUriForPreviewChannel = + TvContract.buildPreviewProgramsUriForChannel(previewChannelId); + try (Cursor previewProgramCursor = + mContentResolver.query( + previewProgramsUriForPreviewChannel, + PreviewProgram.PROJECTION, + null, + null, + null)) { + if (previewProgramCursor != null) { + while (previewProgramCursor.moveToNext()) { + PreviewProgram previewProgram = + PreviewProgram.fromCursor(previewProgramCursor); + previewData.addPreviewProgram(previewProgram); + } + } + } + } + } catch (SQLException e) { + Log.w(TAG, "Unable to get preview data", e); + } + return previewData; + } + + @Override + protected void onPostExecute(PreviewData result) { + super.onPostExecute(result); + if (mQueryPreviewTask == this) { + mQueryPreviewTask = null; + mPreviewData = new PreviewData(result); + mLoadFinished = true; + notifyPreviewDataLoadFinished(); + } + } + } + + private final class CreatePreviewChannelTask extends AsyncTask<Void, Void, Long> { + private final long mPreviewChannelType; + private Set<OnPreviewChannelCreationResultListener> + mOnPreviewChannelCreationResultListeners = new CopyOnWriteArraySet<>(); + + public CreatePreviewChannelTask(long previewChannelType) { + mPreviewChannelType = previewChannelType; + } + + public void addOnPreviewChannelCreationResultListener( + OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener) { + if (onPreviewChannelCreationResultListener != null) { + mOnPreviewChannelCreationResultListeners.add( + onPreviewChannelCreationResultListener); + } + } + + @Override + protected Long doInBackground(Void... params) { + if (DEBUG) Log.d(TAG, "CreatePreviewChannelTask.doInBackground"); + long previewChannelId; + try { + Uri channelUri = mContentResolver.insert(TvContract.Channels.CONTENT_URI, + PreviewDataUtils.createPreviewChannel(mContext, mPreviewChannelType) + .toContentValues()); + if (channelUri != null) { + previewChannelId = ContentUris.parseId(channelUri); + } else { + Log.e(TAG, "Fail to insert preview channel"); + return INVALID_PREVIEW_CHANNEL_ID; + } + } catch (UnsupportedOperationException | NumberFormatException e) { + Log.e(TAG, "Fail to get channel ID"); + return INVALID_PREVIEW_CHANNEL_ID; + } + Drawable appIcon = mContext.getApplicationInfo().loadIcon(mContext.getPackageManager()); + if (appIcon != null && appIcon instanceof BitmapDrawable) { + ChannelLogoUtils.storeChannelLogo(mContext, previewChannelId, + Bitmap.createScaledBitmap(((BitmapDrawable) appIcon).getBitmap(), + mPreviewChannelLogoWidth, mPreviewChannelLogoHeight, false)); + } + return previewChannelId; + } + + @Override + protected void onPostExecute(Long result) { + super.onPostExecute(result); + if (result != INVALID_PREVIEW_CHANNEL_ID) { + mPreviewData.addPreviewChannelId(mPreviewChannelType, result); + } + for (OnPreviewChannelCreationResultListener onPreviewChannelCreationResultListener + : mOnPreviewChannelCreationResultListeners) { + onPreviewChannelCreationResultListener.onPreviewChannelCreationResult(result); + } + mCreatePreviewChannelTasks.remove(mPreviewChannelType); + } + } + + /** + * Updates the whole data which belongs to the package in preview programs table for a + * specific preview channel with a set of {@link PreviewProgramContent}. + */ + private final class UpdatePreviewProgramTask extends AsyncTask<Void, Void, Void> { + private long mPreviewChannelId; + private Set<PreviewProgramContent> mPrograms; + private Map<Long, Long> mCurrentProgramId2PreviewProgramId; + private Set<PreviewDataListener> mPreviewDataListeners = new CopyOnWriteArraySet<>(); + + public UpdatePreviewProgramTask(long previewChannelId, + Set<PreviewProgramContent> programs) { + mPreviewChannelId = previewChannelId; + mPrograms = programs; + if (mPreviewData.getPreviewProgramIds(previewChannelId) == null) { + mCurrentProgramId2PreviewProgramId = new HashMap<>(); + } else { + mCurrentProgramId2PreviewProgramId = new HashMap<>( + mPreviewData.getPreviewProgramIds(previewChannelId)); + } + } + + public void addPreviewDataListener(PreviewDataListener previewDataListener) { + if (previewDataListener != null) { + mPreviewDataListeners.add(previewDataListener); + } + } + + public void addPreviewDataListeners(Set<PreviewDataListener> previewDataListeners) { + if (previewDataListeners != null) { + mPreviewDataListeners.addAll(previewDataListeners); + } + } + + public Set<PreviewProgramContent> getPrograms() { + return mPrograms; + } + + public Set<PreviewDataListener> getPreviewDataListeners() { + return mPreviewDataListeners; + } + + @Override + protected Void doInBackground(Void... params) { + if (DEBUG) Log.d(TAG, "UpdatePreviewProgamTask.doInBackground"); + Map<Long, Long> uncheckedPrograms = new HashMap<>(mCurrentProgramId2PreviewProgramId); + for (PreviewProgramContent program : mPrograms) { + if (isCancelled()) { + return null; + } + Long existingPreviewProgramId = uncheckedPrograms.remove(program.getId()); + if (existingPreviewProgramId != null) { + if (DEBUG) Log.d(TAG, "Preview program " + existingPreviewProgramId + " " + + "already exists for program " + program.getId()); + continue; + } + try { + Uri programUri = mContentResolver.insert(TvContract.PreviewPrograms.CONTENT_URI, + PreviewDataUtils.createPreviewProgramFromContent(program) + .toContentValues()); + if (programUri != null) { + long previewProgramId = ContentUris.parseId(programUri); + mCurrentProgramId2PreviewProgramId.put(program.getId(), previewProgramId); + if (DEBUG) Log.d(TAG, "Add new preview program " + previewProgramId); + } else { + Log.e(TAG, "Fail to insert preview program"); + } + } catch (Exception e) { + Log.e(TAG, "Fail to get preview program ID"); + } + } + + for (Long key : uncheckedPrograms.keySet()) { + if (isCancelled()) { + return null; + } + try { + if (DEBUG) Log.d(TAG, "Remove preview program " + uncheckedPrograms.get(key)); + mContentResolver.delete(TvContract.buildPreviewProgramUri( + uncheckedPrograms.get(key)), null, null); + mCurrentProgramId2PreviewProgramId.remove(key); + } catch (Exception e) { + Log.e(TAG, "Fail to remove preview program " + uncheckedPrograms.get(key)); + } + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + mPreviewData.setPreviewProgramIds( + mPreviewChannelId, mCurrentProgramId2PreviewProgramId); + mUpdatePreviewProgramTasks.remove(mPreviewChannelId); + for (PreviewDataListener previewDataListener : mPreviewDataListeners) { + previewDataListener.onPreviewDataUpdateFinished(); + } + } + + public void saveStatus() { + mPreviewData.setPreviewProgramIds( + mPreviewChannelId, mCurrentProgramId2PreviewProgramId); + } + } + + /** + * Class to store the query result of preview data. + */ + private static final class PreviewData { + private Map<Long, Long> mPreviewChannelType2Id = new HashMap<>(); + private Map<Long, Map<Long, Long>> mProgramId2PreviewProgramId = new HashMap<>(); + + PreviewData() { + mPreviewChannelType2Id = new HashMap<>(); + mProgramId2PreviewProgramId = new HashMap<>(); + } + + PreviewData(PreviewData previewData) { + mPreviewChannelType2Id = new HashMap<>(previewData.mPreviewChannelType2Id); + mProgramId2PreviewProgramId = new HashMap<>(previewData.mProgramId2PreviewProgramId); + } + + public void addPreviewProgram(PreviewProgram previewProgram) { + long previewChannelId = previewProgram.getChannelId(); + Map<Long, Long> programId2PreviewProgram = + mProgramId2PreviewProgramId.get(previewChannelId); + if (programId2PreviewProgram == null) { + programId2PreviewProgram = new HashMap<>(); + } + mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgram); + if (previewProgram.getInternalProviderId() != null) { + programId2PreviewProgram.put( + Long.parseLong(previewProgram.getInternalProviderId()), + previewProgram.getId()); + } + } + + public @PreviewChannelType long getPreviewChannelId(long previewChannelType) { + Long result = mPreviewChannelType2Id.get(previewChannelType); + return result == null ? INVALID_PREVIEW_CHANNEL_ID : result; + } + + public Map<Long, Long> getAllPreviewChannelIds() { + return mPreviewChannelType2Id; + } + + public void addPreviewChannelId(long previewChannelType, long previewChannelId) { + mPreviewChannelType2Id.put(previewChannelType, previewChannelId); + } + + public void removePreviewChannelId(long previewChannelType) { + mPreviewChannelType2Id.remove(previewChannelType); + } + + public void removePreviewChannel(long previewChannelId) { + removePreviewChannelId(previewChannelId); + removePreviewProgramIds(previewChannelId); + } + + public Map<Long, Long> getPreviewProgramIds(long previewChannelId) { + return mProgramId2PreviewProgramId.get(previewChannelId); + } + + public Map<Long, Map<Long, Long>> getAllPreviewProgramIds() { + return mProgramId2PreviewProgramId; + } + + public void setPreviewProgramIds( + long previewChannelId, Map<Long, Long> programId2PreviewProgramId) { + mProgramId2PreviewProgramId.put(previewChannelId, programId2PreviewProgramId); + } + + public void removePreviewProgramIds(long previewChannelId) { + mProgramId2PreviewProgramId.remove(previewChannelId); + } + } + + /** + * A utils class for preview data. + */ + public final static class PreviewDataUtils { + /** + * Creates a preview channel. + */ + public static android.support.media.tv.Channel createPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + if (previewChannelType == TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL) { + return createRecordedProgramPreviewChannel(context, previewChannelType); + } + return createDefaultPreviewChannel(context, previewChannelType); + } + + private static android.support.media.tv.Channel createDefaultPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + android.support.media.tv.Channel.Builder builder = + new android.support.media.tv.Channel.Builder(); + CharSequence appLabel = + context.getApplicationInfo().loadLabel(context.getPackageManager()); + CharSequence appDescription = + context.getApplicationInfo().loadDescription(context.getPackageManager()); + builder.setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(appLabel == null ? null : appLabel.toString()) + .setDescription(appDescription == null ? null : appDescription.toString()) + .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) + .setInternalProviderFlag1(previewChannelType); + return builder.build(); + } + + private static android.support.media.tv.Channel createRecordedProgramPreviewChannel( + Context context, @PreviewChannelType long previewChannelType) { + android.support.media.tv.Channel.Builder builder = + new android.support.media.tv.Channel.Builder(); + builder.setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(context.getResources().getString( + R.string.recorded_programs_preview_channel)) + .setAppLinkIntentUri(TvContract.Channels.CONTENT_URI) + .setInternalProviderFlag1(previewChannelType); + return builder.build(); + } + + /** + * Creates a preview program. + */ + public static PreviewProgram createPreviewProgramFromContent( + PreviewProgramContent program) { + PreviewProgram.Builder builder = new PreviewProgram.Builder(); + builder.setChannelId(program.getPreviewChannelId()) + .setType(program.getType()) + .setLive(program.getLive()) + .setTitle(program.getTitle()) + .setDescription(program.getDescription()) + .setPosterArtUri(program.getPosterArtUri()) + .setIntentUri(program.getIntentUri()) + .setPreviewVideoUri(program.getPreviewVideoUri()) + .setInternalProviderId(Long.toString(program.getId())); + return builder.build(); + } + + /** + * Appends query parameters to a Uri. + */ + public static Uri addQueryParamToUri(Uri uri, Pair<String, String> param) { + return uri.buildUpon().appendQueryParameter(param.first, param.second).build(); + } + } +} diff --git a/src/com/android/tv/data/PreviewProgramContent.java b/src/com/android/tv/data/PreviewProgramContent.java new file mode 100644 index 00000000..39f5051d --- /dev/null +++ b/src/com/android/tv/data/PreviewProgramContent.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2017 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 android.media.tv.TvContract; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Pair; + +import com.android.tv.TvApplication; +import com.android.tv.dvr.data.RecordedProgram; + +import java.util.Objects; + +/** + * A class to store the content of preview programs. + */ +public class PreviewProgramContent { + private final static String PARAM_INPUT = "input"; + + private long mId; + private long mPreviewChannelId; + private int mType; + private boolean mLive; + private String mTitle; + private String mDescription; + private Uri mPosterArtUri; + private Uri mIntentUri; + private Uri mPreviewVideoUri; + + /** + * Create preview program content from {@link Program} + */ + public static PreviewProgramContent createFromProgram(Context context, + long previewChannelId, Program program) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(program.getChannelId()); + if (channel == null) { + return null; + } + String channelDisplayName = channel.getDisplayName(); + return new PreviewProgramContent.Builder() + .setId(program.getId()) + .setPreviewChannelId(previewChannelId) + .setType(TvContract.PreviewPrograms.TYPE_CHANNEL) + .setLive(true) + .setTitle(program.getTitle()) + .setDescription(!TextUtils.isEmpty(channelDisplayName) + ? channelDisplayName : channel.getDisplayNumber()) + .setPosterArtUri(Uri.parse(program.getPosterArtUri())) + .setIntentUri(channel.getUri()) + .setPreviewVideoUri(PreviewDataManager.PreviewDataUtils.addQueryParamToUri( + channel.getUri(), new Pair<>(PARAM_INPUT, channel.getInputId()))) + .build(); + } + + /** + * Create preview program content from {@link RecordedProgram} + */ + public static PreviewProgramContent createFromRecordedProgram( + Context context, long previewChannelId, RecordedProgram recordedProgram) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(recordedProgram.getChannelId()); + String channelDisplayName = null; + if (channel != null) { + channelDisplayName = channel.getDisplayName(); + } + Uri recordedProgramUri = TvContract.buildRecordedProgramUri(recordedProgram.getId()); + return new PreviewProgramContent.Builder() + .setId(recordedProgram.getId()) + .setPreviewChannelId(previewChannelId) + .setType(TvContract.PreviewPrograms.TYPE_CLIP) + .setTitle(recordedProgram.getTitle()) + .setDescription(channelDisplayName != null ? channelDisplayName : "") + .setPosterArtUri(Uri.parse(recordedProgram.getPosterArtUri())) + .setIntentUri(recordedProgramUri) + .setPreviewVideoUri(PreviewDataManager.PreviewDataUtils.addQueryParamToUri( + recordedProgramUri, new Pair<>(PARAM_INPUT, recordedProgram.getInputId()))) + .build(); + } + + private PreviewProgramContent() { } + + public void copyFrom(PreviewProgramContent other) { + if (this == other) { + return; + } + mId = other.mId; + mPreviewChannelId = other.mPreviewChannelId; + mType = other.mType; + mLive = other.mLive; + mTitle = other.mTitle; + mDescription = other.mDescription; + mPosterArtUri = other.mPosterArtUri; + mIntentUri = other.mIntentUri; + mPreviewVideoUri = other.mPreviewVideoUri; + } + + /** + * Returns the id, which is an identification. It usually comes from the original data which + * create the {@PreviewProgramContent}. + */ + public long getId() { + return mId; + } + + /** + * Returns the preview channel id which the preview program belongs to. + */ + public long getPreviewChannelId() { + return mPreviewChannelId; + } + + /** + * Returns the type of the preview program. + */ + public int getType() { + return mType; + } + + /** + * Returns whether the preview program is live or not. + */ + public boolean getLive() { + return mLive; + } + + /** + * Returns the title of the preview program. + */ + public String getTitle() { + return mTitle; + } + + /** + * Returns the description of the preview program. + */ + public String getDescription() { + return mDescription; + } + + /** + * Returns the poster art uri of the preview program. + */ + public Uri getPosterArtUri() { + return mPosterArtUri; + } + + /** + * Returns the intent uri of the preview program. + */ + public Uri getIntentUri() { + return mIntentUri; + } + + /** + * Returns the preview video uri of the preview program. + */ + public Uri getPreviewVideoUri() { + return mPreviewVideoUri; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof PreviewProgramContent)) { + return false; + } + PreviewProgramContent previewProgramContent = (PreviewProgramContent) other; + return previewProgramContent.mId == mId + && previewProgramContent.mPreviewChannelId == mPreviewChannelId + && previewProgramContent.mType == mType + && previewProgramContent.mLive == mLive + && Objects.equals(previewProgramContent.mTitle, mTitle) + && Objects.equals(previewProgramContent.mDescription, mDescription) + && Objects.equals(previewProgramContent.mPosterArtUri, mPosterArtUri) + && Objects.equals(previewProgramContent.mIntentUri, mIntentUri) + && Objects.equals(previewProgramContent.mPreviewVideoUri, mPreviewVideoUri); + } + + @Override + public int hashCode() { + return Objects.hash(mId, mPreviewChannelId, mType, mLive, mTitle, mDescription, + mPosterArtUri, mIntentUri, mPreviewVideoUri); + } + + public static final class Builder { + private final PreviewProgramContent mPreviewProgramContent; + + public Builder() { + mPreviewProgramContent = new PreviewProgramContent(); + } + + public Builder setId(long id) { + mPreviewProgramContent.mId = id; + return this; + } + + public Builder setPreviewChannelId(long previewChannelId) { + mPreviewProgramContent.mPreviewChannelId = previewChannelId; + return this; + } + + public Builder setType(int type) { + mPreviewProgramContent.mType = type; + return this; + } + + public Builder setLive(boolean live) { + mPreviewProgramContent.mLive = live; + return this; + } + + public Builder setTitle(String title) { + mPreviewProgramContent.mTitle = title; + return this; + } + + public Builder setDescription(String description) { + mPreviewProgramContent.mDescription = description; + return this; + } + + public Builder setPosterArtUri(Uri posterArtUri) { + mPreviewProgramContent.mPosterArtUri = posterArtUri; + return this; + } + + public Builder setIntentUri(Uri intentUri) { + mPreviewProgramContent.mIntentUri = intentUri; + return this; + } + + public Builder setPreviewVideoUri(Uri previewVideoUri) { + mPreviewProgramContent.mPreviewVideoUri = previewVideoUri; + return this; + } + + public PreviewProgramContent build() { + PreviewProgramContent previewProgramContent = new PreviewProgramContent(); + previewProgramContent.copyFrom(mPreviewProgramContent); + return previewProgramContent; + } + } +} diff --git a/src/com/android/tv/data/Program.java b/src/com/android/tv/data/Program.java index b9cd3d8d..071c7024 100644 --- a/src/com/android/tv/data/Program.java +++ b/src/com/android/tv/data/Program.java @@ -16,21 +16,23 @@ package com.android.tv.data; +import android.annotation.SuppressLint; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.media.tv.TvContentRating; import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.os.Build; 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; -import com.android.tv.R; import com.android.tv.common.BuildConfig; import com.android.tv.common.CollectionUtils; import com.android.tv.common.TvContentRatingCache; @@ -88,9 +90,11 @@ public final class Program extends BaseProgram implements Comparable<Program>, P public static final String[] PROJECTION = createProjection(); private static String[] createProjection() { - return CollectionUtils - .concatAll(PROJECTION_BASE, BuildCompat.isAtLeastN() ? PROJECTION_ADDED_IN_NYC - : PROJECTION_DEPRECATED_IN_NYC); + return CollectionUtils.concatAll( + PROJECTION_BASE, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + ? PROJECTION_ADDED_IN_NYC + : PROJECTION_DEPRECATED_IN_NYC); } /** @@ -135,7 +139,7 @@ public final class Program extends BaseProgram implements Comparable<Program>, P InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder); } index++; - if (BuildCompat.isAtLeastN()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { builder.setSeasonNumber(cursor.getString(index++)); builder.setSeasonTitle(cursor.getString(index++)); builder.setEpisodeNumber(cursor.getString(index++)); @@ -213,11 +217,6 @@ public final class Program extends BaseProgram implements Comparable<Program>, P private TvContentRating[] mContentRatings; private boolean mRecordingProhibited; - /** - * TODO(DVR): Need to fill the following data. - */ - private boolean mRecordingScheduled; - private Program() { // Do nothing. } @@ -268,46 +267,12 @@ public final class Program extends BaseProgram implements Comparable<Program>, P /** * 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(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); - } - } + public String getEpisodeTitle() { 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; } @@ -361,6 +326,8 @@ public final class Program extends BaseProgram implements Comparable<Program>, P return mCriticScores; } + @Nullable + @Override public TvContentRating[] getContentRatings() { return mContentRatings; } @@ -495,6 +462,63 @@ public final class Program extends BaseProgram implements Comparable<Program>, P return builder.append("}").toString(); } + /** + * Translates a {@link Program} to {@link ContentValues} that are ready to be written into + * Database. + */ + @SuppressLint("InlinedApi") + @SuppressWarnings("deprecation") + public 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()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + 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_LONG_DESCRIPTION, program.getLongDescription()); + 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, + TvContract.Programs.Genres.encode(canonicalGenres)); + } else { + putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, ""); + } + putValue(values, Programs.COLUMN_CONTENT_RATING, + TvContentRatingCache.contentRatingsToString(program.getContentRatings())); + 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; + } + + private static void putValue(ContentValues contentValues, String key, String value) { + if (TextUtils.isEmpty(value)) { + contentValues.putNull(key); + } else { + contentValues.put(key, value); + } + } + + private static void putValue(ContentValues contentValues, String key, byte[] value) { + if (value == null || value.length == 0) { + contentValues.putNull(key); + } else { + contentValues.put(key, value); + } + } + public void copyFrom(Program other) { if (this == other) { return; @@ -524,13 +548,6 @@ public final class Program extends BaseProgram implements Comparable<Program>, P } /** - * 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 { @@ -799,8 +816,12 @@ public final class Program extends BaseProgram implements Comparable<Program>, P */ public Program build() { // Generate the series ID for the episodic program of other TV input. - if (TextUtils.isEmpty(mProgram.mSeriesId) + if (TextUtils.isEmpty(mProgram.mTitle)) { + // If title is null, series cannot be generated for this program. + setSeriesId(null); + } else if (TextUtils.isEmpty(mProgram.mSeriesId) && !TextUtils.isEmpty(mProgram.mEpisodeNumber)) { + // If series ID is not set, generate it for the episodic program of other TV input. setSeriesId(BaseProgram.generateSeriesId(mProgram.mPackageName, mProgram.mTitle)); } Program program = new Program(); @@ -820,17 +841,20 @@ public final class Program extends BaseProgram implements Comparable<Program>, P } /** - * Loads the program poster art and returns it via {@code callback}.<p> + * Loads the program poster art and returns it via {@code callback}. * <p> * Note that it may directly call {@code callback} if the program poster art already is loaded. + * + * @return {@code true} if the load is complete and the callback is executed. */ @UiThread - public void loadPosterArt(Context context, int posterArtWidth, int posterArtHeight, + public boolean loadPosterArt(Context context, int posterArtWidth, int posterArtHeight, ImageLoader.ImageLoaderCallback callback) { if (mPosterArtUri == null) { - return; + return false; } - ImageLoader.loadBitmap(context, mPosterArtUri, posterArtWidth, posterArtHeight, callback); + return ImageLoader.loadBitmap( + context, mPosterArtUri, posterArtWidth, posterArtHeight, callback); } public static boolean isDuplicate(Program p1, Program p2) { diff --git a/src/com/android/tv/data/ProgramDataManager.java b/src/com/android/tv/data/ProgramDataManager.java index d2af33a7..8cb5e74a 100644 --- a/src/com/android/tv/data/ProgramDataManager.java +++ b/src/com/android/tv/data/ProgramDataManager.java @@ -26,6 +26,7 @@ import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.os.Message; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; import android.util.ArraySet; @@ -35,8 +36,6 @@ 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; @@ -51,6 +50,7 @@ import java.util.ListIterator; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @MainThread @@ -85,10 +85,12 @@ public class ProgramDataManager implements MemoryManageable { private final Clock mClock; private final ContentResolver mContentResolver; private boolean mStarted; + // Updated only on the main thread. + private volatile boolean mCurrentProgramsLoadFinished; private ProgramsUpdateTask mProgramsUpdateTask; private final LongSparseArray<UpdateCurrentProgramForChannelTask> mProgramUpdateTaskMap = new LongSparseArray<>(); - private final Map<Long, Program> mChannelIdCurrentProgramMap = new HashMap<>(); + private final Map<Long, Program> mChannelIdCurrentProgramMap = new ConcurrentHashMap<>(); private final MultiLongSparseArray<OnCurrentProgramUpdatedListener> mChannelId2ProgramUpdatedListeners = new MultiLongSparseArray<>(); private final Handler mHandler; @@ -109,17 +111,14 @@ public class ProgramDataManager implements MemoryManageable { private boolean mPauseProgramUpdate = false; private final LruCache<Long, Program> mZeroLengthProgramCache = new LruCache<>(10); - private final EpgFetcher mEpgFetcher; + @MainThread public ProgramDataManager(Context context) { - this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper(), - EpgFetcher.getInstance(context)); + this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper()); } @VisibleForTesting - ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper, - EpgFetcher epgFetcher) { - mEpgFetcher = epgFetcher; + ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper) { mClock = time; mContentResolver = contentResolver; mHandler = new MyHandler(looper); @@ -175,9 +174,6 @@ public class ProgramDataManager implements MemoryManageable { } mContentResolver.registerContentObserver(Programs.CONTENT_URI, true, mProgramObserver); - if (mEpgFetcher != null && Experiments.CLOUD_EPG.get()) { - mEpgFetcher.start(); - } } /** @@ -190,10 +186,6 @@ public class ProgramDataManager implements MemoryManageable { return; } mStarted = false; - - if (mEpgFetcher != null) { - mEpgFetcher.stop(); - } mContentResolver.unregisterContentObserver(mProgramObserver); mHandler.removeCallbacksAndMessages(null); @@ -205,13 +197,23 @@ public class ProgramDataManager implements MemoryManageable { } } - /** - * Returns the current program at the specified channel. - */ + @AnyThread + public boolean isCurrentProgramsLoadFinished() { + return mCurrentProgramsLoadFinished; + } + + /** Returns the current program at the specified channel. */ + @AnyThread public Program getCurrentProgram(long channelId) { return mChannelIdCurrentProgramMap.get(channelId); } + /** Returns all the current programs. */ + @AnyThread + public List<Program> getCurrentPrograms() { + return new ArrayList<>(mChannelIdCurrentProgramMap.values()); + } + /** * Reloads program data. */ @@ -338,19 +340,19 @@ public class ProgramDataManager implements MemoryManageable { } private void notifyCurrentProgramUpdate(long channelId, Program program) { - for (OnCurrentProgramUpdatedListener listener : mChannelId2ProgramUpdatedListeners .get(channelId)) { listener.onCurrentProgramUpdated(channelId, program); - } + } for (OnCurrentProgramUpdatedListener listener : mChannelId2ProgramUpdatedListeners .get(Channel.INVALID_ID)) { listener.onCurrentProgramUpdated(channelId, program); - } + } } private void updateCurrentProgram(long channelId, Program program) { - Program previousProgram = mChannelIdCurrentProgramMap.put(channelId, program); + Program previousProgram = program == null ? mChannelIdCurrentProgramMap.remove(channelId) + : mChannelIdCurrentProgramMap.put(channelId, program); if (!Objects.equals(program, previousProgram)) { if (mPrefetchEnabled) { removePreviousProgramsAndUpdateCurrentProgramInCache(channelId, program); @@ -581,22 +583,22 @@ public class ProgramDataManager implements MemoryManageable { protected void onPostExecute(List<Program> programs) { if (DEBUG) Log.d(TAG, "ProgramsUpdateTask done"); mProgramsUpdateTask = null; - if (programs == null) { - return; - } - Set<Long> removedChannelIds = new HashSet<>(mChannelIdCurrentProgramMap.keySet()); - for (Program program : programs) { - long channelId = program.getChannelId(); - updateCurrentProgram(channelId, program); - removedChannelIds.remove(channelId); - } - for (Long channelId : removedChannelIds) { - if (mPrefetchEnabled) { - mChannelIdProgramCache.remove(channelId); + if (programs != null) { + Set<Long> removedChannelIds = new HashSet<>(mChannelIdCurrentProgramMap.keySet()); + for (Program program : programs) { + long channelId = program.getChannelId(); + updateCurrentProgram(channelId, program); + removedChannelIds.remove(channelId); + } + for (Long channelId : removedChannelIds) { + if (mPrefetchEnabled) { + mChannelIdProgramCache.remove(channelId); + } + mChannelIdCurrentProgramMap.remove(channelId); + notifyCurrentProgramUpdate(channelId, null); } - mChannelIdCurrentProgramMap.remove(channelId); - notifyCurrentProgramUpdate(channelId, null); } + mCurrentProgramsLoadFinished = true; } } diff --git a/src/com/android/tv/data/StreamInfo.java b/src/com/android/tv/data/StreamInfo.java index fe461f14..709863cf 100644 --- a/src/com/android/tv/data/StreamInfo.java +++ b/src/com/android/tv/data/StreamInfo.java @@ -38,5 +38,9 @@ public interface StreamInfo { int getAudioChannelCount(); boolean hasClosedCaption(); boolean isVideoAvailable(); + /** + * Returns true, if video or audio is available. + */ + boolean isVideoOrAudioAvailable(); int getVideoUnavailableReason(); } diff --git a/src/com/android/tv/data/WatchedHistoryManager.java b/src/com/android/tv/data/WatchedHistoryManager.java index 59319338..3edd7b1a 100644 --- a/src/com/android/tv/data/WatchedHistoryManager.java +++ b/src/com/android/tv/data/WatchedHistoryManager.java @@ -10,15 +10,14 @@ import android.os.Looper; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; import android.util.Log; import com.android.tv.common.SharedPreferencesUtils; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Scanner; import java.util.concurrent.TimeUnit; @@ -28,15 +27,15 @@ import java.util.concurrent.TimeUnit; * * <p>When there is no access to watched table of TvProvider, * this class is used to build up watched history and to compute recent channels. + * <p>Note that this class is not thread safe. Please use this on one thread. */ public class WatchedHistoryManager { private final static String TAG = "WatchedHistoryManager"; - private final boolean DEBUG = false; + private final static boolean DEBUG = false; private static final int MAX_HISTORY_SIZE = 10000; private static final String PREF_KEY_LAST_INDEX = "last_index"; private static final long MIN_DURATION_MS = TimeUnit.SECONDS.toMillis(10); - private static final long RECENT_CHANNEL_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5); private final List<WatchedRecord> mWatchedHistory = new ArrayList<>(); private final List<WatchedRecord> mPendingRecords = new ArrayList<>(); @@ -92,11 +91,7 @@ public class WatchedHistoryManager { WatchedHistoryManager(Context context, int maxHistorySize) { mContext = context.getApplicationContext(); mMaxHistorySize = maxHistorySize; - if (Looper.myLooper() == null) { - mHandler = new Handler(Looper.getMainLooper()); - } else { - mHandler = new Handler(); - } + mHandler = new Handler(); } /** @@ -107,56 +102,70 @@ public class WatchedHistoryManager { return; } mStarted = true; - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - mSharedPreferences = mContext.getSharedPreferences( - SharedPreferencesUtils.SHARED_PREF_WATCHED_HISTORY, Context.MODE_PRIVATE); - mLastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1); - if (mLastIndex >= 0 && mLastIndex < mMaxHistorySize) { - for (int i = 0; i <= mLastIndex; ++i) { - WatchedRecord record = - decode(mSharedPreferences.getString(getSharedPreferencesKey(i), - null)); - if (record != null) { - mWatchedHistory.add(record); - } - } - } else if (mLastIndex >= mMaxHistorySize) { - for (long i = mLastIndex - mMaxHistorySize + 1; i <= mLastIndex; ++i) { - WatchedRecord record = decode(mSharedPreferences.getString( - getSharedPreferencesKey(i), null)); - if (record != null) { - mWatchedHistory.add(record); - } - } + if (Looper.myLooper() == Looper.getMainLooper()) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + loadWatchedHistory(); + return null; } - return null; - } - @Override - protected void onPostExecute(Void params) { - mLoaded = true; - if (DEBUG) { - Log.d(TAG, "Loaded: size=" + mWatchedHistory.size() + " index=" + mLastIndex); + @Override + protected void onPostExecute(Void params) { + onLoadFinished(); } - if (!mPendingRecords.isEmpty()) { - Editor editor = mSharedPreferences.edit(); - for (WatchedRecord record : mPendingRecords) { - mWatchedHistory.add(record); - ++mLastIndex; - editor.putString(getSharedPreferencesKey(mLastIndex), encode(record)); - } - editor.putLong(PREF_KEY_LAST_INDEX, mLastIndex).apply(); - mPendingRecords.clear(); + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + loadWatchedHistory(); + onLoadFinished(); + } + } + + @WorkerThread + private void loadWatchedHistory() { + mSharedPreferences = mContext.getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_WATCHED_HISTORY, Context.MODE_PRIVATE); + mLastIndex = mSharedPreferences.getLong(PREF_KEY_LAST_INDEX, -1); + if (mLastIndex >= 0 && mLastIndex < mMaxHistorySize) { + for (int i = 0; i <= mLastIndex; ++i) { + WatchedRecord record = + decode(mSharedPreferences.getString(getSharedPreferencesKey(i), + null)); + if (record != null) { + mWatchedHistory.add(record); } - if (mListener != null) { - mListener.onLoadFinished(); + } + } else if (mLastIndex >= mMaxHistorySize) { + for (long i = mLastIndex - mMaxHistorySize + 1; i <= mLastIndex; ++i) { + WatchedRecord record = decode(mSharedPreferences.getString( + getSharedPreferencesKey(i), null)); + if (record != null) { + mWatchedHistory.add(record); } - mSharedPreferences.registerOnSharedPreferenceChangeListener( - mOnSharedPreferenceChangeListener); } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + private void onLoadFinished() { + mLoaded = true; + if (DEBUG) { + Log.d(TAG, "Loaded: size=" + mWatchedHistory.size() + " index=" + mLastIndex); + } + if (!mPendingRecords.isEmpty()) { + Editor editor = mSharedPreferences.edit(); + for (WatchedRecord record : mPendingRecords) { + mWatchedHistory.add(record); + ++mLastIndex; + editor.putString(getSharedPreferencesKey(mLastIndex), encode(record)); + } + editor.putLong(PREF_KEY_LAST_INDEX, mLastIndex).apply(); + mPendingRecords.clear(); + } + if (mListener != null) { + mListener.onLoadFinished(); + } + mSharedPreferences.registerOnSharedPreferenceChangeListener( + mOnSharedPreferenceChangeListener); } @VisibleForTesting @@ -204,52 +213,6 @@ public class WatchedHistoryManager { return Collections.unmodifiableList(mWatchedHistory); } - /** - * Returns the list of recently watched channels. - */ - public List<Channel> buildRecentChannel(ChannelDataManager channelDataManager, int maxCount) { - List<Channel> list = new ArrayList<>(); - Map<Long, Long> durationMap = new HashMap<>(); - for (int i = mWatchedHistory.size() - 1; i >= 0; --i) { - WatchedRecord record = mWatchedHistory.get(i); - long channelId = record.channelId; - Channel channel = channelDataManager.getChannel(channelId); - if (channel == null || !channel.isBrowsable()) { - continue; - } - Long duration = durationMap.get(channelId); - if (duration == null) { - duration = 0L; - } - if (duration >= RECENT_CHANNEL_THRESHOLD_MS) { - continue; - } - if (list.isEmpty()) { - // We put the first recent channel regardless of RECENT_CHANNEL_THREASHOLD. - // It has the similar functionality as the previous channel in a usual remote - // controller. - list.add(channel); - durationMap.put(channelId, RECENT_CHANNEL_THRESHOLD_MS); - } else { - duration += record.duration; - durationMap.put(channelId, duration); - if (duration >= RECENT_CHANNEL_THRESHOLD_MS) { - list.add(channel); - } - } - if (list.size() >= maxCount) { - break; - } - } - if (DEBUG) { - Log.d(TAG, "Build recent channel"); - for (Channel channel : list) { - Log.d(TAG, "recent channel: " + channel); - } - } - return list; - } - @VisibleForTesting WatchedRecord getRecord(int reverseIndex) { return mWatchedHistory.get(mWatchedHistory.size() - 1 - reverseIndex); diff --git a/src/com/android/tv/data/epg/EpgFetchHelper.java b/src/com/android/tv/data/epg/EpgFetchHelper.java new file mode 100644 index 00000000..5693c877 --- /dev/null +++ b/src/com/android/tv/data/epg/EpgFetchHelper.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2017 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.epg; + +import android.content.ContentProviderOperation; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.media.tv.TvContract; +import android.media.tv.TvContract.Programs; +import android.os.RemoteException; +import android.preference.PreferenceManager; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.data.Program; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** The helper class for {@link com.android.tv.data.epg.EpgFetcher} */ +class EpgFetchHelper { + private static final String TAG = "EpgFetchHelper"; + private static final boolean DEBUG = false; + + private static final long PROGRAM_QUERY_DURATION_MS = TimeUnit.DAYS.toMillis(30); + private static final int BATCH_OPERATION_COUNT = 100; + + // 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 long sLastEpgUpdatedTimestamp = -1; + private static String sLastLineupId; + + private EpgFetchHelper() { } + + /** + * Updates newly fetched EPG data for the given channel to local providers. The method will + * compare the broadcasting time and try to match each newly fetched program with old programs + * of that channel in the database one by one. It will update the matched old program, or insert + * the new program if there is no matching program can be found in the database and at the same + * time remove those old programs which conflicts with the inserted one. + + * @param channelId the target channel ID. + * @param fetchedPrograms the newly fetched program data. + * @return {@code true} if new program data are successfully updated. Otherwise {@code false}. + */ + static boolean updateEpgData(Context context, long channelId, List<Program> fetchedPrograms) { + final int fetchedProgramsCount = fetchedPrograms.size(); + if (fetchedProgramsCount == 0) { + return false; + } + boolean updated = false; + long startTimeMs = System.currentTimeMillis(); + long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION_MS; + List<Program> oldPrograms = queryPrograms(context, channelId, startTimeMs, endTimeMs); + int oldProgramsIndex = 0; + int newProgramsIndex = 0; + + // Compare the new programs with old programs one by one and update/delete the old one + // or insert new program if there is no matching program in the database. + ArrayList<ContentProviderOperation> ops = new ArrayList<>(); + while (newProgramsIndex < fetchedProgramsCount) { + Program oldProgram = oldProgramsIndex < oldPrograms.size() + ? oldPrograms.get(oldProgramsIndex) : null; + Program newProgram = fetchedPrograms.get(newProgramsIndex); + boolean addNewProgram = false; + if (oldProgram != null) { + if (oldProgram.equals(newProgram)) { + // Exact match. No need to update. Move on to the next programs. + oldProgramsIndex++; + newProgramsIndex++; + } else if (hasSameTitleAndOverlap(oldProgram, newProgram)) { + // 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(Program.toContentValues(newProgram)) + .build()); + oldProgramsIndex++; + newProgramsIndex++; + } else if (oldProgram.getEndTimeUtcMillis() < newProgram.getEndTimeUtcMillis()) { + // No match. Remove the old program first to see if the next program in + // {@code oldPrograms} partially matches the new program. + ops.add(ContentProviderOperation.newDelete( + TvContract.buildProgramUri(oldProgram.getId())) + .build()); + oldProgramsIndex++; + } else { + // No match. The new program does not match any of the old programs. Insert + // it as a new program. + addNewProgram = true; + newProgramsIndex++; + } + } else { + // No old programs. Just insert new programs. + addNewProgram = true; + newProgramsIndex++; + } + if (addNewProgram) { + ops.add(ContentProviderOperation + .newInsert(Programs.CONTENT_URI) + .withValues(Program.toContentValues(newProgram)) + .build()); + } + // Throttle the batch operation not to cause TransactionTooLargeException. + if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { + try { + if (DEBUG) { + int size = ops.size(); + Log.d(TAG, "Running " + size + " operations for channel " + channelId); + for (int i = 0; i < size; ++i) { + Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); + } + } + context.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); + updated = true; + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Failed to insert programs.", e); + return updated; + } + ops.clear(); + } + } + if (DEBUG) { + Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); + } + return updated; + } + + private static List<Program> queryPrograms(Context context, long channelId, + long startTimeMs, long endTimeMs) { + try (Cursor c = context.getContentResolver().query( + TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs), + Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) { + if (c == null) { + return Collections.emptyList(); + } + ArrayList<Program> programs = new ArrayList<>(); + while (c.moveToNext()) { + programs.add(Program.fromCursor(c)); + } + return programs; + } + } + + /** + * Returns {@code true} if the {@code oldProgram} needs to be updated with the + * {@code newProgram}. + */ + private static boolean hasSameTitleAndOverlap(Program oldProgram, Program newProgram) { + // NOTE: Here, we update the old program if it has the same title and overlaps with the + // new program. The test logic is just an example and you can modify this. E.g. check + // whether the both programs have the same program ID if your EPG supports any ID for + // the programs. + return TextUtils.equals(oldProgram.getTitle(), newProgram.getTitle()) + && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() + && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); + } + + /** + * Sets the last known lineup ID into shared preferences for future usage. If channels are not + * re-scanned, EPG fetcher can directly use this value instead of checking the correct lineup ID + * every time when it needs to fetch EPG data. + */ + @WorkerThread + synchronized static void setLastLineupId(Context context, String lineupId) { + if (DEBUG) { + if (lineupId == null) { + Log.d(TAG, "Clear stored lineup id: " + sLastLineupId); + } + } + sLastLineupId = lineupId; + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(KEY_LAST_LINEUP_ID, lineupId).apply(); + } + + /** + * Gets the last known lineup ID from shared preferences. + */ + synchronized static String getLastLineupId(Context context) { + if (sLastLineupId == null) { + sLastLineupId = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_LINEUP_ID, null); + } + if (DEBUG) Log.d(TAG, "Last lineup is " + sLastLineupId); + return sLastLineupId; + } + + /** + * Sets the last updated timestamp of EPG data into shared preferences. If the EPG data is not + * out-dated, it's not necessary for EPG fetcher to fetch EPG again. + */ + @WorkerThread + synchronized static void setLastEpgUpdatedTimestamp(Context context, long timestamp) { + sLastEpgUpdatedTimestamp = timestamp; + PreferenceManager.getDefaultSharedPreferences(context).edit().putLong( + KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).apply(); + } + + /** + * Gets the last updated timestamp of EPG data. + */ + synchronized static long getLastEpgUpdatedTimestamp(Context context) { + if (sLastEpgUpdatedTimestamp < 0) { + sLastEpgUpdatedTimestamp = PreferenceManager.getDefaultSharedPreferences(context) + .getLong(KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); + } + return sLastEpgUpdatedTimestamp; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java index 3b093b6a..24f8b826 100644 --- a/src/com/android/tv/data/epg/EpgFetcher.java +++ b/src/com/android/tv/data/epg/EpgFetcher.java @@ -16,570 +16,720 @@ package com.android.tv.data.epg; -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.ContentProviderOperation; -import android.content.ContentValues; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; 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.net.TrafficStats; +import android.os.AsyncTask; +import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; -import android.os.RemoteException; -import android.preference.PreferenceManager; +import android.support.annotation.AnyThread; import android.support.annotation.MainThread; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; +import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; +import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; import com.android.tv.TvApplication; -import com.android.tv.common.WeakHandler; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.config.RemoteConfigUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelDataManager; -import com.android.tv.data.InternalDataUtils; +import com.android.tv.data.ChannelLogoFetcher; import com.android.tv.data.Lineup; import com.android.tv.data.Program; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.TimerEvent; +import com.android.tv.tuner.util.PostalCodeUtils; import com.android.tv.util.LocationUtils; -import com.android.tv.util.RecurringRunner; +import com.android.tv.util.NetworkTrafficTags; import com.android.tv.util.Utils; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Objects; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; /** - * An utility class to fetch the EPG. This class isn't thread-safe. + * The service class to fetch EPG routinely or on-demand during channel scanning + * + * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one + * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on + * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}. */ public class EpgFetcher { private static final String TAG = "EpgFetcher"; private static final boolean DEBUG = false; - private static final int MSG_FETCH_EPG = 1; + private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101; + + private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10); + + private static final int REASON_EPG_READER_NOT_READY = 1; + private static final int REASON_LOCATION_INFO_UNAVAILABLE = 2; + private static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3; + private static final int REASON_NO_EPG_DATA_RETURNED = 4; + private static final int REASON_NO_NEW_EPG = 5; - 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 long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10); - private static final int BATCH_OPERATION_COUNT = 100; + private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3); + private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2); - private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry(); - private static final String CONTENT_RATING_SEPARATOR = ","; + private static final int DEFAULT_ROUTINE_INTERVAL_HOUR = 4; + private static final String KEY_ROUTINE_INTERVAL = "live_channels_epg_fetcher_interval_hour"; - // 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 final int MSG_PREPARE_FETCH_DURING_SCAN = 1; + private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2; + private static final int MSG_FINISH_FETCH_DURING_SCAN = 3; + private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4; + + private static final int QUERY_CHANNEL_COUNT = 50; + private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3; private static EpgFetcher sInstance; private final Context mContext; 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) { + private final PerformanceMonitor mPerformanceMonitor; + private FetchAsyncTask mFetchTask; + private FetchDuringScanHandler mFetchDuringScanHandler; + private long mEpgTimeStamp; + private List<Lineup> mPossibleLineups; + private final Object mPossibleLineupsLock = new Object(); + private final Object mFetchDuringScanHandlerLock = new Object(); + // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished. + private boolean mScanStarted; + + private final long mRoutineIntervalMs; + private final long mEpgDataExpiredTimeLimitMs; + private final long mFastFetchDurationSec; + + public static EpgFetcher getInstance(Context context) { if (sInstance == null) { - sInstance = new EpgFetcher(context.getApplicationContext()); + sInstance = new EpgFetcher(context); } return sInstance; } - /** - * Creates and returns {@link EpgReader}. - */ - public static EpgReader createEpgReader(Context context) { + /** Creates and returns {@link EpgReader}. */ + public static EpgReader createEpgReader(Context context, String region) { return new StubEpgReader(context); } private EpgFetcher(Context context) { - mContext = context; - mEpgReader = new StubEpgReader(mContext); - mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager(); - mChannelDataManager.addListener(new ChannelDataManager.Listener() { - @Override - public void onLoadFinished() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); - handleChannelChanged(); + mContext = context.getApplicationContext(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mChannelDataManager = applicationSingletons.getChannelDataManager(); + mPerformanceMonitor = applicationSingletons.getPerformanceMonitor(); + mEpgReader = createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)); + + int remoteInteval = + (int) RemoteConfigUtils.getRemoteConfig( + context, KEY_ROUTINE_INTERVAL, DEFAULT_ROUTINE_INTERVAL_HOUR); + mRoutineIntervalMs = + remoteInteval < 0 + ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR) + : TimeUnit.HOURS.toMillis(remoteInteval); + mEpgDataExpiredTimeLimitMs = mRoutineIntervalMs * 2; + mFastFetchDurationSec = FAST_FETCH_DURATION_SEC + mRoutineIntervalMs / 1000; + } + + /** + * Starts the routine service of EPG fetching. It use {@link JobScheduler} to schedule the EPG + * fetching routine. The EPG fetching routine will be started roughly every 4 hours, unless + * the channel scanning of tuner input is started. + */ + @MainThread + public void startRoutineService() { + JobScheduler jobScheduler = + (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); + for (JobInfo job : jobScheduler.getAllPendingJobs()) { + if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) { + return; } + } + JobInfo job = + new JobInfo.Builder( + EPG_ROUTINELY_FETCHING_JOB_ID, + new ComponentName(mContext, EpgFetchService.class)) + .setPeriodic(mRoutineIntervalMs) + .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL) + .setPersisted(true) + .build(); + jobScheduler.schedule(job); + Log.i(TAG, "EPG fetching routine service started."); + } + /** + * Fetches EPG immediately if current EPG data are out-dated, i.e., not successfully updated + * by routine fetching service due to various reasons. + */ + @MainThread + public void fetchImmediatelyIfNeeded() { + if (TvCommonUtils.isRunningInTest()) { + // Do not run EpgFetcher in test. + return; + } + new AsyncTask<Void, Void, Long>() { @Override - public void onChannelListUpdated() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); - handleChannelChanged(); + protected Long doInBackground(Void... args) { + return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext); } @Override - public void onChannelBrowsableChanged() { - if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()"); - handleChannelChanged(); + protected void onPostExecute(Long result) { + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + Log.i(TAG, "EPG data expired. Start fetching immediately."); + fetchImmediately(); + } } - }); + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - private void handleChannelChanged() { - if (mStarted) { - if (needToStop()) { - stop(); - } - } else { - start(); - } - } + /** + * Fetches EPG immediately. + */ + @MainThread + public void fetchImmediately() { + if (!mChannelDataManager.isDbLoadFinished()) { + mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + executeFetchTaskIfPossible(null, null); + } - private boolean needToStop() { - return !canStart(); - } + @Override + public void onChannelListUpdated() { } - 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; + @Override + public void onChannelBrowsableChanged() { } + }); + } else { + executeFetchTaskIfPossible(null, null); } + } - 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; + /** + * Notifies EPG fetch service that channel scanning is started. + */ + @MainThread + public void onChannelScanStarted() { + if (mScanStarted || !Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + return; } - - 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; + mScanStarted = true; + stopFetchingJob(); + synchronized (mFetchDuringScanHandlerLock) { + if (mFetchDuringScanHandler == null) { + HandlerThread thread = new HandlerThread("EpgFetchDuringScan"); + thread.start(); + mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper()); } - } 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); + mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN); } - return true; + Log.i(TAG, "EPG fetching on channel scanning started."); } /** - * Starts fetching EPG. + * Notifies EPG fetch service that channel scanning is finished. */ @MainThread - public void start() { - if (DEBUG) Log.d(TAG, "start()"); - if (mStarted) { - if (DEBUG) Log.d(TAG, "EpgFetcher thread already started."); + public void onChannelScanFinished() { + if (!mScanStarted) { return; } - if (!canStart()) { - return; + mScanStarted = false; + mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } + + @MainThread + private void stopFetchingJob() { + if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job..."); + if (mFetchTask != null) { + mFetchTask.cancel(true); + mFetchTask = null; + Log.i(TAG, "EPG routinely fetching job stopped."); } - 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(); + private boolean executeFetchTaskIfPossible(JobService service, JobParameters params) { + SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished()); + if (!TvCommonUtils.isRunningInTest() && checkFetchPrerequisite()) { + mFetchTask = new FetchAsyncTask(service, params); + mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + return true; } + return false; } - /** - * Stops fetching EPG. - */ @MainThread - public void stop() { - if (DEBUG) Log.d(TAG, "stop()"); - if (!mStarted) { - return; + private boolean checkFetchPrerequisite() { + if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job."); + if (!Features.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) { + Log.i(TAG, "Cannot start routine service: country not supported: " + + LocationUtils.getCurrentCountry(mContext)); + return false; + } + if (mFetchTask != null) { + // Fetching job is already running or ready to run, no need to start again. + return false; + } + if (mFetchDuringScanHandler != null) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels."); + return false; + } + if (getTunerChannelCount() == 0) { + if (DEBUG) Log.d(TAG, "Cannot start routine service: no internal tuner channels."); + return false; } - mStarted = false; - mRecurringRunner.stop(); - mHandler.removeCallbacksAndMessages(null); - mHandler.getLooper().quit(); + if (!TextUtils.isEmpty(EpgFetchHelper.getLastLineupId(mContext))) { + return true; + } + if (!TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return true; + } + return true; } - private void fetchEpg() { - fetchEpg(0); + @MainThread + private int getTunerChannelCount() { + for (TvInputInfo input : TvApplication.getSingletons(mContext) + .getTvInputManagerHelper().getTvInputInfos(true, true)) { + String inputId = input.getId(); + if (Utils.isInternalTvInput(mContext, inputId)) { + return mChannelDataManager.getChannelCountForInput(inputId); + } + } + return 0; } - private void fetchEpg(long delay) { - mHandler.removeMessages(MSG_FETCH_EPG); - mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay); + @AnyThread + private void clearUnusedLineups(@Nullable String lineupId) { + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return; + } + for (Lineup lineup : mPossibleLineups) { + if (!TextUtils.equals(lineupId, lineup.id)) { + mEpgReader.clearCachedChannels(lineup.id); + } + } + mPossibleLineups = null; + } } - private void onFetchEpg() { - if (DEBUG) Log.d(TAG, "Start fetching EPG."); + @WorkerThread + private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) { if (!mEpgReader.isAvailable()) { - if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available."); - fetchEpg(EPG_READER_INIT_WAIT_MS); - return; + Log.i(TAG, "EPG reader is temporarily unavailable."); + return REASON_EPG_READER_NOT_READY; } - 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; + // Checks the EPG Timestamp. + mEpgTimeStamp = mEpgReader.getEpgTimestamp(); + if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) { + if (DEBUG) Log.d(TAG, "No new EPG."); + return REASON_NO_NEW_EPG; + } + // Updates postal code. + boolean postalCodeChanged = false; + try { + postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext); + } catch (IOException e) { + if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_INFO_UNAVAILABLE; } - if (address == null) { - if (DEBUG) Log.d(TAG, "Null address returned."); - fetchEpg(LOCATION_INIT_WAIT_MS); - return; + } catch (SecurityException e) { + Log.w(TAG, "No permission to get the current location."); + if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) { + return REASON_LOCATION_PERMISSION_NOT_GRANTED; } - 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; + } catch (PostalCodeUtils.NoPostalCodeException e) { + Log.i(TAG, "Cannot get address or postal code."); + return REASON_LOCATION_INFO_UNAVAILABLE; + } + // Updates possible lineups if necessary. + SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset."); + if (postalCodeChanged || forceUpdatePossibleLineups + || EpgFetchHelper.getLastLineupId(mContext) == null) { + // To prevent main thread being blocked, though theoretically it should not happen. + List<Lineup> possibleLineups = + mEpgReader.getLineups(PostalCodeUtils.getLastPostalCode(mContext)); + if (possibleLineups.isEmpty()) { + return REASON_NO_EPG_DATA_RETURNED; + } + for (Lineup lineup : possibleLineups) { + mEpgReader.preloadChannels(lineup.id); + } + synchronized (mPossibleLineupsLock) { + mPossibleLineups = possibleLineups; } + EpgFetchHelper.setLastLineupId(mContext, null); } + return null; + } - // Check the EPG Timestamp. - long epgTimestamp = mEpgReader.getEpgTimestamp(); - if (epgTimestamp <= getLastUpdatedEpgTimestamp()) { - if (DEBUG) Log.d(TAG, "No new EPG."); + @WorkerThread + private void batchFetchEpg(List<Channel> channels, long durationSec) { + Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + channels.size()); + if (channels.size() == 0) { return; } - - boolean updated = false; - List<Channel> channels = mEpgReader.getChannels(lineupId); + List<Long> queryChannelIds = new ArrayList<>(QUERY_CHANNEL_COUNT); for (Channel channel : channels) { - List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId())); - Collections.sort(programs); - if (DEBUG) { - Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel); - } - if (updateEpg(channel.getId(), programs)) { - updated = true; + queryChannelIds.add(channel.getId()); + if (queryChannelIds.size() >= QUERY_CHANNEL_COUNT) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + queryChannelIds.clear(); } } - - final boolean epgUpdated = updated; - setLastUpdatedEpgTimestamp(epgTimestamp); - mHandler.removeMessages(MSG_FETCH_EPG); - if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + if (!queryChannelIds.isEmpty()) { + batchUpdateEpg(mEpgReader.getPrograms(queryChannelIds, durationSec)); + } } - @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); + @WorkerThread + private void batchUpdateEpg(Map<Long, List<Program>> allPrograms) { + for (Map.Entry<Long, List<Program>> entry : allPrograms.entrySet()) { + List<Program> programs = entry.getValue(); + if (programs == null) { + continue; } + Collections.sort(programs); + Log.i(TAG, "Batch fetched " + programs.size() + " programs for channel " + + entry.getKey()); + EpgFetchHelper.updateEpgData(mContext, entry.getKey(), programs); } - 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; + @WorkerThread + private String pickBestLineupId(List<Channel> currentChannelList) { + String maxLineupId = null; + synchronized (mPossibleLineupsLock) { + if (mPossibleLineups == null) { + return null; + } + int maxCount = 0; + for (Lineup lineup : mPossibleLineups) { + int count = getMatchedChannelCount(lineup.id, currentChannelList); + Log.i(TAG, lineup.name + " (" + lineup.id + ") - " + count + " matches"); + if (count > maxCount) { + maxCount = count; + maxLineupId = lineup.id; + } } } - return null; + return maxLineupId; } - private long getLastUpdatedEpgTimestamp() { - if (mLastEpgTimestamp < 0) { - mLastEpgTimestamp = PreferenceManager.getDefaultSharedPreferences(mContext).getLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, 0); + @WorkerThread + private int getMatchedChannelCount(String lineupId, List<Channel> currentChannelList) { + // Construct a list of display numbers for existing channels. + if (currentChannelList.isEmpty()) { + if (DEBUG) Log.d(TAG, "No existing channel to compare"); + return 0; + } + List<String> numbers = new ArrayList<>(currentChannelList.size()); + for (Channel channel : currentChannelList) { + // We only support channels from internal tuner inputs. + if (Utils.isInternalTvInput(mContext, channel.getInputId())) { + numbers.add(channel.getDisplayNumber()); + } } - return mLastEpgTimestamp; + numbers.retainAll(mEpgReader.getChannelNumbers(lineupId)); + return numbers.size(); } - private void setLastUpdatedEpgTimestamp(long timestamp) { - mLastEpgTimestamp = timestamp; - PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong( - KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit(); - } + public static class EpgFetchService extends JobService { + private EpgFetcher mEpgFetcher; - private String getLastLineupId() { - if (mLineupId == null) { - mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext) - .getString(KEY_LAST_LINEUP_ID, null); + @Override + public void onCreate() { + super.onCreate(); + TvApplication.setCurrentRunningProcess(this, true); + mEpgFetcher = EpgFetcher.getInstance(this); } - if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId); - return mLineupId; - } - private void setLastLineupId(String lineupId) { - mLineupId = lineupId; - PreferenceManager.getDefaultSharedPreferences(mContext).edit() - .putString(KEY_LAST_LINEUP_ID, lineupId).commit(); - } + @Override + public boolean onStartJob(JobParameters params) { + if (!mEpgFetcher.mChannelDataManager.isDbLoadFinished()) { + mEpgFetcher.mChannelDataManager.addListener(new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + mEpgFetcher.mChannelDataManager.removeListener(this); + if (!mEpgFetcher.executeFetchTaskIfPossible(EpgFetchService.this, params)) { + jobFinished(params, false); + } + } - private boolean updateEpg(long channelId, List<Program> newPrograms) { - final int fetchedProgramsCount = newPrograms.size(); - if (fetchedProgramsCount == 0) { + @Override + public void onChannelListUpdated() { } + + @Override + public void onChannelBrowsableChanged() { } + }); + return true; + } else { + return mEpgFetcher.executeFetchTaskIfPossible(this, params); + } + } + + @Override + public boolean onStopJob(JobParameters params) { + mEpgFetcher.stopFetchingJob(); return false; } - boolean updated = false; - long startTimeMs = System.currentTimeMillis(); - long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION; - List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs); - Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null; - int oldProgramsIndex = 0; - int newProgramsIndex = 0; - // Skip the past programs. They will be automatically removed by the system. - if (currentOldProgram != null) { - long oldStartTimeUtcMillis = currentOldProgram.getStartTimeUtcMillis(); - for (Program program : newPrograms) { - if (program.getEndTimeUtcMillis() > oldStartTimeUtcMillis) { - break; - } - newProgramsIndex++; - } + } + + private class FetchAsyncTask extends AsyncTask<Void, Void, Integer> { + private final JobService mService; + private final JobParameters mParams; + private List<Channel> mCurrentChannelList; + private TimerEvent mTimerEvent; + + private FetchAsyncTask(JobService service, JobParameters params) { + mService = service; + mParams = params; } - // Compare the new programs with old programs one by one and update/delete the old one - // or insert new program if there is no matching program in the database. - ArrayList<ContentProviderOperation> ops = new ArrayList<>(); - while (newProgramsIndex < fetchedProgramsCount) { - // TODO: Extract to method and make test. - Program oldProgram = oldProgramsIndex < oldPrograms.size() - ? oldPrograms.get(oldProgramsIndex) : null; - Program newProgram = newPrograms.get(newProgramsIndex); - boolean addNewProgram = false; - if (oldProgram != null) { - if (oldProgram.equals(newProgram)) { - // Exact match. No need to update. Move on to the next programs. - oldProgramsIndex++; - newProgramsIndex++; - } else if (isSameTitleAndOverlap(oldProgram, newProgram)) { - // 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() - < newProgram.getEndTimeUtcMillis()) { - // No match. Remove the old program first to see if the next program in - // {@code oldPrograms} partially matches the new program. - ops.add(ContentProviderOperation.newDelete( - TvContract.buildProgramUri(oldProgram.getId())) - .build()); - oldProgramsIndex++; + + @Override + protected void onPreExecute() { + mTimerEvent = mPerformanceMonitor.startTimer(); + mCurrentChannelList = mChannelDataManager.getChannelList(); + } + + @Override + protected Integer doInBackground(Void... args) { + final int oldTag = TrafficStats.getThreadStatsTag(); + TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH); + try { + if (DEBUG) Log.d(TAG, "Start EPG routinely fetching."); + Integer failureReason = prepareFetchEpg(false); + // InterruptedException might be caught by RPC, we should check it here. + if (failureReason != null || this.isCancelled()) { + return failureReason; + } + String lineupId = EpgFetchHelper.getLastLineupId(mContext); + lineupId = lineupId == null ? pickBestLineupId(mCurrentChannelList) : lineupId; + if (lineupId != null) { + Log.i(TAG, "Selecting the lineup " + lineupId); + // During normal fetching process, the lineup ID should be confirmed since all + // channels are known, clear up possible lineups to save resources. + EpgFetchHelper.setLastLineupId(mContext, lineupId); + clearUnusedLineups(lineupId); } else { - // No match. The new program does not match any of the old programs. Insert - // it as a new program. - addNewProgram = true; - newProgramsIndex++; + Log.i(TAG, "Failed to get lineup id"); + return REASON_NO_EPG_DATA_RETURNED; } - } else { - // No old programs. Just insert new programs. - addNewProgram = true; - newProgramsIndex++; - } - if (addNewProgram) { - ops.add(ContentProviderOperation - .newInsert(TvContract.Programs.CONTENT_URI) - .withValues(toContentValues(newProgram)) - .build()); - } - // Throttle the batch operation not to cause TransactionTooLargeException. - if (ops.size() > BATCH_OPERATION_COUNT || newProgramsIndex >= fetchedProgramsCount) { - try { - if (DEBUG) { - int size = ops.size(); - Log.d(TAG, "Running " + size + " operations for channel " + channelId); - for (int i = 0; i < size; ++i) { - Log.d(TAG, "Operation(" + i + "): " + ops.get(i)); - } + final List<Channel> channels = mEpgReader.getChannels(lineupId); + // InterruptedException might be caught by RPC, we should check it here. + if (this.isCancelled()) { + return null; + } + if (channels.isEmpty()) { + Log.i(TAG, "Failed to get EPG channels."); + return REASON_NO_EPG_DATA_RETURNED; + } + if (System.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext) + > mEpgDataExpiredTimeLimitMs) { + batchFetchEpg(channels, mFastFetchDurationSec); + } + new Handler(mContext.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + ChannelLogoFetcher.startFetchingChannelLogos( + mContext, channels); + } + }); + for (Channel channel : channels) { + if (this.isCancelled()) { + return null; } - mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops); - updated = true; - } catch (RemoteException | OperationApplicationException e) { - Log.e(TAG, "Failed to insert programs.", e); - return updated; + long channelId = channel.getId(); + List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channelId)); + // InterruptedException might be caught by RPC, we should check it here. + Collections.sort(programs); + Log.i(TAG, "Fetched " + programs.size() + " programs for channel " + channelId); + EpgFetchHelper.updateEpgData(mContext, channelId, programs); } - ops.clear(); + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp); + if (DEBUG) Log.d(TAG, "Fetching EPG is finished."); + return null; + } finally { + TrafficStats.setThreadStatsTag(oldTag); } } - if (DEBUG) { - Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId); - } - return updated; - } - 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.emptyList(); - } - ArrayList<Program> programs = new ArrayList<>(); - while (c.moveToNext()) { - programs.add(Program.fromCursor(c)); + @Override + protected void onPostExecute(Integer failureReason) { + mFetchTask = null; + if (failureReason == null || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED + || failureReason == REASON_NO_NEW_EPG) { + jobFinished(false); + } else { + // Applies back-off policy + jobFinished(true); } - return programs; + mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK); + mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK); } - } - /** - * Returns {@code true} if the {@code oldProgram} program needs to be updated with the - * {@code newProgram} program. - */ - private boolean isSameTitleAndOverlap(Program oldProgram, Program newProgram) { - // NOTE: Here, we update the old program if it has the same title and overlaps with the - // new program. The test logic is just an example and you can modify this. E.g. check - // whether the both programs have the same program ID if your EPG supports any ID for - // the programs. - return Objects.equals(oldProgram.getTitle(), newProgram.getTitle()) - && oldProgram.getStartTimeUtcMillis() <= newProgram.getEndTimeUtcMillis() - && newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis(); - } + @Override + protected void onCancelled(Integer failureReason) { + clearUnusedLineups(null); + jobFinished(false); + } - @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()); - 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()); + private void jobFinished(boolean reschedule) { + if (mService != null && mParams != null) { + // Task is executed from JobService, need to report jobFinished. + mService.jobFinished(mParams, reschedule); } - 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; - } - - private static void putValue(ContentValues contentValues, String key, String value) { - if (TextUtils.isEmpty(value)) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); } } - private static void putValue(ContentValues contentValues, String key, byte[] value) { - if (value == null || value.length == 0) { - contentValues.putNull(key); - } else { - contentValues.put(key, value); - } - } + @WorkerThread + private class FetchDuringScanHandler extends Handler { + private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>(); + private String mPossibleLineupId; + + private final ChannelDataManager.Listener mDuringScanChannelListener = + new ChannelDataManager.Listener() { + @Override + public void onLoadFinished() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, new ArrayList<>( + mChannelDataManager.getChannelList())).sendToTarget(); + } + } + + @Override + public void onChannelListUpdated() { + if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()"); + if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP + && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + Message.obtain(FetchDuringScanHandler.this, + MSG_CHANNEL_UPDATED_DURING_SCAN, + mChannelDataManager.getChannelList()).sendToTarget(); + } + } + + @Override + public void onChannelBrowsableChanged() { + // Do nothing + } + }; - private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> { - public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) { - super(looper, ref); + @AnyThread + private FetchDuringScanHandler(Looper looper) { + super(looper); } @Override - public void handleMessage(Message msg, @NonNull EpgFetcher epgFetcher) { + public void handleMessage(Message msg) { switch (msg.what) { - case MSG_FETCH_EPG: - epgFetcher.onFetchEpg(); + case MSG_PREPARE_FETCH_DURING_SCAN: + case MSG_RETRY_PREPARE_FETCH_DURING_SCAN: + onPrepareFetchDuringScan(); break; - default: - super.handleMessage(msg); + case MSG_CHANNEL_UPDATED_DURING_SCAN: + if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + onChannelUpdatedDuringScan((List<Channel>) msg.obj); + } + break; + case MSG_FINISH_FETCH_DURING_SCAN: + removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN); + if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) { + sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN); + } else { + onFinishFetchDuringScan(); + } break; } } - } - private class EpgRunner implements Runnable { - @Override - public void run() { - fetchEpg(); + private void onPrepareFetchDuringScan() { + Integer failureReason = prepareFetchEpg(true); + if (failureReason != null) { + sendEmptyMessageDelayed( + MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS); + return; + } + mChannelDataManager.addListener(mDuringScanChannelListener); + } + + private void onChannelUpdatedDuringScan(List<Channel> currentChannelList) { + String lineupId = pickBestLineupId(currentChannelList); + Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId); + if (TextUtils.isEmpty(lineupId)) { + if (TextUtils.isEmpty(mPossibleLineupId)) { + return; + } + } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) { + mFetchedChannelIdsDuringScan.clear(); + mPossibleLineupId = lineupId; + } + List<Long> currentChannelIds = new ArrayList<>(); + for (Channel channel : currentChannelList) { + currentChannelIds.add(channel.getId()); + } + mFetchedChannelIdsDuringScan.retainAll(currentChannelIds); + List<Channel> newChannels = new ArrayList<>(); + for (Channel channel : mEpgReader.getChannels(mPossibleLineupId)) { + if (!mFetchedChannelIdsDuringScan.contains(channel.getId())) { + newChannels.add(channel); + mFetchedChannelIdsDuringScan.add(channel.getId()); + } + } + batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC); + } + + private void onFinishFetchDuringScan() { + mChannelDataManager.removeListener(mDuringScanChannelListener); + EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId); + clearUnusedLineups(null); + mFetchedChannelIdsDuringScan.clear(); + synchronized (mFetchDuringScanHandlerLock) { + if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) { + removeCallbacksAndMessages(null); + getLooper().quit(); + mFetchDuringScanHandler = null; + } + } + // Clear timestamp to make routine service start right away. + EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0); + Log.i(TAG, "EPG Fetching during channel scanning finished."); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + fetchImmediately(); + } + }); } } } diff --git a/src/com/android/tv/data/epg/EpgReader.java b/src/com/android/tv/data/epg/EpgReader.java index 4f3b6f52..c5aeca27 100644 --- a/src/com/android/tv/data/epg/EpgReader.java +++ b/src/com/android/tv/data/epg/EpgReader.java @@ -16,15 +16,17 @@ package com.android.tv.data.epg; +import android.support.annotation.AnyThread; 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 com.android.tv.dvr.data.SeriesInfo; import java.util.List; +import java.util.Map; /** * An interface used to retrieve the EPG data. This class should be used in worker thread. @@ -42,25 +44,48 @@ public interface EpgReader { */ long getEpgTimestamp(); + /** Sets the region code. */ + void setRegionCode(String regionCode); + + /** Returns the lineups list. */ + List<Lineup> getLineups(@NonNull String postalCode); + /** - * Returns the channels list. + * Returns the list of channel numbers (unsorted) for the given lineup. The result is used to + * choose the most appropriate lineup among others by comparing the channel numbers of the + * existing channels on the device. + */ + List<String> getChannelNumbers(@NonNull String lineupId); + + /** + * Returns the list of channels for the given lineup. The returned channels should map into the + * existing channels on the device. This method is usually called after selecting the lineup. */ List<Channel> getChannels(@NonNull String lineupId); + /** Pre-loads and caches channels for a given lineup. */ + void preloadChannels(@NonNull String lineupId); + /** - * Returns the lineups list. + * Clears cached channels for a given lineup. */ - List<Lineup> getLineups(@NonNull String postalCode); + @AnyThread + void clearCachedChannels(@NonNull String lineupId); /** - * Returns the programs for the given channel. The result is sorted by the start time. - * Note that the {@code Program} doesn't have valid program ID because it's not retrieved from - * TvProvider. + * Returns the programs for the given channel. Must call {@link #getChannels(String)} + * beforehand. Note that the {@code Program} doesn't have valid program ID because it's not + * retrieved from TvProvider. */ List<Program> getPrograms(long channelId); /** - * Returns the series information for the given series ID. + * Returns the programs for the given channels. Note that the {@code Program} doesn't have valid + * program ID because it's not retrieved from TvProvider. This method is only used to get + * programs for a short duration typically. */ - SeriesInfo getSeriesInfo(String seriesId); -} + Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration); + + /** Returns the series information for the given series ID. */ + SeriesInfo getSeriesInfo(@NonNull String seriesId); +}
\ No newline at end of file diff --git a/src/com/android/tv/data/epg/StubEpgReader.java b/src/com/android/tv/data/epg/StubEpgReader.java index 64093f89..ab6935ad 100644 --- a/src/com/android/tv/data/epg/StubEpgReader.java +++ b/src/com/android/tv/data/epg/StubEpgReader.java @@ -18,13 +18,15 @@ package com.android.tv.data.epg; import android.content.Context; +import android.support.annotation.NonNull; 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 com.android.tv.dvr.data.SeriesInfo; import java.util.Collections; import java.util.List; +import java.util.Map; /** * A stub class to read EPG. @@ -44,22 +46,47 @@ public class StubEpgReader implements EpgReader{ } @Override - public List<Channel> getChannels(String lineupId) { + public void setRegionCode(String regionCode) { + // Do nothing + } + + @Override + public List<Lineup> getLineups(@NonNull String postalCode) { + return Collections.emptyList(); + } + + @Override + public List<String> getChannelNumbers(@NonNull String lineupId) { return Collections.emptyList(); } @Override - public List<Lineup> getLineups(String postalCode) { + public List<Channel> getChannels(@NonNull String lineupId) { return Collections.emptyList(); } @Override + public void preloadChannels(@NonNull String lineupId) { + // Do nothing + } + + @Override + public void clearCachedChannels(@NonNull String lineupId) { + // Do nothing + } + + @Override public List<Program> getPrograms(long channelId) { return Collections.emptyList(); } @Override - public SeriesInfo getSeriesInfo(String seriesId) { + public Map<Long, List<Program>> getPrograms(@NonNull List<Long> channelIds, long duration) { + return Collections.emptyMap(); + } + + @Override + public SeriesInfo getSeriesInfo(@NonNull String seriesId) { return null; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dialog/DvrHistoryDialogFragment.java b/src/com/android/tv/dialog/DvrHistoryDialogFragment.java new file mode 100644 index 00000000..d686e6e6 --- /dev/null +++ b/src/com/android/tv/dialog/DvrHistoryDialogFragment.java @@ -0,0 +1,130 @@ +/* + * 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.dialog; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.app.Dialog; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +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.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.RecordingState; +import com.android.tv.dvr.ui.DvrUiHelper; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays the DVR history. + */ +@TargetApi(VERSION_CODES.N) +public class DvrHistoryDialogFragment extends SafeDismissDialogFragment { + public static final String DIALOG_TAG = DvrHistoryDialogFragment.class.getSimpleName(); + + private static final String TRACKER_LABEL = "DVR history"; + private final List<ScheduledRecording> mSchedules = new ArrayList<>(); + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); + DvrDataManager dataManager = singletons.getDvrDataManager(); + ChannelDataManager channelDataManager = singletons.getChannelDataManager(); + for (ScheduledRecording schedule : dataManager.getAllScheduledRecordings()) { + if (!schedule.isInProgress() && !schedule.isNotStarted()) { + mSchedules.add(schedule); + } + } + mSchedules.sort(ScheduledRecording.START_TIME_COMPARATOR.reversed()); + LayoutInflater inflater = LayoutInflater.from(getContext()); + ArrayAdapter adapter = new ArrayAdapter<ScheduledRecording>(getContext(), + R.layout.list_item_dvr_history, ScheduledRecording.toArray(mSchedules)) { + @NonNull + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = inflater.inflate(R.layout.list_item_dvr_history, parent, false); + ScheduledRecording schedule = mSchedules.get(position); + setText(view, R.id.state, getStateString(schedule.getState())); + setText(view, R.id.schedule_time, getRecordingTimeText(schedule)); + setText(view, R.id.program_title, DvrUiHelper.getStyledTitleWithEpisodeNumber( + getContext(), schedule, 0)); + setText(view, R.id.channel_name, getChannelNameText(schedule)); + return view; + } + + private void setText(View view, int id, CharSequence text) { + ((TextView) view.findViewById(id)).setText(text); + } + + private void setText(View view, int id, int text) { + ((TextView) view.findViewById(id)).setText(text); + } + + @SuppressLint("SwitchIntDef") + private int getStateString(@RecordingState int state) { + switch (state) { + case ScheduledRecording.STATE_RECORDING_CLIPPED: + return R.string.dvr_history_dialog_state_clip; + case ScheduledRecording.STATE_RECORDING_FAILED: + return R.string.dvr_history_dialog_state_fail; + case ScheduledRecording.STATE_RECORDING_FINISHED: + return R.string.dvr_history_dialog_state_success; + default: + break; + } + return 0; + } + + private String getChannelNameText(ScheduledRecording schedule) { + Channel channel = channelDataManager.getChannel(schedule.getChannelId()); + return channel == null ? null : + TextUtils.isEmpty(channel.getDisplayName()) ? channel.getDisplayNumber() : + channel.getDisplayName().trim() + " " + channel.getDisplayNumber(); + } + + private String getRecordingTimeText(ScheduledRecording schedule) { + return Utils.getDurationString(getContext(), schedule.getStartTimeMs(), + schedule.getEndTimeMs(), true, true, true, 0); + } + }; + ListView listView = new ListView(getActivity()); + listView.setAdapter(adapter); + return new AlertDialog.Builder(getActivity()).setTitle(R.string.dvr_history_dialog_title) + .setView(listView).create(); + } + + @Override + public String getTrackerLabel() { + return TRACKER_LABEL; + } +} diff --git a/src/com/android/tv/dialog/FullscreenDialogFragment.java b/src/com/android/tv/dialog/FullscreenDialogFragment.java index d16202a1..d00422a7 100644 --- a/src/com/android/tv/dialog/FullscreenDialogFragment.java +++ b/src/com/android/tv/dialog/FullscreenDialogFragment.java @@ -77,7 +77,7 @@ public class FullscreenDialogFragment extends SafeDismissDialogFragment { return mTrackerLabel; } - private class FullscreenDialog extends TvDialog { + private class FullscreenDialog extends Dialog { public FullscreenDialog(Context context, int theme) { super(context, theme); } diff --git a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java b/src/com/android/tv/dialog/HalfSizedDialogFragment.java index d320816e..315c6a93 100644 --- a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java +++ b/src/com/android/tv/dialog/HalfSizedDialogFragment.java @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.dialog; +import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; import android.os.Handler; @@ -25,7 +26,6 @@ import android.view.View; import android.view.ViewGroup; import com.android.tv.R; -import com.android.tv.dialog.SafeDismissDialogFragment; import java.util.concurrent.TimeUnit; @@ -54,13 +54,6 @@ public class HalfSizedDialogFragment extends SafeDismissDialogFragment { @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); } @@ -81,6 +74,19 @@ public class HalfSizedDialogFragment extends SafeDismissDialogFragment { } @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.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; + } + }); + return dialog; + } + + @Override public int getTheme() { return R.style.Theme_TV_dialog_HalfSizedDialog; } diff --git a/src/com/android/tv/dialog/PinDialogFragment.java b/src/com/android/tv/dialog/PinDialogFragment.java index d9d6c73f..d5c154da 100644 --- a/src/com/android/tv/dialog/PinDialogFragment.java +++ b/src/com/android/tv/dialog/PinDialogFragment.java @@ -28,6 +28,7 @@ import android.content.Context; import android.content.DialogInterface; import android.content.SharedPreferences; import android.content.res.Resources; +import android.media.tv.TvContentRating; import android.os.Bundle; import android.os.Handler; import android.preference.PreferenceManager; @@ -45,11 +46,13 @@ 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.util.TvSettings; public class PinDialogFragment extends SafeDismissDialogFragment { private static final String TAG = "PinDialogFragment"; - private static final boolean DBG = true; + private static final boolean DEBUG = true; /** * PIN code dialog for unlock channel @@ -80,18 +83,13 @@ public class PinDialogFragment extends SafeDismissDialogFragment { */ 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; - private static final int MAX_WRONG_PIN_COUNT = 5; private static final int DISABLE_PIN_DURATION_MILLIS = 60 * 1000; // 1 minute private static final String INITIAL_TEXT = "—"; private static final String TRACKER_LABEL = "Pin dialog"; - - public interface ResultListener { - void done(boolean success); - } + private static final String ARGS_TYPE = "args_type"; + private static final String ARGS_RATING = "args_rating"; public static final String DIALOG_TAG = PinDialogFragment.class.getName(); @@ -99,8 +97,9 @@ public class PinDialogFragment extends SafeDismissDialogFragment { R.id.first, R.id.second, R.id.third, R.id.fourth }; private int mType; - private ResultListener mListener; - private int mRetCode; + private int mRequestType; + private boolean mPinChecked; + private boolean mDismissSilently; private TextView mWrongPinView; private View mEnterPinView; @@ -114,29 +113,35 @@ public class PinDialogFragment extends SafeDismissDialogFragment { private long mDisablePinUntil; private final Handler mHandler = new Handler(); - public PinDialogFragment(int type, ResultListener listener) { - this(type, listener, null); + public static PinDialogFragment create(int type) { + return create(type, null); } - public PinDialogFragment(int type, ResultListener listener, String rating) { - mType = type; - mListener = listener; - mRetCode = PIN_DIALOG_RESULT_FAIL; - mRatingString = rating; + public static PinDialogFragment create(int type, String rating) { + PinDialogFragment fragment = new PinDialogFragment(); + Bundle args = new Bundle(); + args.putInt(ARGS_TYPE, type); + args.putString(ARGS_RATING, rating); + fragment.setArguments(args); + return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + mRequestType = getArguments().getInt(ARGS_TYPE, PIN_DIALOG_TYPE_ENTER_PIN); + mType = mRequestType; + mRatingString = getArguments().getString(ARGS_RATING); setStyle(STYLE_NO_TITLE, 0); mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); mDisablePinUntil = TvSettings.getDisablePinUntil(getActivity()); if (ActivityManager.isUserAMonkey()) { // Skip PIN dialog half the time for monkeys if (Math.random() < 0.5) { - exit(PIN_DIALOG_RESULT_SUCCESS); + exit(true); } } + mPinChecked = false; } @Override @@ -186,7 +191,19 @@ public class PinDialogFragment extends SafeDismissDialogFragment { 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)); + TvContentRating tvContentRating = + TvContentRating.unflattenFromString(mRatingString); + if (TvContentRating.UNRATED.equals(tvContentRating)) { + mTitleView.setText(getString(R.string.pin_enter_unlock_dvr_unrated)); + } else { + mTitleView.setText( + getString( + R.string.pin_enter_unlock_dvr, + TvApplication.getSingletons(getContext()) + .getTvInputManagerHelper() + .getContentRatingsManager() + .getDisplayNameForRating(tvContentRating))); + } break; case PIN_DIALOG_TYPE_ENTER_PIN: mTitleView.setText(R.string.pin_enter_pin); @@ -217,10 +234,6 @@ public class PinDialogFragment extends SafeDismissDialogFragment { return v; } - public void setResultListener(ResultListener listener) { - mListener = listener; - } - private final Runnable mUpdateEnterPinRunnable = new Runnable() { @Override public void run() { @@ -250,18 +263,27 @@ public class PinDialogFragment extends SafeDismissDialogFragment { } } - private void exit(int retCode) { - mRetCode = retCode; + private void exit(boolean pinChecked) { + mPinChecked = pinChecked; + dismiss(); + } + + /** Dismisses the pin dialog without calling activity listener. */ + public void dismissSilently() { + mDismissSilently = true; dismiss(); } @Override public void onDismiss(DialogInterface dialog) { super.onDismiss(dialog); - if (DBG) Log.d(TAG, "onDismiss: mRetCode=" + mRetCode); - if (mListener != null) { - mListener.done(mRetCode == PIN_DIALOG_RESULT_SUCCESS); + if (DEBUG) Log.d(TAG, "onDismiss: mPinChecked=" + mPinChecked); + SoftPreconditions.checkState(getActivity() instanceof OnPinCheckedListener); + if (!mDismissSilently && getActivity() instanceof OnPinCheckedListener) { + ((OnPinCheckedListener) getActivity()).onPinChecked( + mPinChecked, mRequestType, mRatingString); } + mDismissSilently = false; } private void handleWrongPin() { @@ -279,15 +301,14 @@ public class PinDialogFragment extends SafeDismissDialogFragment { } private void done(String pin) { - if (DBG) Log.d(TAG, "done: mType=" + mType + " pin=" + pin + " stored=" + getPin()); + if (DEBUG) Log.d(TAG, "done: mType=" + mType + " pin=" + pin + " stored=" + getPin()); 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())) { - exit(PIN_DIALOG_RESULT_SUCCESS); + exit(true); } else { resetPinInput(); handleWrongPin(); @@ -301,7 +322,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment { } else { if (pin.equals(mPrevPin)) { setPin(pin); - exit(PIN_DIALOG_RESULT_SUCCESS); + exit(true); } else { if (TextUtils.isEmpty(getPin())) { mTitleView.setText(R.string.pin_enter_create_pin); @@ -332,7 +353,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment { } private void setPin(String pin) { - if (DBG) Log.d(TAG, "setPin: " + pin); + if (DEBUG) Log.d(TAG, "setPin: " + pin); mPin = pin; mSharedPreferences.edit().putString(TvSettings.PREF_PIN, pin).apply(); } @@ -684,4 +705,20 @@ public class PinDialogFragment extends SafeDismissDialogFragment { : (value > mMaxValue) ? value - interval : value; } } + + /** + * A listener to the result of {@link PinDialogFragment}. Any activity requiring pin code + * checking should implement this listener to receive the result. + */ + public interface OnPinCheckedListener { + /** + * Called when {@link PinDialogFragment} is dismissed. + * + * @param checked {@code true} if the pin code entered is checked to be correct, + * otherwise {@code false}. + * @param type The dialog type regarding to what pin entering is for. + * @param rating The target rating to unblock for. + */ + void onPinChecked(boolean checked, int type, String rating); + } } diff --git a/src/com/android/tv/dialog/SafeDismissDialogFragment.java b/src/com/android/tv/dialog/SafeDismissDialogFragment.java index f671a87d..e3390b0a 100644 --- a/src/com/android/tv/dialog/SafeDismissDialogFragment.java +++ b/src/com/android/tv/dialog/SafeDismissDialogFragment.java @@ -17,11 +17,7 @@ package com.android.tv.dialog; import android.app.Activity; -import android.app.Dialog; import android.app.DialogFragment; -import android.content.Context; -import android.os.Bundle; -import android.view.KeyEvent; import com.android.tv.MainActivity; import com.android.tv.TvApplication; @@ -39,11 +35,6 @@ public abstract class SafeDismissDialogFragment extends DialogFragment private Tracker mTracker; @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - return new TvDialog(getActivity(), getTheme()); - } - - @Override public void onAttach(Activity activity) { super.onAttach(activity); mAttached = true; @@ -92,21 +83,4 @@ public abstract class SafeDismissDialogFragment extends DialogFragment super.dismiss(); } } - - protected class TvDialog extends Dialog { - public TvDialog(Context context, int theme) { - super(context, theme); - } - - @Override - 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 && mActivity != null) { - mActivity.showSearchActivity(); - return true; - } - return super.onKeyUp(keyCode, event); - } - } } diff --git a/src/com/android/tv/dialog/WebDialogFragment.java b/src/com/android/tv/dialog/WebDialogFragment.java index 75f93bb2..171a256b 100644 --- a/src/com/android/tv/dialog/WebDialogFragment.java +++ b/src/com/android/tv/dialog/WebDialogFragment.java @@ -37,6 +37,7 @@ public class WebDialogFragment extends SafeDismissDialogFragment { private static final String TITLE = "TITLE"; private static final String TRACKER_LABEL = "TRACKER_LABEL"; + private WebView mWebView; private String mTrackerLabel; /** @@ -73,13 +74,21 @@ public class WebDialogFragment extends SafeDismissDialogFragment { String title = getArguments().getString(TITLE); getDialog().setTitle(title); - WebView webView = new WebView(getActivity()); - webView.setWebViewClient(new WebViewClient()); + mWebView = new WebView(getActivity()); + mWebView.setWebViewClient(new WebViewClient()); String url = getArguments().getString(URL); - webView.loadUrl(url); + mWebView.loadUrl(url); Log.d(TAG, "Loading web content from " + url); - return webView; + return mWebView; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (mWebView != null) { + mWebView.destroy(); + } } @Override diff --git a/src/com/android/tv/dvr/BaseDvrDataManager.java b/src/com/android/tv/dvr/BaseDvrDataManager.java index 89661df3..a8637449 100644 --- a/src/com/android/tv/dvr/BaseDvrDataManager.java +++ b/src/com/android/tv/dvr/BaseDvrDataManager.java @@ -26,7 +26,10 @@ import android.util.Log; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; -import com.android.tv.dvr.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.Clock; import java.util.ArrayList; @@ -318,5 +321,41 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager { } @Override + public void checkAndRemoveEmptySeriesRecording(long... seriesRecordingIds) { + List<SeriesRecording> toRemove = new ArrayList<>(); + for (long rId : seriesRecordingIds) { + SeriesRecording seriesRecording = getSeriesRecording(rId); + if (seriesRecording != null && isEmptySeriesRecording(seriesRecording)) { + toRemove.add(seriesRecording); + } + } + removeSeriesRecording(SeriesRecording.toArray(toRemove)); + } + + /** + * Returns {@code true}, if the series recording is empty and can be removed. If a series + * recording is in NORMAL state or has recordings or schedules, it is not empty and cannot be + * removed. + */ + protected final boolean isEmptySeriesRecording(@NonNull SeriesRecording seriesRecording) { + if (!seriesRecording.isStopped()) { + return false; + } + long seriesRecordingId = seriesRecording.getId(); + for (ScheduledRecording r : getAvailableScheduledRecordings()) { + if (r.getSeriesRecordingId() == seriesRecordingId) { + return false; + } + } + String seriesId = seriesRecording.getSeriesId(); + for (RecordedProgram r : getRecordedPrograms()) { + if (seriesId.equals(r.getSeriesId())) { + return false; + } + } + return true; + } + + @Override public void forgetStorage(String inputId) { } } diff --git a/src/com/android/tv/dvr/DvrDataManager.java b/src/com/android/tv/dvr/DvrDataManager.java index 06613667..6d400b82 100644 --- a/src/com/android/tv/dvr/DvrDataManager.java +++ b/src/com/android/tv/dvr/DvrDataManager.java @@ -21,7 +21,10 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Range; -import com.android.tv.dvr.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.SeriesRecording; import java.util.Collection; import java.util.List; @@ -211,6 +214,13 @@ public interface DvrDataManager { Collection<Long> getDisallowedProgramIds(); /** + * Checks each of the give series recordings to see if it's empty, i.e., it doesn't contains + * any available schedules or recorded programs, and it's status is + * {@link SeriesRecording#STATE_SERIES_STOPPED}; and removes those empty series recordings. + */ + void checkAndRemoveEmptySeriesRecording(long... seriesRecordingIds); + + /** * Listens for the DVR schedules loading finished. */ interface OnDvrScheduleLoadFinishedListener { diff --git a/src/com/android/tv/dvr/DvrDataManagerImpl.java b/src/com/android/tv/dvr/DvrDataManagerImpl.java index 46682a48..6094ca72 100644 --- a/src/com/android/tv/dvr/DvrDataManagerImpl.java +++ b/src/com/android/tv/dvr/DvrDataManagerImpl.java @@ -42,7 +42,11 @@ import android.util.Range; import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.dvr.DvrStorageStatusManager.OnStorageMountChangedListener; -import com.android.tv.dvr.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.IdGenerator; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddScheduleTask; import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddSeriesRecordingTask; import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDeleteScheduleTask; @@ -51,12 +55,14 @@ 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.dvr.provider.DvrDbSync; +import com.android.tv.dvr.recorder.SeriesRecordingScheduler; 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.TvUriMatcher; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -267,11 +273,14 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { removeScheduledRecording(ScheduledRecording.toArray(toDelete)); } IdGenerator.SCHEDULED_RECORDING.setMaxId(maxId); + if (mRecordedProgramLoadFinished) { + validateSeriesRecordings(); + } mDvrLoadFinished = true; notifyDvrScheduleLoadFinished(); - mDbSync = new DvrDbSync(mContext, DvrDataManagerImpl.this); - mDbSync.start(); if (isInitialized()) { + mDbSync = new DvrDbSync(mContext, DvrDataManagerImpl.this); + mDbSync.start(); SeriesRecordingScheduler.getInstance(mContext).start(); } } @@ -306,8 +315,11 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { if (uri == null) { uri = RecordedPrograms.CONTENT_URI; } - int match = TvProviderUriMatcher.match(uri); - if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM) { + if (recordedPrograms == null) { + recordedPrograms = Collections.emptyList(); + } + int match = TvUriMatcher.match(uri); + if (match == TvUriMatcher.MATCH_RECORDED_PROGRAM) { if (!mRecordedProgramLoadFinished) { for (RecordedProgram recorded : recordedPrograms) { if (isInputAvailable(recorded.getInputId())) { @@ -318,7 +330,11 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } mRecordedProgramLoadFinished = true; notifyRecordedProgramLoadFinished(); - } else if (recordedPrograms == null || recordedPrograms.isEmpty()) { + if (isInitialized()) { + mDbSync = new DvrDbSync(mContext, DvrDataManagerImpl.this); + mDbSync.start(); + } + } else if (recordedPrograms.isEmpty()) { List<RecordedProgram> oldRecordedPrograms = new ArrayList<>(mRecordedPrograms.values()); mRecordedPrograms.clear(); @@ -355,19 +371,24 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } } if (isInitialized()) { + validateSeriesRecordings(); SeriesRecordingScheduler.getInstance(mContext).start(); } - } else if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM_ID) { + } else if (match == TvUriMatcher.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()) { + if (recordedPrograms.isEmpty()) { mRecordedProgramsForRemovedInput.remove(id); RecordedProgram old = mRecordedPrograms.remove(id); if (old != null) { notifyRecordedProgramsRemoved(old); + SeriesRecording r = mSeriesId2SeriesRecordings.get(old.getSeriesId()); + if (r != null && isEmptySeriesRecording(r)) { + removeSeriesRecording(r); + } } } else { RecordedProgram recordedProgram = recordedPrograms.get(0); @@ -592,10 +613,16 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { public void removeScheduledRecording(boolean forceRemove, ScheduledRecording... schedules) { List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); List<ScheduledRecording> schedulesNotToDelete = new ArrayList<>(); + Set<Long> seriesRecordingIdsToCheck = new HashSet<>(); for (ScheduledRecording r : schedules) { mScheduledRecordings.remove(r.getId()); - getDeletedScheduleMap().remove(r.getId()); + getDeletedScheduleMap().remove(r.getProgramId()); mProgramId2ScheduledRecordings.remove(r.getProgramId()); + if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET + && (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED + || r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) { + seriesRecordingIdsToCheck.add(r.getSeriesRecordingId()); + } boolean isScheduleForRemovedInput = mScheduledRecordingsForRemovedInput.remove(r.getProgramId()) != null; // If it belongs to the series recording and it's not started yet, just mark delete @@ -614,8 +641,19 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } } if (mDvrLoadFinished) { + if (mRecordedProgramLoadFinished) { + checkAndRemoveEmptySeriesRecording(seriesRecordingIdsToCheck); + } notifyScheduledRecordingRemoved(schedules); } + Iterator<ScheduledRecording> iterator = schedulesNotToDelete.iterator(); + while (iterator.hasNext()) { + ScheduledRecording r = iterator.next(); + if (!mSeriesRecordings.containsKey(r.getSeriesRecordingId())) { + iterator.remove(); + schedulesToDelete.add(r); + } + } if (!schedulesToDelete.isEmpty()) { new AsyncDeleteScheduleTask(mContext).executeOnDbThread( ScheduledRecording.toArray(schedulesToDelete)); @@ -669,6 +707,7 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { private void updateScheduledRecording(boolean updateDb, final ScheduledRecording... schedules) { List<ScheduledRecording> toUpdate = new ArrayList<>(); + Set<Long> seriesRecordingIdsToCheck = new HashSet<>(); for (ScheduledRecording r : schedules) { if (!SoftPreconditions.checkState(mScheduledRecordings.containsKey(r.getId()), TAG, "Recording not found for: " + r)) { @@ -691,6 +730,13 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { if (programId != ScheduledRecording.ID_NOT_SET) { mProgramId2ScheduledRecordings.put(programId, r); } + if (r.getState() == ScheduledRecording.STATE_RECORDING_FAILED + && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) { + // If the scheduled recording is failed, it may cause the automatically generated + // series recording for this schedule becomes invalid (with no future schedules and + // past recordings.) We should check and remove these series recordings. + seriesRecordingIdsToCheck.add(r.getSeriesRecordingId()); + } } if (toUpdate.isEmpty()) { return; @@ -702,12 +748,17 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { if (updateDb) { new AsyncUpdateScheduleTask(mContext).executeOnDbThread(scheduleArray); } + checkAndRemoveEmptySeriesRecording(seriesRecordingIdsToCheck); removeDeletedSchedules(schedules); } @Override public void updateSeriesRecording(final SeriesRecording... seriesRecordings) { for (SeriesRecording r : seriesRecordings) { + if (!SoftPreconditions.checkArgument(mSeriesRecordings.containsKey(r.getId()), TAG, + "Non Existing Series ID: " + r)) { + continue; + } SeriesRecording old1 = mSeriesRecordings.put(r.getId(), r); SeriesRecording old2 = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r); SoftPreconditions.checkArgument(old1.equals(old2), TAG, "Series ID cannot be" @@ -769,14 +820,6 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { 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>() { @@ -785,6 +828,21 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { return r.getInputId().equals(inputId); } }); + List<SeriesRecording> removedSeriesRecordings = new ArrayList<>(); + List<SeriesRecording> movedSeriesRecordings = + moveElements(mSeriesRecordingsForRemovedInput, mSeriesRecordings, + new Filter<SeriesRecording>() { + @Override + public boolean filter(SeriesRecording r) { + if (r.getInputId().equals(inputId)) { + if (!isEmptySeriesRecording(r)) { + return true; + } + removedSeriesRecordings.add(r); + } + return false; + } + }); if (!movedSchedules.isEmpty()) { for (ScheduledRecording schedule : movedSchedules) { mProgramId2ScheduledRecordings.put(schedule.getProgramId(), schedule); @@ -795,6 +853,11 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { mSeriesId2SeriesRecordings.put(seriesRecording.getSeriesId(), seriesRecording); } } + for (SeriesRecording r : removedSeriesRecordings) { + mSeriesRecordingsForRemovedInput.remove(r.getId()); + } + new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread( + SeriesRecording.toArray(removedSeriesRecordings)); // Notify after all the data are moved. if (!movedSchedules.isEmpty()) { notifyScheduledRecordingAdded(ScheduledRecording.toArray(movedSchedules)); @@ -811,20 +874,20 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { 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); - } - }); + 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); - } - }); + new Filter<SeriesRecording>() { + @Override + public boolean filter(SeriesRecording r) { + return r.getInputId().equals(inputId); + } + }); List<RecordedProgram> movedRecordedPrograms = moveElements(mRecordedPrograms, mRecordedProgramsForRemovedInput, new Filter<RecordedProgram>() { @@ -855,6 +918,15 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { } } + private void checkAndRemoveEmptySeriesRecording(Set<Long> seriesRecordingIds) { + int i = 0; + long[] rIds = new long[seriesRecordingIds.size()]; + for (long rId : seriesRecordingIds) { + rIds[i++] = rId; + } + checkAndRemoveEmptySeriesRecording(rIds); + } + @Override public void forgetStorage(String inputId) { List<ScheduledRecording> schedulesToDelete = new ArrayList<>(); @@ -901,6 +973,25 @@ public class DvrDataManagerImpl extends BaseDvrDataManager { }.executeOnDbThread(); } + private void validateSeriesRecordings() { + Iterator<SeriesRecording> iter = mSeriesRecordings.values().iterator(); + List<SeriesRecording> removedSeriesRecordings = new ArrayList<>(); + while (iter.hasNext()) { + SeriesRecording r = iter.next(); + if (isEmptySeriesRecording(r)) { + iter.remove(); + removedSeriesRecordings.add(r); + } + } + if (!removedSeriesRecordings.isEmpty()) { + SeriesRecording[] removed = SeriesRecording.toArray(removedSeriesRecordings); + new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread(removed); + if (mDvrLoadFinished) { + notifySeriesRecordingRemoved(removed); + } + } + } + private final class RecordedProgramsQueryTask extends AsyncRecordedProgramQueryTask { private final Uri mUri; diff --git a/src/com/android/tv/dvr/DvrManager.java b/src/com/android/tv/dvr/DvrManager.java index 5fa6f90f..d222003d 100644 --- a/src/com/android/tv/dvr/DvrManager.java +++ b/src/com/android/tv/dvr/DvrManager.java @@ -46,7 +46,9 @@ 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.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.AsyncDbTask; import com.android.tv.util.Utils; @@ -142,7 +144,7 @@ public class DvrManager { } private void createSeriesRecordingForRecordedProgramIfNeeded(RecordedProgram recordedProgram) { - if (recordedProgram.getSeriesId() != null) { + if (recordedProgram.isEpisodic()) { SeriesRecording seriesRecording = mDataManager.getSeriesRecording(recordedProgram.getSeriesId()); if (seriesRecording == null) { @@ -234,7 +236,7 @@ public class DvrManager { * 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) { + List<Program> programsToSchedule, @SeriesRecording.SeriesState int initialState) { Log.i(TAG, "Adding series recording for program " + selectedProgram + ", and schedules: " + programsToSchedule); if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { @@ -308,8 +310,7 @@ public class DvrManager { ScheduledRecording scheduleWithSameProgram = mDataManager.getScheduledRecordingForProgramId(program.getId()); if (scheduleWithSameProgram != null) { - if (scheduleWithSameProgram.getState() - == ScheduledRecording.STATE_RECORDING_NOT_STARTED) { + if (scheduleWithSameProgram.isNotStarted()) { ScheduledRecording r = ScheduledRecording.buildFrom(scheduleWithSameProgram) .setSeriesRecordingId(series.getId()) .build(); @@ -337,10 +338,10 @@ public class DvrManager { */ 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 the channel option of series changed, remove the existing schedules. The new + // schedules will be added by SeriesRecordingScheduler or by SeriesSettingsFragment. if (previousSeries.getChannelOption() != series.getChannelOption() || (previousSeries.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE && previousSeries.getChannelId() != series.getChannelId())) { @@ -350,6 +351,18 @@ public class DvrManager { for (ScheduledRecording schedule : schedules) { if (schedule.isNotStarted()) { schedulesToRemove.add(schedule); + } else if (schedule.isInProgress() + && series.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE + && schedule.getChannelId() != series.getChannelId()) { + stopRecording(schedule); + } + } + List<ScheduledRecording> deletedSchedules = + new ArrayList<>(mDataManager.getDeletedSchedules()); + for (ScheduledRecording deletedSchedule : deletedSchedules) { + if (deletedSchedule.getSeriesRecordingId() == series.getId() + && deletedSchedule.getEndTimeMs() > System.currentTimeMillis()) { + schedulesToRemove.add(deletedSchedule); } } mDataManager.removeScheduledRecording(true, @@ -363,7 +376,7 @@ public class DvrManager { List<ScheduledRecording> schedulesToUpdate = new ArrayList<>(); for (ScheduledRecording schedule : mDataManager.getScheduledRecordings(series.getId())) { - if (schedule.isNotStarted()) { + if (schedule.isNotStarted() || schedule.isInProgress()) { schedulesToUpdate.add(ScheduledRecording.buildFrom(schedule) .setPriority(priority).build()); } @@ -373,7 +386,6 @@ public class DvrManager { ScheduledRecording.toArray(schedulesToUpdate)); } } - scheduler.resumeUpdate(); } } @@ -400,33 +412,6 @@ public class DvrManager { } /** - * 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) { @@ -509,13 +494,16 @@ public class DvrManager { if (!SoftPreconditions.checkState(mDataManager.isInitialized())) { return; } - new AsyncDbTask<Void, Void, Void>() { + new AsyncDbTask<Void, Void, Integer>() { @Override - protected Void doInBackground(Void... params) { + protected Integer doInBackground(Void... params) { ContentResolver resolver = mAppContext.getContentResolver(); - int deletedCounts = resolver.delete(recordedProgram.getUri(), null, null); + return resolver.delete(recordedProgram.getUri(), null, null); + } + + @Override + protected void onPostExecute(Integer deletedCounts) { if (deletedCounts > 0) { - // TODO: executeOnExecutor should be called on the main thread. new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { @@ -524,7 +512,6 @@ public class DvrManager { } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } - return null; } }.executeOnDbThread(); } @@ -539,13 +526,22 @@ public class DvrManager { dbOperations.add(ContentProviderOperation.newDelete(r.getUri()).build()); } } - new AsyncDbTask<Void, Void, Void>() { + new AsyncDbTask<Void, Void, Boolean>() { @Override - protected Void doInBackground(Void... params) { + protected Boolean doInBackground(Void... params) { ContentResolver resolver = mAppContext.getContentResolver(); try { resolver.applyBatch(TvContract.AUTHORITY, dbOperations); - // TODO: executeOnExecutor should be called on the main thread. + } catch (RemoteException | OperationApplicationException e) { + Log.w(TAG, "Remove recorded programs from DB failed.", e); + return false; + } + return true; + } + + @Override + protected void onPostExecute(Boolean success) { + if (success) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { @@ -555,10 +551,7 @@ public class DvrManager { return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } catch (RemoteException | OperationApplicationException e) { - Log.w(TAG, "Remove reocrded programs from DB failed.", e); } - return null; } }.executeOnDbThread(); } @@ -657,6 +650,9 @@ public class DvrManager { if (!mDataManager.isDvrScheduleLoadFinished() || channel == null) { return false; } + if (channel.isRecordingProhibited()) { + return false; + } TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId()); if (info == null) { Log.w(TAG, "Could not find TvInputInfo for " + channel); @@ -680,7 +676,12 @@ public class DvrManager { if (!mDataManager.isInitialized()) { return false; } - TvInputInfo info = Utils.getTvInputInfoForProgram(mAppContext, program); + Channel channel = TvApplication.getSingletons(mAppContext).getChannelDataManager() + .getChannel(program.getChannelId()); + if (channel == null || channel.isRecordingProhibited()) { + return false; + } + TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId()); if (info == null) { Log.w(TAG, "Could not find TvInputInfo for " + program); return false; @@ -733,6 +734,17 @@ public class DvrManager { return mDataManager.getSeriesRecording(program.getSeriesId()); } + /** + * Returns if there are valid items. Valid item contains {@link RecordedProgram}, + * available {@link ScheduledRecording} and {@link SeriesRecording}. + */ + public boolean hasValidItems() { + return !(mDataManager.getRecordedPrograms().isEmpty() + && mDataManager.getStartedRecordings().isEmpty() + && mDataManager.getNonStartedScheduledRecordings().isEmpty() + && mDataManager.getSeriesRecordings().isEmpty()); + } + @WorkerThread @VisibleForTesting // Should be public to use mock DvrManager object. @@ -840,9 +852,10 @@ public class DvrManager { } /** - * Listener internally used inside dvr package. + * Listener to stop recording request. Should only be internally used inside dvr and its + * sub-package. */ - interface Listener { + public interface Listener { void onStopRecordingRequested(ScheduledRecording scheduledRecording); } } diff --git a/src/com/android/tv/dvr/DvrRecordingService.java b/src/com/android/tv/dvr/DvrRecordingService.java deleted file mode 100644 index 8c40aaa8..00000000 --- a/src/com/android/tv/dvr/DvrRecordingService.java +++ /dev/null @@ -1,122 +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.AlarmManager; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.HandlerThread; -import android.os.IBinder; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; -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; - -/** - * DVR Scheduler service. - * - * <p> This service is responsible for: - * <ul> - * <li>Send record commands to TV inputs</li> - * <li>Wake up at proper timing for recording</li> - * <li>Deconflict schedule, handling overlapping times etc.</li> - * <li> - * - * </ul> - * - * <p>The service does not stop it self. - */ -public class DvrRecordingService extends Service { - private static final String TAG = "DvrRecordingService"; - private static final boolean DEBUG = false; - public static final String HANDLER_THREAD_NAME = "DvrRecordingService-handler"; - - public static void startService(Context context) { - Intent dvrSchedulerIntent = new Intent(context, DvrRecordingService.class); - context.startService(dvrSchedulerIntent); - } - - private final Clock mClock = Clock.SYSTEM; - private RecurringRunner mReaperRunner; - - 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); - WritableDvrDataManager dataManager = (WritableDvrDataManager) singletons.getDvrDataManager(); - - AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - // mScheduler may have been set for testing. - if (mScheduler == null) { - mHandlerThread = new HandlerThread(HANDLER_THREAD_NAME); - mHandlerThread.start(); - mScheduler = new Scheduler(mHandlerThread.getLooper(), singletons.getDvrManager(), - singletons.getInputSessionManager(), dataManager, - singletons.getChannelDataManager(), singletons.getTvInputManagerHelper(), this, - mClock, alarmManager); - mScheduler.start(); - } - mReaperRunner = new RecurringRunner(this, java.util.concurrent.TimeUnit.DAYS.toMillis(1), - new ScheduledProgramReaper(dataManager, mClock), null); - mReaperRunner.start(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (DEBUG) Log.d(TAG, "onStartCommand (" + intent + "," + flags + "," + startId + ")"); - mScheduler.update(); - return START_STICKY; - } - - @Override - public void onDestroy() { - if (DEBUG) Log.d(TAG, "onDestroy"); - mReaperRunner.stop(); - mScheduler.stop(); - mScheduler = null; - if (mHandlerThread != null) { - mHandlerThread.quitSafely(); - mHandlerThread = null; - } - super.onDestroy(); - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @VisibleForTesting - void setScheduler(Scheduler scheduler) { - Log.i(TAG, "Setting scheduler for tests to " + scheduler); - mScheduler = scheduler; - } -} diff --git a/src/com/android/tv/dvr/DvrScheduleManager.java b/src/com/android/tv/dvr/DvrScheduleManager.java index a5851a75..b72117aa 100644 --- a/src/com/android/tv/dvr/DvrScheduleManager.java +++ b/src/com/android/tv/dvr/DvrScheduleManager.java @@ -24,7 +24,6 @@ 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; @@ -35,7 +34,10 @@ 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.dvr.recorder.InputTaskScheduler; import com.android.tv.util.CompositeComparator; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -88,9 +90,8 @@ public class DvrScheduleManager { 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<>(); + // although there's conflict, it might still be recorded partially. + private final Map<String, Map<Long, ConflictInfo>> mInputConflictInfoMap = new HashMap<>(); private boolean mInitialized; @@ -171,10 +172,9 @@ public class DvrScheduleManager { mInputScheduleMap.remove(inputId); } } - Map<ScheduledRecording, Boolean> conflictInfo = - mInputConflictInfoMap.get(inputId); + Map<Long, ConflictInfo> conflictInfo = mInputConflictInfoMap.get(inputId); if (conflictInfo != null) { - conflictInfo.remove(schedule); + conflictInfo.remove(schedule.getId()); if (conflictInfo.isEmpty()) { mInputConflictInfoMap.remove(inputId); } @@ -221,21 +221,11 @@ public class DvrScheduleManager { mInputScheduleMap.remove(inputId); } // Update conflict list as well - Map<ScheduledRecording, Boolean> conflictInfo = - mInputConflictInfoMap.get(inputId); + Map<Long, ConflictInfo> 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); + ConflictInfo oldConflictInfo = conflictInfo.get(schedule.getId()); + if (oldConflictInfo != null) { + oldConflictInfo.schedule = schedule; } } } @@ -317,24 +307,25 @@ public class DvrScheduleManager { List<ScheduledRecording> addedConflicts = new ArrayList<>(); List<ScheduledRecording> removedConflicts = new ArrayList<>(); for (String inputId : mInputScheduleMap.keySet()) { - Map<ScheduledRecording, Boolean> oldConflictsInfo = mInputConflictInfoMap.get(inputId); + Map<Long, ConflictInfo> oldConflictInfo = mInputConflictInfoMap.get(inputId); Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>(); - if (oldConflictsInfo != null) { - for (ScheduledRecording r : oldConflictsInfo.keySet()) { - oldConflictMap.put(r.getId(), r); + if (oldConflictInfo != null) { + for (ConflictInfo conflictInfo : oldConflictInfo.values()) { + oldConflictMap.put(conflictInfo.schedule.getId(), conflictInfo.schedule); } } - Map<ScheduledRecording, Boolean> conflictInfo = getConflictingSchedulesInfo(inputId); - if (conflictInfo.isEmpty()) { + List<ConflictInfo> conflicts = getConflictingSchedulesInfo(inputId); + if (conflicts.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); + Map<Long, ConflictInfo> conflictInfos = new HashMap<>(); + for (ConflictInfo conflictInfo : conflicts) { + conflictInfos.put(conflictInfo.schedule.getId(), conflictInfo); + if (oldConflictMap.remove(conflictInfo.schedule.getId()) == null) { + addedConflicts.add(conflictInfo.schedule); } } + mInputConflictInfoMap.put(inputId, conflictInfos); } removedConflicts.addAll(oldConflictMap.values()); } @@ -565,8 +556,7 @@ public class DvrScheduleManager { } /** - * Returns list of all conflicting scheduled recordings with schedules belonging to {@code - * seriesRecording} + * Returns list of all conflicting scheduled recordings for the given {@code seriesRecording} * recording. * <p> * Any empty list means there is no conflicts. @@ -581,9 +571,18 @@ public class DvrScheduleManager { if (input == null || !input.canRecord() || input.getTunerCount() <= 0) { return Collections.emptyList(); } - List<ScheduledRecording> schedulesForSeries = mDataManager.getScheduledRecordings( + List<ScheduledRecording> scheduledRecordingForSeries = mDataManager.getScheduledRecordings( seriesRecording.getId()); - return getConflictingSchedules(input, schedulesForSeries); + List<ScheduledRecording> availableScheduledRecordingForSeries = new ArrayList<>(); + for (ScheduledRecording scheduledRecording : scheduledRecordingForSeries) { + if (scheduledRecording.isNotStarted() || scheduledRecording.isInProgress()) { + availableScheduledRecordingForSeries.add(scheduledRecording); + } + } + if (availableScheduledRecordingForSeries.isEmpty()) { + return Collections.emptyList(); + } + return getConflictingSchedules(input, availableScheduledRecordingForSeries); } /** @@ -617,16 +616,16 @@ public class DvrScheduleManager { * the given input. */ @NonNull - private Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(String inputId) { + private List<ConflictInfo> 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(); + return Collections.emptyList(); } List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId()); if (schedules == null || schedules.isEmpty()) { - return Collections.emptyMap(); + return Collections.emptyList(); } return getConflictingSchedulesInfo(schedules, input.getTunerCount()); } @@ -645,8 +644,8 @@ public class DvrScheduleManager { if (!mInitialized || input == null) { return false; } - Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId()); - return conflicts != null && conflicts.containsKey(schedule); + Map<Long, ConflictInfo> conflicts = mInputConflictInfoMap.get(input.getId()); + return conflicts != null && conflicts.containsKey(schedule.getId()); } /** @@ -664,8 +663,12 @@ public class DvrScheduleManager { if (!mInitialized || input == null) { return false; } - Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId()); - return conflicts != null && conflicts.getOrDefault(schedule, false); + Map<Long, ConflictInfo> conflicts = mInputConflictInfoMap.get(input.getId()); + if (conflicts != null) { + ConflictInfo conflictInfo = conflicts.get(schedule.getId()); + return conflictInfo != null && conflictInfo.partialConflict; + } + return false; } /** @@ -813,15 +816,17 @@ public class DvrScheduleManager { @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); + List<ScheduledRecording> result = new ArrayList<>(); + for (ConflictInfo conflictInfo : + getConflictingSchedulesInfo(schedules, tunerCount, periods)) { + result.add(conflictInfo.schedule); + } return result; } @VisibleForTesting - static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo( - List<ScheduledRecording> schedules, int tunerCount) { + static List<ConflictInfo> getConflictingSchedulesInfo(List<ScheduledRecording> schedules, + int tunerCount) { return getConflictingSchedulesInfo(schedules, tunerCount, null); } @@ -836,13 +841,13 @@ public class DvrScheduleManager { * to be partially recorded under the given schedules and tuner count {@code true}, * or not {@code false}. */ - private static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo( + private static List<ConflictInfo> 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, ConflictInfo> conflicts = new HashMap<>(); Map<ScheduledRecording, ScheduledRecording> modified2OriginalSchedules = new HashMap<>(); // Simulate InputTaskScheduler. while (!schedulesToCheck.isEmpty()) { @@ -853,26 +858,29 @@ public class DvrScheduleManager { 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); + ScheduledRecording originalSchedule = modified2OriginalSchedules.get(schedule); + conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, true)); } } else { ScheduledRecording candidate = findReplaceableRecording(recordings, schedule); if (candidate != null) { if (!modified2OriginalSchedules.containsKey(candidate)) { - conflicts.put(candidate, true); + conflicts.put(candidate, new ConflictInfo(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); + ScheduledRecording originalSchedule = + modified2OriginalSchedules.get(schedule); + conflicts.put(originalSchedule, new ConflictInfo(originalSchedule, 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); + conflicts.put(schedule, new ConflictInfo(schedule, false)); } long earliestEndTime = getEarliestEndTime(recordings); if (earliestEndTime < schedule.getEndTimeMs()) { @@ -912,7 +920,14 @@ public class DvrScheduleManager { } } } - return conflicts; + List<ConflictInfo> result = new ArrayList<>(conflicts.values()); + Collections.sort(result, new Comparator<ConflictInfo>() { + @Override + public int compare(ConflictInfo lhs, ConflictInfo rhs) { + return RESULT_COMPARATOR.compare(lhs.schedule, rhs.schedule); + } + }); + return result; } private static void removeFinishedRecordings(List<ScheduledRecording> recordings, @@ -954,6 +969,17 @@ public class DvrScheduleManager { return earliest; } + @VisibleForTesting + static class ConflictInfo { + public ScheduledRecording schedule; + public boolean partialConflict; + + ConflictInfo(ScheduledRecording schedule, boolean partialConflict) { + this.schedule = schedule; + this.partialConflict = partialConflict; + } + } + /** * A listener which is notified the initialization of schedule manager. */ @@ -970,6 +996,9 @@ public class DvrScheduleManager { public interface OnConflictStateChangeListener { /** * Called when the conflicting schedules change. + * <p> + * Note that this can be called before + * {@link ScheduledRecordingListener#onScheduledRecordingAdded} is called. * * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise * {@code false}. diff --git a/src/com/android/tv/dvr/DvrStorageStatusManager.java b/src/com/android/tv/dvr/DvrStorageStatusManager.java index a653b5f4..2d41d732 100644 --- a/src/com/android/tv/dvr/DvrStorageStatusManager.java +++ b/src/com/android/tv/dvr/DvrStorageStatusManager.java @@ -25,6 +25,7 @@ import android.content.IntentFilter; import android.content.OperationApplicationException; import android.database.Cursor; import android.media.tv.TvContract; +import android.media.tv.TvInputInfo; import android.net.Uri; import android.os.AsyncTask; import android.os.Environment; @@ -36,8 +37,11 @@ import android.support.annotation.IntDef; import android.support.annotation.WorkerThread; import android.util.Log; +import com.android.tv.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; import java.io.File; @@ -294,7 +298,7 @@ public class DvrStorageStatusManager { storageMounted, storageMountedDir, storageMountedCapacity); } - private class CleanUpDbTask extends AsyncTask<Void, Void, Void> { + private class CleanUpDbTask extends AsyncTask<Void, Void, Boolean> { private final ContentResolver mContentResolver; private CleanUpDbTask() { @@ -302,13 +306,15 @@ public class DvrStorageStatusManager { } @Override - protected Void doInBackground(Void... params) { + protected Boolean 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 (storageStatus == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL) { + return true; + } + List<ContentProviderOperation> ops = getDeleteOps(); if (ops == null || ops.isEmpty()) { return null; } @@ -329,13 +335,28 @@ public class DvrStorageStatusManager { } @Override - protected void onPostExecute(Void result) { + protected void onPostExecute(Boolean forgetStorage) { + if (forgetStorage != null && forgetStorage == true) { + DvrManager dvrManager = TvApplication.getSingletons(mContext).getDvrManager(); + TvInputManagerHelper tvInputManagerHelper = + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); + List<TvInputInfo> tvInputInfoList = + tvInputManagerHelper.getTvInputInfos(true, false); + if (tvInputInfoList == null || tvInputInfoList.isEmpty()) { + return; + } + for (TvInputInfo info : tvInputInfoList) { + if (Utils.isBundledInput(info.getId())) { + dvrManager.forgetStorage(info.getId()); + } + } + } if (mCleanUpDbTask == this) { mCleanUpDbTask = null; } } - private List<ContentProviderOperation> getDeleteOps(boolean deleteAll) { + private List<ContentProviderOperation> getDeleteOps() { List<ContentProviderOperation> ops = new ArrayList<>(); try (Cursor c = mContentResolver.query( @@ -364,7 +385,7 @@ public class DvrStorageStatusManager { continue; } File recordedProgramDir = new File(dataUri.getPath()); - if (deleteAll || !recordedProgramDir.exists()) { + if (!recordedProgramDir.exists()) { ops.add(ContentProviderOperation.newDelete( TvContract.buildRecordedProgramUri(Long.parseLong(id))).build()); } diff --git a/src/com/android/tv/dvr/DvrWatchedPositionManager.java b/src/com/android/tv/dvr/DvrWatchedPositionManager.java index 4eada742..da6ddb1a 100644 --- a/src/com/android/tv/dvr/DvrWatchedPositionManager.java +++ b/src/com/android/tv/dvr/DvrWatchedPositionManager.java @@ -22,6 +22,7 @@ import android.media.tv.TvInputManager; import android.support.annotation.IntDef; import com.android.tv.common.SharedPreferencesUtils; +import com.android.tv.dvr.data.RecordedProgram; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -36,9 +37,6 @@ import java.util.concurrent.CopyOnWriteArraySet; * 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<>(); diff --git a/src/com/android/tv/dvr/WritableDvrDataManager.java b/src/com/android/tv/dvr/WritableDvrDataManager.java index bf72d912..129ba153 100644 --- a/src/com/android/tv/dvr/WritableDvrDataManager.java +++ b/src/com/android/tv/dvr/WritableDvrDataManager.java @@ -18,7 +18,9 @@ package com.android.tv.dvr; import android.support.annotation.MainThread; -import com.android.tv.dvr.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.RecordingState; +import com.android.tv.dvr.data.SeriesRecording; /** * Full data manager. @@ -27,7 +29,7 @@ import com.android.tv.dvr.ScheduledRecording.RecordingState; * for internal use only. Do not call them from UI directly. */ @MainThread -interface WritableDvrDataManager extends DvrDataManager { +public interface WritableDvrDataManager extends DvrDataManager { /** * Adds new recordings. */ diff --git a/src/com/android/tv/dvr/IdGenerator.java b/src/com/android/tv/dvr/data/IdGenerator.java index 0ed6362c..2ade1dad 100644 --- a/src/com/android/tv/dvr/IdGenerator.java +++ b/src/com/android/tv/dvr/data/IdGenerator.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.data; import java.util.concurrent.atomic.AtomicLong; diff --git a/src/com/android/tv/dvr/RecordedProgram.java b/src/com/android/tv/dvr/data/RecordedProgram.java index dd744f80..2e953a52 100644 --- a/src/com/android/tv/dvr/RecordedProgram.java +++ b/src/com/android/tv/dvr/data/RecordedProgram.java @@ -14,22 +14,23 @@ * limitations under the License */ -package com.android.tv.dvr; - -import static android.media.tv.TvContract.RecordedPrograms; +package com.android.tv.dvr.data; import android.annotation.TargetApi; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.media.tv.TvContentRating; import android.media.tv.TvContract; +import android.media.tv.TvContract.RecordedPrograms; 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.common.TvContentRatingCache; import com.android.tv.data.BaseProgram; import com.android.tv.data.GenreItems; import com.android.tv.data.InternalDataUtils; @@ -105,7 +106,8 @@ public class RecordedProgram extends BaseProgram { .setVideoWidth(cursor.getInt(index++)) .setVideoHeight(cursor.getInt(index++)) .setAudioLanguage(cursor.getString(index++)) - .setContentRating(cursor.getString(index++)) + .setContentRatings( + TvContentRatingCache.getInstance().getRatings(cursor.getString(index++))) .setPosterArtUri(cursor.getString(index++)) .setThumbnailUri(cursor.getString(index++)) .setSearchable(cursor.getInt(index++) == 1) @@ -156,7 +158,8 @@ public class RecordedProgram extends BaseProgram { 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_CONTENT_RATING, + TvContentRatingCache.contentRatingsToString(recordedProgram.mContentRatings)); 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); @@ -201,7 +204,7 @@ public class RecordedProgram extends BaseProgram { private int mVideoWidth; private int mVideoHeight; private String mAudioLanguage; - private String mContentRating; + private TvContentRating[] mContentRatings; private String mPosterArtUri; private String mThumbnailUri; private boolean mSearchable = true; @@ -326,8 +329,8 @@ public class RecordedProgram extends BaseProgram { return this; } - public Builder setContentRating(String contentRating) { - mContentRating = contentRating; + public Builder setContentRatings(TvContentRating[] contentRatings) { + mContentRatings = contentRatings; return this; } @@ -404,15 +407,17 @@ public class RecordedProgram extends BaseProgram { } public RecordedProgram build() { - // Generate the series ID for the episodic program of other TV input. - if (TextUtils.isEmpty(mSeriesId) - && !TextUtils.isEmpty(mEpisodeNumber)) { + if (TextUtils.isEmpty(mTitle)) { + // If title is null, series cannot be generated for this program. + setSeriesId(null); + } else if (TextUtils.isEmpty(mSeriesId) && !TextUtils.isEmpty(mEpisodeNumber)) { + // If series ID is not set, generate it for the episodic program of other TV input. 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, + mLongDescription, mVideoWidth, mVideoHeight, mAudioLanguage, mContentRatings, mPosterArtUri, mThumbnailUri, mSearchable, mDataUri, mDataBytes, mDurationMillis, mExpireTimeUtcMillis, mInternalProviderFlag1, mInternalProviderFlag2, mInternalProviderFlag3, mInternalProviderFlag4, @@ -443,7 +448,7 @@ public class RecordedProgram extends BaseProgram { .setVideoWidth(orig.getVideoWidth()) .setVideoHeight(orig.getVideoHeight()) .setAudioLanguage(orig.getAudioLanguage()) - .setContentRating(orig.getContentRating()) + .setContentRatings(orig.getContentRatings()) .setPosterArtUri(orig.getPosterArtUri()) .setThumbnailUri(orig.getThumbnailUri()) .setSearchable(orig.isSearchable()) @@ -488,7 +493,7 @@ public class RecordedProgram extends BaseProgram { private final int mVideoWidth; private final int mVideoHeight; private final String mAudioLanguage; - private final String mContentRating; + private final TvContentRating[] mContentRatings; private final String mPosterArtUri; private final String mThumbnailUri; private final boolean mSearchable; @@ -507,10 +512,11 @@ public class RecordedProgram extends BaseProgram { 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) { + String audioLanguage, TvContentRating[] contentRatings, 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; @@ -531,7 +537,7 @@ public class RecordedProgram extends BaseProgram { mVideoHeight = videoHeight; mAudioLanguage = audioLanguage; - mContentRating = contentRating; + mContentRatings = contentRatings; mPosterArtUri = posterArtUri; mThumbnailUri = thumbnailUri; mSearchable = searchable; @@ -578,8 +584,10 @@ public class RecordedProgram extends BaseProgram { return mChannelId; } - public String getContentRating() { - return mContentRating; + @Nullable + @Override + public TvContentRating[] getContentRatings() { + return mContentRatings; } public Uri getDataUri() { @@ -605,44 +613,12 @@ public class RecordedProgram extends BaseProgram { 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); - } - } + public String getEpisodeTitle() { 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")) { @@ -796,7 +772,7 @@ public class RecordedProgram extends BaseProgram { Objects.equals(mShortDescription, that.mShortDescription) && Objects.equals(mLongDescription, that.mLongDescription) && Objects.equals(mAudioLanguage, that.mAudioLanguage) && - Objects.equals(mContentRating, that.mContentRating) && + Arrays.equals(mContentRatings, that.mContentRatings) && Objects.equals(mPosterArtUri, that.mPosterArtUri) && Objects.equals(mThumbnailUri, that.mThumbnailUri); } @@ -831,7 +807,8 @@ public class RecordedProgram extends BaseProgram { ", mVideoHeight=" + mVideoHeight + ", mVideoWidth=" + mVideoWidth + ", mAudioLanguage='" + mAudioLanguage + '\'' + - ", mContentRating='" + mContentRating + '\'' + + ", mContentRatings='" + + TvContentRatingCache.contentRatingsToString(mContentRatings) + '\'' + ", mPosterArtUri=" + mPosterArtUri + ", mThumbnailUri=" + mThumbnailUri + ", mSearchable=" + mSearchable + diff --git a/src/com/android/tv/dvr/ScheduledRecording.java b/src/com/android/tv/dvr/data/ScheduledRecording.java index 2bda10ea..5d11c0f3 100644 --- a/src/com/android/tv/dvr/ScheduledRecording.java +++ b/src/com/android/tv/dvr/data/ScheduledRecording.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.data; import android.content.ContentValues; import android.content.Context; @@ -22,14 +22,15 @@ 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.TvApplication; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; import com.android.tv.data.Program; +import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.util.CompositeComparator; import com.android.tv.util.Utils; @@ -43,7 +44,6 @@ import java.util.Objects; /** * A data class for one recording contents. */ -@VisibleForTesting public final class ScheduledRecording implements Parcelable { private static final String TAG = "ScheduledRecording"; @@ -141,7 +141,6 @@ public final class ScheduledRecording implements Parcelable { /** * 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() @@ -667,23 +666,19 @@ public final class ScheduledRecording implements Parcelable { } /** - * Returns the program's title withe its season and episode number. + * Returns the program's display title, if the program title is not null, returns program title. + * Otherwise returns the channel name. */ - public String getProgramTitleWithEpisodeNumber(Context context) { - if (TextUtils.isEmpty(mProgramTitle)) { + public String getProgramDisplayTitle(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); - } + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(mChannelId); + return channel != null ? channel.getDisplayName() + : context.getString(R.string.no_program_information); } - /** * Converts a string to a @RecordingType int, defaulting to {@link #TYPE_TIMED}. */ diff --git a/src/com/android/tv/dvr/data/SeasonEpisodeNumber.java b/src/com/android/tv/dvr/data/SeasonEpisodeNumber.java new file mode 100644 index 00000000..89533dbb --- /dev/null +++ b/src/com/android/tv/dvr/data/SeasonEpisodeNumber.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.dvr.data; + +import android.text.TextUtils; + +import java.util.Objects; + +/** + * A plain java object which includes the season/episode number for the series recording. + */ +public class SeasonEpisodeNumber { + public final long seriesRecordingId; + public final String seasonNumber; + public final String episodeNumber; + + /** + * Creates a new Builder with the values set from an existing {@link ScheduledRecording}. + */ + public SeasonEpisodeNumber(ScheduledRecording r) { + this(r.getSeriesRecordingId(), r.getSeasonNumber(), r.getEpisodeNumber()); + } + + public SeasonEpisodeNumber(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 SeasonEpisodeNumber) + || TextUtils.isEmpty(seasonNumber) || TextUtils.isEmpty(episodeNumber)) { + return false; + } + SeasonEpisodeNumber that = (SeasonEpisodeNumber) 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 "SeasonEpisodeNumber{" + + "seriesRecordingId=" + seriesRecordingId + + ", seasonNumber='" + seasonNumber + + ", episodeNumber=" + episodeNumber + + '}'; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/SeriesInfo.java b/src/com/android/tv/dvr/data/SeriesInfo.java index 30256dc5..a0dec4a4 100644 --- a/src/com/android/tv/dvr/SeriesInfo.java +++ b/src/com/android/tv/dvr/data/SeriesInfo.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr; +package com.android.tv.dvr.data; /** * Series information. diff --git a/src/com/android/tv/dvr/SeriesRecording.java b/src/com/android/tv/dvr/data/SeriesRecording.java index f0690f5f..822e7320 100644 --- a/src/com/android/tv/dvr/SeriesRecording.java +++ b/src/com/android/tv/dvr/data/SeriesRecording.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.data; import android.content.ContentValues; import android.database.Cursor; @@ -26,6 +26,7 @@ import android.text.TextUtils; import com.android.tv.data.BaseProgram; import com.android.tv.data.Program; +import com.android.tv.dvr.DvrScheduleManager; import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; import com.android.tv.util.Utils; @@ -128,7 +129,6 @@ public class SeriesRecording implements Parcelable { /** * 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) diff --git a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java index 1a12fb23..c5383d02 100644 --- a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java +++ b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java @@ -21,8 +21,8 @@ import android.database.Cursor; import android.os.AsyncTask; import android.support.annotation.Nullable; -import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; import com.android.tv.util.NamedThreadFactory; diff --git a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java index 2f16ba5d..8b9481a9 100644 --- a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java +++ b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java @@ -27,8 +27,8 @@ import android.provider.BaseColumns; import android.text.TextUtils; import android.util.Log; -import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.provider.DvrContract.Schedules; import com.android.tv.dvr.provider.DvrContract.SeriesRecordings; diff --git a/src/com/android/tv/dvr/DvrDbSync.java b/src/com/android/tv/dvr/provider/DvrDbSync.java index df181455..ff391959 100644 --- a/src/com/android/tv/dvr/DvrDbSync.java +++ b/src/com/android/tv/dvr/provider/DvrDbSync.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.provider; import android.annotation.SuppressLint; import android.annotation.TargetApi; @@ -34,8 +34,13 @@ 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.dvr.DvrDataManagerImpl; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.recorder.SeriesRecordingScheduler; import com.android.tv.util.AsyncDbTask.AsyncQueryProgramTask; -import com.android.tv.util.TvProviderUriMatcher; +import com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Collections; @@ -57,11 +62,12 @@ import java.util.Set; */ @MainThread @TargetApi(Build.VERSION_CODES.N) -class DvrDbSync { +public class DvrDbSync { private static final String TAG = "DvrDbSync"; private static final boolean DEBUG = false; private final Context mContext; + private final DvrManager mDvrManager; private final DvrDataManagerImpl mDataManager; private final ChannelDataManager mChannelDataManager; private final Queue<Long> mProgramIdQueue = new LinkedList<>(); @@ -72,12 +78,12 @@ class DvrDbSync { @SuppressLint("SwitchIntDef") @Override public void onChange(boolean selfChange, Uri uri) { - switch (TvProviderUriMatcher.match(uri)) { - case TvProviderUriMatcher.MATCH_PROGRAM: + switch (TvUriMatcher.match(uri)) { + case TvUriMatcher.MATCH_PROGRAM: if (DEBUG) Log.d(TAG, "onProgramsUpdated"); onProgramsUpdated(); break; - case TvProviderUriMatcher.MATCH_PROGRAM_ID: + case TvUriMatcher.MATCH_PROGRAM_ID: if (DEBUG) { Log.d(TAG, "onProgramUpdated: programId=" + ContentUris.parseId(uri)); } @@ -129,17 +135,21 @@ class DvrDbSync { } }; - DvrDbSync(Context context, DvrDataManagerImpl dataManager) { - this(context, dataManager, TvApplication.getSingletons(context).getChannelDataManager()); + public DvrDbSync(Context context, DvrDataManagerImpl dataManager) { + this(context, dataManager, TvApplication.getSingletons(context).getChannelDataManager(), + TvApplication.getSingletons(context).getDvrManager(), + SeriesRecordingScheduler.getInstance(context)); } @VisibleForTesting DvrDbSync(Context context, DvrDataManagerImpl dataManager, - ChannelDataManager channelDataManager) { + ChannelDataManager channelDataManager, DvrManager dvrManager, + SeriesRecordingScheduler seriesRecordingScheduler) { mContext = context; + mDvrManager = dvrManager; mDataManager = dataManager; mChannelDataManager = channelDataManager; - mSeriesRecordingScheduler = SeriesRecordingScheduler.getInstance(context); + mSeriesRecordingScheduler = seriesRecordingScheduler; } /** @@ -273,16 +283,15 @@ class DvrDbSync { // Check the series recording. SeriesRecording seriesRecordingForOldSchedule = mDataManager.getSeriesRecording(schedule.getSeriesRecordingId()); - if (program.getSeriesId() != null) { + if (program.isEpisodic()) { // 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); + SeriesRecording newSeriesRecording = mDvrManager.addSeriesRecording( + program, Collections.singletonList(program), + SeriesRecording.STATE_SERIES_STOPPED); builder.setSeriesRecordingId(newSeriesRecording.getId()); needUpdate = true; } else if (seriesRecording.getId() != schedule.getSeriesRecordingId()) { @@ -306,8 +315,9 @@ class DvrDbSync { // 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 + // Change start time only when the recording is not started yet. + boolean needToChangeStartTime = + schedule.getState() != ScheduledRecording.STATE_RECORDING_IN_PROGRESS && program.getStartTimeUtcMillis() != schedule.getStartTimeMs(); if (needToChangeStartTime) { builder.setStartTimeMs(program.getStartTimeUtcMillis()); diff --git a/src/com/android/tv/dvr/EpisodicProgramLoadTask.java b/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java index 15ca2700..ba0aca51 100644 --- a/src/com/android/tv/dvr/EpisodicProgramLoadTask.java +++ b/src/com/android/tv/dvr/provider/EpisodicProgramLoadTask.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.provider; import android.annotation.TargetApi; import android.content.Context; @@ -24,13 +24,15 @@ 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.dvr.DvrDataManager; +import com.android.tv.dvr.data.SeasonEpisodeNumber; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.AsyncDbTask.AsyncProgramQueryTask; import com.android.tv.util.AsyncDbTask.CursorFilter; import com.android.tv.util.PermissionUtils; @@ -40,7 +42,6 @@ import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; /** @@ -253,21 +254,13 @@ abstract public class EpisodicProgramLoadTask { 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<>(); + private final Set<SeasonEpisodeNumber> mSeasonEpisodeNumbers = new HashSet<>(); SeriesRecordingCursorFilter(List<SeriesRecording> seriesRecordings) { if (!mLoadDisallowedProgram) { @@ -282,7 +275,7 @@ abstract public class EpisodicProgramLoadTask { if (seriesRecordingIds.contains(r.getSeriesRecordingId()) && r.getState() != ScheduledRecording.STATE_RECORDING_FAILED && r.getState() != ScheduledRecording.STATE_RECORDING_CLIPPED) { - mScheduledEpisodes.add(new ScheduledEpisode(r)); + mSeasonEpisodeNumbers.add(new SeasonEpisodeNumber(r)); } } } @@ -306,9 +299,9 @@ abstract public class EpisodicProgramLoadTask { } if (programMatches) { return mLoadScheduledEpisode - || !isEpisodeScheduled(mScheduledEpisodes, new ScheduledEpisode( - seriesRecording.getId(), program.getSeasonNumber(), - program.getEpisodeNumber())); + || !mSeasonEpisodeNumbers.contains(new SeasonEpisodeNumber( + seriesRecording.getId(), program.getSeasonNumber(), + program.getEpisodeNumber())); } } return false; @@ -333,50 +326,4 @@ abstract public class EpisodicProgramLoadTask { 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/ConflictChecker.java b/src/com/android/tv/dvr/recorder/ConflictChecker.java index 201e379e..8aa90116 100644 --- a/src/com/android/tv/dvr/ConflictChecker.java +++ b/src/com/android/tv/dvr/recorder/ConflictChecker.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; import android.annotation.TargetApi; import android.content.ContentUris; @@ -37,6 +37,9 @@ 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 com.android.tv.dvr.DvrScheduleManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.ui.DvrUiHelper; import java.util.ArrayList; import java.util.HashMap; diff --git a/src/com/android/tv/dvr/recorder/DvrRecordingService.java b/src/com/android/tv/dvr/recorder/DvrRecordingService.java new file mode 100644 index 00000000..5d324ca5 --- /dev/null +++ b/src/com/android/tv/dvr/recorder/DvrRecordingService.java @@ -0,0 +1,207 @@ +/* + * 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.recorder; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.InputSessionManager; +import com.android.tv.InputSessionManager.OnRecordingSessionChangeListener; +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.feature.CommonFeatures; +import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.util.Clock; +import com.android.tv.util.RecurringRunner; + +/** + * DVR recording service. This service should be a foreground service and send a notification + * to users to do long-running recording task. + * + * <p>This service is waken up when there's a scheduled recording coming soon and at boot completed + * since schedules have to be loaded from databases in order to set new recording alarms, which + * might take a long time. + */ +@RequiresApi(Build.VERSION_CODES.N) +public class DvrRecordingService extends Service { + private static final String TAG = "DvrRecordingService"; + private static final boolean DEBUG = false; + + private static final String DVR_NOTIFICATION_CHANNEL_ID = "dvr_notification_channel"; + private static final int ONGOING_NOTIFICATION_ID = 1; + @VisibleForTesting static final String EXTRA_START_FOR_RECORDING = "start_for_recording"; + + private static DvrRecordingService sInstance; + private NotificationChannel mNotificationChannel; + private String mContentTitle; + private String mContentTextRecording; + private String mContentTextLoading; + + /** + * Starts the service in foreground. + * + * @param startForRecording {@code true} if there are upcoming recordings in + * {@link RecordingScheduler#SOON_DURATION_IN_MS} and the service is + * started in foreground for those recordings. + */ + @MainThread + static void startForegroundService(Context context, boolean startForRecording) { + if (sInstance == null) { + Intent intent = new Intent(context, DvrRecordingService.class); + intent.putExtra(EXTRA_START_FOR_RECORDING, startForRecording); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } else { + sInstance.startForeground(startForRecording); + } + } + + @MainThread + static void stopForegroundIfNotRecording() { + if (sInstance != null) { + sInstance.stopForegroundIfNotRecordingInternal(); + } + } + + private RecurringRunner mReaperRunner; + private InputSessionManager mSessionManager; + + @VisibleForTesting boolean mIsRecording; + private boolean mForeground; + + @VisibleForTesting final OnRecordingSessionChangeListener mOnRecordingSessionChangeListener = + new OnRecordingSessionChangeListener() { + @Override + public void onRecordingSessionChange(final boolean create, final int count) { + mIsRecording = count > 0; + if (create) { + startForeground(true); + } else { + stopForegroundIfNotRecordingInternal(); + } + } + }; + + @Override + public void onCreate() { + TvApplication.setCurrentRunningProcess(this, true); + if (DEBUG) Log.d(TAG, "onCreate"); + super.onCreate(); + SoftPreconditions.checkFeatureEnabled(this, CommonFeatures.DVR, TAG); + sInstance = this; + ApplicationSingletons singletons = TvApplication.getSingletons(this); + WritableDvrDataManager dataManager = + (WritableDvrDataManager) singletons.getDvrDataManager(); + mSessionManager = singletons.getInputSessionManager(); + mSessionManager.addOnRecordingSessionChangeListener(mOnRecordingSessionChangeListener); + mReaperRunner = new RecurringRunner(this, java.util.concurrent.TimeUnit.DAYS.toMillis(1), + new ScheduledProgramReaper(dataManager, Clock.SYSTEM), null); + mReaperRunner.start(); + mContentTitle = getString(R.string.dvr_notification_content_title); + mContentTextRecording = getString(R.string.dvr_notification_content_text_recording); + mContentTextLoading = getString(R.string.dvr_notification_content_text_loading); + createNotificationChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (DEBUG) Log.d(TAG, "onStartCommand (" + intent + "," + flags + "," + startId + ")"); + if (intent != null) { + startForeground(intent.getBooleanExtra(EXTRA_START_FOR_RECORDING, false)); + } + return START_STICKY; + } + + @Override + public void onDestroy() { + if (DEBUG) Log.d(TAG, "onDestroy"); + mReaperRunner.stop(); + mSessionManager.removeRecordingSessionChangeListener(mOnRecordingSessionChangeListener); + sInstance = null; + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @VisibleForTesting + protected void stopForegroundIfNotRecordingInternal() { + if (mForeground && !mIsRecording) { + stopForeground(); + } + } + + private void startForeground(boolean hasUpcomingRecording) { + if (!mForeground || hasUpcomingRecording) { + // We may need to update notification for upcoming recordings. + mForeground = true; + startForegroundInternal(hasUpcomingRecording); + } + } + + private void stopForeground() { + stopForegroundInternal(); + mForeground = false; + } + + @VisibleForTesting + protected void startForegroundInternal(boolean hasUpcomingRecording) { + // STOPSHIP: Replace the content title with real UX strings + Notification.Builder builder = new Notification.Builder(this) + .setContentTitle(mContentTitle) + .setContentText(hasUpcomingRecording ? mContentTextRecording : mContentTextLoading) + .setSmallIcon(R.drawable.ic_dvr); + Notification notification = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? + builder.setChannelId(DVR_NOTIFICATION_CHANNEL_ID).build() : builder.build(); + startForeground(ONGOING_NOTIFICATION_ID, notification); + } + + @VisibleForTesting + protected void stopForegroundInternal() { + stopForeground(true); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // STOPSHIP: Replace the channel name with real UX strings + mNotificationChannel = new NotificationChannel(DVR_NOTIFICATION_CHANNEL_ID, + getString(R.string.dvr_notification_channel_name), + NotificationManager.IMPORTANCE_LOW); + ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)) + .createNotificationChannel(mNotificationChannel); + } + } +} diff --git a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java index 6d2f0d43..f1c0020b 100644 --- a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java +++ b/src/com/android/tv/dvr/recorder/DvrStartRecordingReceiver.java @@ -14,21 +14,27 @@ * limitations under the License */ -package com.android.tv.dvr; - -import com.android.tv.TvApplication; +package com.android.tv.dvr.recorder; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.os.Build; +import android.support.annotation.RequiresApi; + +import com.android.tv.TvApplication; /** * Signals the DVR to start recording shows <i>soon</i>. */ +@RequiresApi(Build.VERSION_CODES.N) public class DvrStartRecordingReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { TvApplication.setCurrentRunningProcess(context, true); - DvrRecordingService.startService(context); + RecordingScheduler scheduler = TvApplication.getSingletons(context).getRecordingScheduler(); + if (scheduler != null) { + scheduler.updateAndStartServiceIfNeeded(); + } } } diff --git a/src/com/android/tv/dvr/InputTaskScheduler.java b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java index 53c89ebc..fee4568e 100644 --- a/src/com/android/tv/dvr/InputTaskScheduler.java +++ b/src/com/android/tv/dvr/recorder/InputTaskScheduler.java @@ -14,14 +14,13 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; 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; @@ -30,6 +29,10 @@ 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.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.util.Clock; import com.android.tv.util.CompositeComparator; @@ -121,14 +124,13 @@ public class InputTaskScheduler { 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); + clock, 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; @@ -139,7 +141,7 @@ public class InputTaskScheduler { mDataManager = (WritableDvrDataManager) dataManager; mSessionManager = sessionManager; mClock = clock; - mMainThreadHandler = mainThreadHandler; + mMainThreadHandler = new Handler(Looper.getMainLooper()); mRecordingTaskFactory = recordingTaskFactory != null ? recordingTaskFactory : new RecordingTaskFactory() { @Override @@ -150,11 +152,7 @@ public class InputTaskScheduler { mDataManager, mClock); } }; - if (workerThreadHandler == null) { - mHandler = new WorkerThreadHandler(looper); - } else { - mHandler = workerThreadHandler; - } + mHandler = new WorkerThreadHandler(looper); } /** @@ -211,7 +209,7 @@ public class InputTaskScheduler { && 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. + // The reschedule is called in RecordingScheduler. wrapper.mTask.cancel(); mWaitingSchedules.put(schedule.getId(), schedule); return; diff --git a/src/com/android/tv/dvr/Scheduler.java b/src/com/android/tv/dvr/recorder/RecordingScheduler.java index ce78e1be..cbaf46b5 100644 --- a/src/com/android/tv/dvr/Scheduler.java +++ b/src/com/android/tv/dvr/recorder/RecordingScheduler.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; import android.app.AlarmManager; import android.app.PendingIntent; @@ -22,18 +22,28 @@ import android.content.Context; import android.content.Intent; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager.TvInputCallback; +import android.os.Build; +import android.os.HandlerThread; import android.os.Looper; import android.support.annotation.MainThread; +import android.support.annotation.RequiresApi; import android.support.annotation.VisibleForTesting; import android.util.ArrayMap; import android.util.Log; import android.util.Range; +import com.android.tv.ApplicationSingletons; import com.android.tv.InputSessionManager; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ChannelDataManager.Listener; +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.WritableDvrDataManager; +import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.util.Clock; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -44,15 +54,25 @@ import java.util.Map; import java.util.concurrent.TimeUnit; /** - * The core class to manage schedule and run actual recording. + * The core class to manage DVR schedule and run recording task. + ** + * <p> This class is responsible for: + * <ul> + * <li>Sending record commands to TV inputs</li> + * <li>Resolving conflicting schedules, handling overlapping recording time durations, etc.</li> + * </ul> + * + * <p>This should be a singleton associated with application's main process. */ +@RequiresApi(Build.VERSION_CODES.N) @MainThread -public class Scheduler extends TvInputCallback implements ScheduledRecordingListener { - private static final String TAG = "Scheduler"; +public class RecordingScheduler extends TvInputCallback implements ScheduledRecordingListener { + private static final String TAG = "RecordingScheduler"; 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); + private static final String HANDLER_THREAD_NAME = "RecordingScheduler"; + private final static long SOON_DURATION_IN_MS = TimeUnit.MINUTES.toMillis(1); + @VisibleForTesting final static long MS_TO_WAKE_BEFORE_START = TimeUnit.SECONDS.toMillis(30); private final Looper mLooper; private final InputSessionManager mSessionManager; @@ -67,7 +87,52 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList private final Map<String, InputTaskScheduler> mInputSchedulerMap = new ArrayMap<>(); private long mLastStartTimePendingMs; - public Scheduler(Looper looper, DvrManager dvrManager, InputSessionManager sessionManager, + private OnDvrScheduleLoadFinishedListener mDvrScheduleLoadListener = + new OnDvrScheduleLoadFinishedListener() { + @Override + public void onDvrScheduleLoadFinished() { + mDataManager.removeDvrScheduleLoadFinishedListener(this); + if (isDbLoaded()) { + updateInternal(); + } + } + }; + + private Listener mChannelDataLoadListener = new Listener() { + @Override + public void onLoadFinished() { + mChannelDataManager.removeListener(this); + if (isDbLoaded()) { + updateInternal(); + } + } + + @Override + public void onChannelListUpdated() { } + + @Override + public void onChannelBrowsableChanged() { } + }; + + /** + * Creates a scheduler to schedule alarms for scheduled recordings and create recording tasks. + * This method should be only called once in the life-cycle of the application. + */ + public static RecordingScheduler createScheduler(Context context) { + SoftPreconditions.checkState( + TvApplication.getSingletons(context).getRecordingScheduler() == null); + HandlerThread handlerThread = new HandlerThread(HANDLER_THREAD_NAME); + handlerThread.start(); + ApplicationSingletons singletons = TvApplication.getSingletons(context); + return new RecordingScheduler(handlerThread.getLooper(), + singletons.getDvrManager(), singletons.getInputSessionManager(), + (WritableDvrDataManager) singletons.getDvrDataManager(), + singletons.getChannelDataManager(), singletons.getTvInputManagerHelper(), context, + Clock.SYSTEM, (AlarmManager) context.getSystemService(Context.ALARM_SERVICE)); + } + + @VisibleForTesting + RecordingScheduler(Looper looper, DvrManager dvrManager, InputSessionManager sessionManager, WritableDvrDataManager dataManager, ChannelDataManager channelDataManager, TvInputManagerHelper inputManager, Context context, Clock clock, AlarmManager alarmManager) { @@ -80,89 +145,70 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList mContext = context; mClock = clock; mAlarmManager = alarmManager; - } - - /** - * Starts the scheduler. - */ - public void start() { mDataManager.addScheduledRecordingListener(this); mInputManager.addCallback(this); - if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) { + if (isDbLoaded()) { updateInternal(); } else { if (!mDataManager.isDvrScheduleLoadFinished()) { - mDataManager.addDvrScheduleLoadFinishedListener( - new OnDvrScheduleLoadFinishedListener() { - @Override - public void onDvrScheduleLoadFinished() { - mDataManager.removeDvrScheduleLoadFinishedListener(this); - updateInternal(); - } - }); + mDataManager.addDvrScheduleLoadFinishedListener(mDvrScheduleLoadListener); } if (!mChannelDataManager.isDbLoadFinished()) { - mChannelDataManager.addListener(new Listener() { - @Override - public void onLoadFinished() { - mChannelDataManager.removeListener(this); - updateInternal(); - } - - @Override - public void onChannelListUpdated() { } - - @Override - public void onChannelBrowsableChanged() { } - }); + mChannelDataManager.addListener(mChannelDataLoadListener); } } } /** - * Stops the scheduler. + * Start recording that will happen soon, and set the next alarm time. */ - public void stop() { - for (InputTaskScheduler inputTaskScheduler : mInputSchedulerMap.values()) { - inputTaskScheduler.stop(); + public void updateAndStartServiceIfNeeded() { + if (DEBUG) Log.d(TAG, "update and start service if needed"); + if (isDbLoaded()) { + updateInternal(); + } else { + // updateInternal will be called when DB is loaded. Start DvrRecordingService to + // prevent process being killed before that. + DvrRecordingService.startForegroundService(mContext, false); + } + } + + private void updateInternal() { + boolean recordingSoon = updatePendingRecordings(); + updateNextAlarm(); + if (recordingSoon) { + // Start DvrRecordingService to protect upcoming recording task from being killed. + DvrRecordingService.startForegroundService(mContext, true); + } else { + DvrRecordingService.stopForegroundIfNotRecording(); } - mInputManager.removeCallback(this); - mDataManager.removeScheduledRecordingListener(this); } - private void updatePendingRecordings() { + private boolean updatePendingRecordings() { List<ScheduledRecording> scheduledRecordings = mDataManager .getScheduledRecordings(new Range<>(mLastStartTimePendingMs, - mClock.currentTimeMillis() + SOON_DURATION_IN_MS), + mClock.currentTimeMillis() + SOON_DURATION_IN_MS), ScheduledRecording.STATE_RECORDING_NOT_STARTED); for (ScheduledRecording r : scheduledRecordings) { scheduleRecordingSoon(r); } + // update() may be called multiple times, under this situation, pending recordings may be + // already updated thus scheduledRecordings may have a size of 0. Therefore we also have to + // check mLastStartTimePendingMs to check if we have upcoming recordings and prevent the + // recording service being wrongly pushed back to background in updateInternal(). + return scheduledRecordings.size() > 0 + || (mLastStartTimePendingMs > mClock.currentTimeMillis() + && mLastStartTimePendingMs < mClock.currentTimeMillis() + SOON_DURATION_IN_MS); } - /** - * Start recording that will happen soon, and set the next alarm time. - */ - public void update() { - if (DEBUG) Log.d(TAG, "update"); - updateInternal(); - } - - private void updateInternal() { - if (isInitialized()) { - updatePendingRecordings(); - updateNextAlarm(); - } - } - - private boolean isInitialized() { + private boolean isDbLoaded() { return mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished(); } @Override public void onScheduledRecordingAdded(ScheduledRecording... schedules) { if (DEBUG) Log.d(TAG, "added " + Arrays.asList(schedules)); - if (!isInitialized()) { + if (!isDbLoaded()) { return; } handleScheduleChange(schedules); @@ -171,14 +217,14 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList @Override public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { if (DEBUG) Log.d(TAG, "removed " + Arrays.asList(schedules)); - if (!isInitialized()) { + if (!isDbLoaded()) { return; } boolean needToUpdateAlarm = false; for (ScheduledRecording schedule : schedules) { - InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId()); - if (scheduler != null) { - scheduler.removeSchedule(schedule); + InputTaskScheduler inputTaskScheduler = mInputSchedulerMap.get(schedule.getInputId()); + if (inputTaskScheduler != null) { + inputTaskScheduler.removeSchedule(schedule); needToUpdateAlarm = true; } } @@ -190,14 +236,14 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList @Override public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { if (DEBUG) Log.d(TAG, "state changed " + Arrays.asList(schedules)); - if (!isInitialized()) { + if (!isDbLoaded()) { return; } // Update the recordings. for (ScheduledRecording schedule : schedules) { - InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId()); - if (scheduler != null) { - scheduler.updateSchedule(schedule); + InputTaskScheduler inputTaskScheduler = mInputSchedulerMap.get(schedule.getInputId()); + if (inputTaskScheduler != null) { + inputTaskScheduler.updateSchedule(schedule); } } handleScheduleChange(schedules); @@ -231,13 +277,13 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList 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); + InputTaskScheduler inputTaskScheduler = mInputSchedulerMap.get(input.getId()); + if (inputTaskScheduler == null) { + inputTaskScheduler = new InputTaskScheduler(mContext, input, mLooper, + mChannelDataManager, mDvrManager, mDataManager, mSessionManager, mClock); + mInputSchedulerMap.put(input.getId(), inputTaskScheduler); } - scheduler.addSchedule(schedule); + inputTaskScheduler.addSchedule(schedule); if (mLastStartTimePendingMs < schedule.getStartTimeMs()) { mLastStartTimePendingMs = schedule.getStartTimeMs(); } @@ -263,21 +309,21 @@ public class Scheduler extends TvInputCallback implements ScheduledRecordingList 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. + // No need to remove input task schedule worker 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)); + InputTaskScheduler inputTaskScheduler = mInputSchedulerMap.get(inputId); + if (inputTaskScheduler != null) { + inputTaskScheduler.updateTvInputInfo(Utils.getTvInputInfoForInputId(mContext, inputId)); } } @Override public void onTvInputInfoUpdated(TvInputInfo input) { - InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId()); - if (scheduler != null) { - scheduler.updateTvInputInfo(input); + InputTaskScheduler inputTaskScheduler = mInputSchedulerMap.get(input.getId()); + if (inputTaskScheduler != null) { + inputTaskScheduler.updateTvInputInfo(input); } } } diff --git a/src/com/android/tv/dvr/RecordingTask.java b/src/com/android/tv/dvr/recorder/RecordingTask.java index c3d236b0..14888056 100644 --- a/src/com/android/tv/dvr/RecordingTask.java +++ b/src/com/android/tv/dvr/recorder/RecordingTask.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; import android.annotation.TargetApi; import android.content.Context; @@ -37,7 +37,10 @@ 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.dvr.DvrManager; +import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.recorder.InputTaskScheduler.HandlerWrapper; import com.android.tv.util.Clock; import com.android.tv.util.Utils; @@ -51,7 +54,6 @@ import java.util.concurrent.TimeUnit; * There is only one looper so messages must be handled quickly or start a separate thread. */ @WorkerThread -@VisibleForTesting @TargetApi(Build.VERSION_CODES.N) public class RecordingTask extends RecordingCallback implements Handler.Callback, DvrManager.Listener { @@ -256,13 +258,21 @@ public class RecordingTask extends RecordingCallback implements Handler.Callback public void run() { if (TvApplication.getSingletons(mContext).getMainActivityWrapper() .isResumed()) { - Toast.makeText(mContext.getApplicationContext(), - R.string.dvr_error_insufficient_space_description, - Toast.LENGTH_LONG) - .show(); + ScheduledRecording scheduledRecording = mDataManager + .getScheduledRecording(mScheduledRecording.getId()); + if (scheduledRecording != null) { + Toast.makeText(mContext.getApplicationContext(), + mContext.getString(R.string + .dvr_error_insufficient_space_description_one_recording, + scheduledRecording.getProgramDisplayTitle(mContext)), + Toast.LENGTH_LONG) + .show(); + } } else { Utils.setRecordingFailedReason(mContext.getApplicationContext(), TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Utils.addFailedScheduledRecordingInfo(mContext.getApplicationContext(), + mScheduledRecording.getProgramDisplayTitle(mContext)); } } }); diff --git a/src/com/android/tv/dvr/ScheduledProgramReaper.java b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java index cd79a631..d958c4a1 100644 --- a/src/com/android/tv/dvr/ScheduledProgramReaper.java +++ b/src/com/android/tv/dvr/recorder/ScheduledProgramReaper.java @@ -14,11 +14,14 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; import android.support.annotation.MainThread; import android.support.annotation.VisibleForTesting; +import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.util.Clock; import java.util.ArrayList; diff --git a/src/com/android/tv/dvr/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java index 5ed12ce8..15508c24 100644 --- a/src/com/android/tv/dvr/SeriesRecordingScheduler.java +++ b/src/com/android/tv/dvr/recorder/SeriesRecordingScheduler.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.recorder; import android.annotation.SuppressLint; import android.annotation.TargetApi; @@ -23,7 +23,6 @@ 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; @@ -36,11 +35,19 @@ 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; 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.dvr.DvrManager; +import com.android.tv.dvr.WritableDvrDataManager; +import com.android.tv.dvr.data.SeasonEpisodeNumber; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesInfo; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.provider.EpisodicProgramLoadTask; import com.android.tv.experiments.Experiments; +import com.android.tv.util.LocationUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -52,11 +59,11 @@ 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}. + * Creates the {@link com.android.tv.dvr.data.ScheduledRecording}s for + * the {@link com.android.tv.dvr.data.SeriesRecording}. * <p> * The current implementation assumes that the series recordings are scheduled only for one channel. */ @@ -85,15 +92,13 @@ public class SeriesRecordingScheduler { private final DvrManager mDvrManager; private final WritableDvrDataManager mDataManager; private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>(); - private final List<FetchSeriesInfoTask> mFetchSeriesInfoTasks = new ArrayList<>(); + private final LongSparseArray<FetchSeriesInfoTask> mFetchSeriesInfoTasks = + new LongSparseArray<>(); 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 @@ -107,7 +112,7 @@ public class SeriesRecordingScheduler { public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { // Cancel the update. for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator(); - iter.hasNext(); ) { + iter.hasNext(); ) { SeriesRecordingUpdateTask task = iter.next(); if (CollectionUtils.subtract(task.getSeriesRecordings(), seriesRecordings, SeriesRecording.ID_COMPARATOR).isEmpty()) { @@ -115,6 +120,13 @@ public class SeriesRecordingScheduler { iter.remove(); } } + for (SeriesRecording seriesRecording : seriesRecordings) { + FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(seriesRecording.getId()); + if (task != null) { + task.cancel(true); + mFetchSeriesInfoTasks.remove(seriesRecording.getId()); + } + } } @Override @@ -226,7 +238,8 @@ public class SeriesRecordingScheduler { } if (DEBUG) Log.d(TAG, "stop"); mStarted = false; - for (FetchSeriesInfoTask task : mFetchSeriesInfoTasks) { + for (int i = 0; i < mFetchSeriesInfoTasks.size(); i++) { + FetchSeriesInfoTask task = mFetchSeriesInfoTasks.get(mFetchSeriesInfoTasks.keyAt(i)); task.cancel(true); } mFetchSeriesInfoTasks.clear(); @@ -250,7 +263,7 @@ public class SeriesRecordingScheduler { if (Experiments.CLOUD_EPG.get()) { FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording); task.execute(); - mFetchSeriesInfoTasks.add(task); + mFetchSeriesInfoTasks.put(seriesRecording.getId(), task); } } @@ -363,20 +376,6 @@ public class SeriesRecordingScheduler { } } - /** - * 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) { @@ -403,8 +402,7 @@ public class SeriesRecordingScheduler { /** * @see #pickOneProgramPerEpisode(List, List) */ - @VisibleForTesting - static LongSparseArray<List<Program>> pickOneProgramPerEpisode( + public static LongSparseArray<List<Program>> pickOneProgramPerEpisode( DvrDataManager dataManager, List<SeriesRecording> seriesRecordings, List<Program> programs) { // Initialize. @@ -415,7 +413,7 @@ public class SeriesRecordingScheduler { seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId()); } // Group programs by the episode. - Map<ScheduledEpisode, List<Program>> programsForEpisodeMap = new HashMap<>(); + Map<SeasonEpisodeNumber, List<Program>> programsForEpisodeMap = new HashMap<>(); for (Program program : programs) { long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId()); if (TextUtils.isEmpty(program.getSeasonNumber()) @@ -424,17 +422,17 @@ public class SeriesRecordingScheduler { result.get(seriesRecordingId).add(program); continue; } - ScheduledEpisode episode = new ScheduledEpisode(seriesRecordingId, + SeasonEpisodeNumber seasonEpisodeNumber = new SeasonEpisodeNumber(seriesRecordingId, program.getSeasonNumber(), program.getEpisodeNumber()); - List<Program> programsForEpisode = programsForEpisodeMap.get(episode); + List<Program> programsForEpisode = programsForEpisodeMap.get(seasonEpisodeNumber); if (programsForEpisode == null) { programsForEpisode = new ArrayList<>(); - programsForEpisodeMap.put(episode, programsForEpisode); + programsForEpisodeMap.put(seasonEpisodeNumber, programsForEpisode); } programsForEpisode.add(program); } // Pick one program. - for (Entry<ScheduledEpisode, List<Program>> entry : programsForEpisodeMap.entrySet()) { + for (Entry<SeasonEpisodeNumber, List<Program>> entry : programsForEpisodeMap.entrySet()) { List<Program> programsForEpisode = entry.getValue(); Collections.sort(programsForEpisode, new Comparator<Program>() { @Override @@ -512,13 +510,6 @@ public class SeriesRecordingScheduler { mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule); } } - if (!mOnSeriesRecordingUpdatedListeners.isEmpty()) { - for (OnSeriesRecordingUpdatedListener listener - : mOnSeriesRecordingUpdatedListeners) { - listener.onSeriesRecordingUpdated( - SeriesRecording.toArray(getSeriesRecordings())); - } - } } @Override @@ -543,7 +534,7 @@ public class SeriesRecordingScheduler { @Override protected SeriesInfo doInBackground(Void... voids) { - return EpgFetcher.createEpgReader(mContext) + return EpgFetcher.createEpgReader(mContext, LocationUtils.getCurrentCountry(mContext)) .getSeriesInfo(mSeriesRecording.getSeriesId()); } @@ -561,19 +552,12 @@ public class SeriesRecordingScheduler { mFetchedSeriesIds.add(seriesInfo.getId()); updateFetchedSeries(); } - mFetchSeriesInfoTasks.remove(this); + mFetchSeriesInfoTasks.remove(mSeriesRecording.getId()); } @Override protected void onCancelled(SeriesInfo seriesInfo) { - mFetchSeriesInfoTasks.remove(this); + mFetchSeriesInfoTasks.remove(mSeriesRecording.getId()); } } - - /** - * A listener to notify when series recording are updated. - */ - public interface OnSeriesRecordingUpdatedListener { - void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings); - } } diff --git a/src/com/android/tv/dvr/ui/BigArguments.java b/src/com/android/tv/dvr/ui/BigArguments.java new file mode 100644 index 00000000..ec3b5065 --- /dev/null +++ b/src/com/android/tv/dvr/ui/BigArguments.java @@ -0,0 +1,54 @@ +/* + * 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.annotation.NonNull; + +import com.android.tv.common.SoftPreconditions; + +import java.util.HashMap; +import java.util.Map; + +/** + * Stores the object to pass through activities/fragments. + */ +public class BigArguments { + private final static String TAG = "BigArguments"; + private static Map<String, Object> sBigArgumentMap = new HashMap<>(); + + /** + * Sets the argument. + */ + public static void setArgument(String name, @NonNull Object value) { + SoftPreconditions.checkState(value != null, TAG, "Set argument, but value is null"); + sBigArgumentMap.put(name, value); + } + + /** + * Returns the argument which is associated to the name. + */ + public static Object getArgument(String name) { + return sBigArgumentMap.get(name); + } + + /** + * Resets the arguments. + */ + public static void reset() { + sBigArgumentMap.clear(); + } +} diff --git a/src/com/android/tv/dvr/ui/ChangeImageTransformWithScaledParent.java b/src/com/android/tv/dvr/ui/ChangeImageTransformWithScaledParent.java new file mode 100644 index 00000000..cddece73 --- /dev/null +++ b/src/com/android/tv/dvr/ui/ChangeImageTransformWithScaledParent.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.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.drawable.BitmapDrawable; +import android.transition.ChangeImageTransform; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; + +import com.android.tv.R; + +import java.util.Map; + +/** + * TODO: Remove this class once b/32405620 is fixed. + * This class is for the workaround of b/32405620 and only for the shared element transition between + * {@link com.android.tv.dvr.ui.browse.RecordingCardView} and + * {@link com.android.tv.dvr.ui.browse.DvrDetailsActivity}. + */ +public class ChangeImageTransformWithScaledParent extends ChangeImageTransform { + private static final String PROPNAME_MATRIX = "android:changeImageTransform:matrix"; + + public ChangeImageTransformWithScaledParent(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { + super.captureStartValues(transitionValues); + applyParentScale(transitionValues); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + super.captureEndValues(transitionValues); + applyParentScale(transitionValues); + } + + private void applyParentScale(TransitionValues transitionValues) { + View view = transitionValues.view; + Map<String, Object> values = transitionValues.values; + Matrix matrix = (Matrix) values.get(PROPNAME_MATRIX); + if (matrix != null && view.getId() == R.id.details_overview_image + && view instanceof ImageView) { + ImageView imageView = (ImageView) view; + if (imageView.getScaleType() == ScaleType.CENTER_INSIDE + && imageView.getDrawable() instanceof BitmapDrawable) { + Bitmap bitmap = ((BitmapDrawable) imageView.getDrawable()).getBitmap(); + if (bitmap.getWidth() < imageView.getWidth() + && bitmap.getHeight() < imageView.getHeight()) { + float scale = imageView.getContext().getResources().getFraction( + R.fraction.lb_focus_zoom_factor_medium, 1, 1); + matrix.postScale(scale, scale, imageView.getWidth() / 2, + imageView.getHeight() / 2); + } + } + } + } +} diff --git a/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java deleted file mode 100644 index 5d8e20ff..00000000 --- a/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java +++ /dev/null @@ -1,59 +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.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 deleted file mode 100644 index 19521fca..00000000 --- a/src/com/android/tv/dvr/ui/DetailsContent.java +++ /dev/null @@ -1,207 +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.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/DvrAlreadyRecordedFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java index 9df228d1..62327870 100644 --- a/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java +++ b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java @@ -24,15 +24,12 @@ 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 com.android.tv.dvr.data.RecordedProgram; import java.util.List; @@ -92,7 +89,7 @@ public class DvrAlreadyRecordedFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_RECORD_ANYWAY) { getDvrManager().addSchedule(mProgram); } else if (action.getId() == ACTION_WATCH) { @@ -100,4 +97,23 @@ public class DvrAlreadyRecordedFragment extends DvrGuidedStepFragment { } dismissDialog(); } + + @Override + public String getTrackerPrefix() { + return "onTrackedGuidedActionClicked"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_RECORD_ANYWAY) { + return "record-anyway"; + } else if (actionId == ACTION_WATCH) { + return "watch-now"; + } else if (actionId == ACTION_CANCEL) { + return "cancel-recording"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java index 78f21784..6da75e55 100644 --- a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java +++ b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java @@ -25,15 +25,12 @@ 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 com.android.tv.dvr.data.ScheduledRecording; import java.util.List; @@ -95,7 +92,7 @@ public class DvrAlreadyScheduledFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_RECORD_ANYWAY) { getDvrManager().addSchedule(mProgram); } else if (action.getId() == ACTION_RECORD_INSTEAD) { @@ -104,4 +101,23 @@ public class DvrAlreadyScheduledFragment extends DvrGuidedStepFragment { } dismissDialog(); } + + @Override + public String getTrackerPrefix() { + return "DvrAlreadyScheduledFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_RECORD_ANYWAY) { + return "record-anyway"; + } else if (actionId == ACTION_RECORD_INSTEAD) { + return "record-instead"; + } else if (actionId == ACTION_CANCEL) { + return "cancel-recording"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java index 837d8ab2..36659412 100644 --- a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java +++ b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java @@ -27,7 +27,7 @@ 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.data.ScheduledRecording; import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelRecordConflictFragment; import java.util.ArrayList; @@ -85,7 +85,7 @@ public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragmen } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); long duration = mDurations.get((int) action.getId()); long startTimeMs = System.currentTimeMillis(); @@ -106,4 +106,25 @@ public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragmen R.id.halfsized_dialog_host); } } + + @Override + public String getTrackerPrefix() { + return "DvrChannelRecordDurationOptionFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == 0) { + return "record-10-minutes"; + } else if (actionId == 1) { + return "record-30-minutes"; + } else if (actionId == 2) { + return "record-1-hour"; + } else if (actionId == 3) { + return "record-3-hour"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/ui/DvrConflictFragment.java b/src/com/android/tv/dvr/ui/DvrConflictFragment.java index e7be4d0a..6f362e68 100644 --- a/src/com/android/tv/dvr/ui/DvrConflictFragment.java +++ b/src/com/android/tv/dvr/ui/DvrConflictFragment.java @@ -34,10 +34,9 @@ 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.dvr.recorder.ConflictChecker; +import com.android.tv.dvr.recorder.ConflictChecker.OnUpcomingConflictChangeListener; +import com.android.tv.dvr.data.ScheduledRecording; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -85,7 +84,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_VIEW_SCHEDULES) { DvrUiHelper.startSchedulesActivityForOneTimeRecordingConflict( getContext(), getConflicts()); @@ -93,6 +92,16 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { dismissDialog(); } + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = getId(); + if (actionId == ACTION_VIEW_SCHEDULES) { + return "view-schedules"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } + String getConflictDescription() { List<String> titles = new ArrayList<>(); HashSet<String> titleSet = new HashSet<>(); @@ -185,6 +194,11 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null); return new Guidance(title, descriptionPrefix + " " + description, null, icon); } + + @Override + public String getTrackerPrefix() { + return "DvrProgramConflictFragment"; + } } /** @@ -236,6 +250,11 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null); return new Guidance(title, descriptionPrefix + " " + description, null, icon); } + + @Override + public String getTrackerPrefix() { + return "DvrChannelRecordConflictFragment"; + } } /** @@ -300,7 +319,7 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_CANCEL) { ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); if (checker != null) { @@ -319,6 +338,23 @@ public abstract class DvrConflictFragment extends DvrGuidedStepFragment { } @Override + public String getTrackerPrefix() { + return "DvrChannelWatchConflictFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_CANCEL) { + return "cancel"; + } else if (actionId == ACTION_DELETE_CONFLICT) { + return "delete"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } + + @Override public void onDetach() { ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker(); if (checker != null) { diff --git a/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java deleted file mode 100644 index 73ddcdd0..00000000 --- a/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java +++ /dev/null @@ -1,87 +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.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/DvrGuidedStepFragment.java b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java index d26e6836..ab852e10 100644 --- a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java +++ b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java @@ -16,24 +16,43 @@ package com.android.tv.dvr.ui; +import android.app.Activity; 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.ApplicationSingletons; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; +import com.android.tv.dialog.HalfSizedDialogFragment.OnActionClickListener; import com.android.tv.dialog.SafeDismissDialogFragment; import com.android.tv.dvr.DvrManager; -import com.android.tv.dvr.ui.HalfSizedDialogFragment.OnActionClickListener; +import com.android.tv.dvr.DvrStorageStatusManager; + +import java.util.List; + +public abstract class DvrGuidedStepFragment extends TrackedGuidedStepFragment { + /** + * Action ID for "recording/scheduling the program anyway". + */ + public static final int ACTION_RECORD_ANYWAY = 1; + /** + * Action ID for "deleting existed recordings". + */ + public static final int ACTION_DELETE_RECORDINGS = 2; + /** + * Action ID for "cancelling current recording request". + */ + public static final int ACTION_CANCEL_RECORDING = 3; + public static final String UNKNOWN_DVR_ACTION = "Unknown DVR Action"; -public class DvrGuidedStepFragment extends GuidedStepFragment { private DvrManager mDvrManager; private OnActionClickListener mOnActionClickListener; @@ -44,7 +63,8 @@ public class DvrGuidedStepFragment extends GuidedStepFragment { @Override public void onAttach(Context context) { super.onAttach(context); - mDvrManager = TvApplication.getSingletons(context).getDvrManager(); + ApplicationSingletons singletons = TvApplication.getSingletons(context); + mDvrManager = singletons.getDvrManager(); } @Override @@ -64,13 +84,27 @@ public class DvrGuidedStepFragment extends GuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (mOnActionClickListener != null) { mOnActionClickListener.onActionClick(action.getId()); } dismissDialog(); } + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_RECORD_ANYWAY) { + return "record-anyway"; + } else if (actionId == ACTION_DELETE_RECORDINGS) { + return "delete-recordings"; + } else if (actionId == ACTION_CANCEL_RECORDING) { + return "cancel-recording"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } + protected void dismissDialog() { if (getActivity() instanceof MainActivity) { SafeDismissDialogFragment currentDialog = @@ -86,4 +120,76 @@ public class DvrGuidedStepFragment extends GuidedStepFragment { protected void setOnActionClickListener(OnActionClickListener listener) { mOnActionClickListener = listener; } + + /** + * The inner guided step fragment for + * {@link com.android.tv.dvr.ui.DvrHalfSizedDialogFragment + * .DvrNoFreeSpaceErrorDialogFragment}. + */ + public static class DvrNoFreeSpaceErrorFragment extends DvrGuidedStepFragment { + @Override + public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) { + return new GuidanceStylist.Guidance(getString(R.string.dvr_error_no_free_space_title), + getString(R.string.dvr_error_no_free_space_description), null, null); + } + + @Override + public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) { + Activity activity = getActivity(); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_RECORD_ANYWAY) + .title(R.string.dvr_action_record_anyway) + .build()); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_DELETE_RECORDINGS) + .title(R.string.dvr_action_delete_recordings) + .build()); + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_CANCEL_RECORDING) + .title(R.string.dvr_action_record_cancel) + .build()); + } + + @Override + public String getTrackerPrefix() { + return "DvrNoFreeSpaceErrorFragment"; + } + } + + /** + * The inner guided step fragment for + * {@link com.android.tv.dvr.ui.DvrHalfSizedDialogFragment + * .DvrSmallSizedStorageErrorDialogFragment}. + */ + public static class DvrSmallSizedStorageErrorFragment extends DvrGuidedStepFragment { + @Override + public GuidanceStylist.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 GuidanceStylist.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 onTrackedGuidedActionClicked(GuidedAction action) { + dismissDialog(); + } + + @Override + public String getTrackerPrefix() { + return "DvrSmallSizedStorageErrorFragment"; + } + } }
\ 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 index 2b132db8..f8ef3850 100644 --- a/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java +++ b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java @@ -29,6 +29,7 @@ import android.view.ViewGroup; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dialog.HalfSizedDialogFragment; import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelWatchConflictFragment; import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment; import com.android.tv.guide.ProgramGuide; @@ -166,6 +167,17 @@ public class DvrHalfSizedDialogFragment extends HalfSizedDialogFragment { } /** + * A dialog fragment to show error message when there is no enough free space to record. + */ + public static class DvrNoFreeSpaceErrorDialogFragment + extends DvrGuidedStepDialogFragment { + @Override + protected DvrGuidedStepFragment onCreateGuidedStepFragment() { + return new DvrGuidedStepFragment.DvrNoFreeSpaceErrorFragment(); + } + } + + /** * A dialog fragment to show error message when the current storage is too small to * support DVR */ @@ -173,32 +185,7 @@ public class DvrHalfSizedDialogFragment extends HalfSizedDialogFragment { 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(); - } - }; + return new DvrGuidedStepFragment.DvrSmallSizedStorageErrorFragment(); } } diff --git a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java index 3b1dbfa0..182416b6 100644 --- a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java +++ b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java @@ -17,6 +17,7 @@ package com.android.tv.dvr.ui; import android.app.Activity; +import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; @@ -24,19 +25,67 @@ 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.common.SoftPreconditions; +import com.android.tv.dvr.ui.browse.DvrBrowseActivity; +import java.util.ArrayList; import java.util.List; public class DvrInsufficientSpaceErrorFragment extends DvrGuidedStepFragment { - private static final int ACTION_DONE = 1; - private static final int ACTION_OPEN_DVR = 2; + /** + * Key for the failed scheduled recordings information. + */ + public static final String FAILED_SCHEDULED_RECORDING_INFOS = + "failed_scheduled_recording_infos"; + + private static final String TAG = "DvrInsufficientSpaceErrorFragment"; + + private static final int ACTION_VIEW_RECENT_RECORDINGS = 1; + + private ArrayList<String> mFailedScheduledRecordingInfos; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + Bundle args = getArguments(); + if (args != null) { + mFailedScheduledRecordingInfos = + args.getStringArrayList(FAILED_SCHEDULED_RECORDING_INFOS); + } + SoftPreconditions.checkState( + mFailedScheduledRecordingInfos != null && !mFailedScheduledRecordingInfos.isEmpty(), + TAG, "failed scheduled recording is null"); + } @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); + String title; + String description; + int failedScheduledRecordingSize = mFailedScheduledRecordingInfos.size(); + if (failedScheduledRecordingSize == 1) { + title = getString( + R.string.dvr_error_insufficient_space_title_one_recording, + mFailedScheduledRecordingInfos.get(0)); + description = getString( + R.string.dvr_error_insufficient_space_description_one_recording, + mFailedScheduledRecordingInfos.get(0)); + } else if (failedScheduledRecordingSize == 2) { + title = getString( + R.string.dvr_error_insufficient_space_title_two_recordings, + mFailedScheduledRecordingInfos.get(0), mFailedScheduledRecordingInfos.get(1)); + description = getString( + R.string.dvr_error_insufficient_space_description_two_recordings, + mFailedScheduledRecordingInfos.get(0), mFailedScheduledRecordingInfos.get(1)); + } else { + title = getString( + R.string.dvr_error_insufficient_space_title_three_or_more_recordings, + mFailedScheduledRecordingInfos.get(0), mFailedScheduledRecordingInfos.get(1), + mFailedScheduledRecordingInfos.get(2)); + description = getString( + R.string.dvr_error_insufficient_space_description_three_or_more_recordings, + mFailedScheduledRecordingInfos.get(0), mFailedScheduledRecordingInfos.get(1), + mFailedScheduledRecordingInfos.get(2)); + } return new Guidance(title, description, null, null); } @@ -44,28 +93,38 @@ public class DvrInsufficientSpaceErrorFragment extends DvrGuidedStepFragment { 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)) + .clickAction(GuidedAction.ACTION_ID_OK) .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()); + if (TvApplication.getSingletons(getContext()).getDvrManager().hasValidItems()) { + actions.add(new GuidedAction.Builder(activity) + .id(ACTION_VIEW_RECENT_RECORDINGS) + .title(getResources().getString( + R.string.dvr_error_insufficient_space_action_view_recent_recordings)) + .build()); } } @Override - public void onGuidedActionClicked(GuidedAction action) { - if (action.getId() == ACTION_OPEN_DVR) { - Intent intent = new Intent(getActivity(), DvrActivity.class); + public void onTrackedGuidedActionClicked(GuidedAction action) { + if (action.getId() == ACTION_VIEW_RECENT_RECORDINGS) { + Intent intent = new Intent(getActivity(), DvrBrowseActivity.class); getActivity().startActivity(intent); } dismissDialog(); } + + @Override + public String getTrackerPrefix() { + return "DvrInsufficientSpaceErrorFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_VIEW_RECENT_RECORDINGS) { + return "view-recent"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/ui/DvrItemPresenter.java b/src/com/android/tv/dvr/ui/DvrItemPresenter.java deleted file mode 100644 index 339e5d2f..00000000 --- a/src/com/android/tv/dvr/ui/DvrItemPresenter.java +++ /dev/null @@ -1,80 +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.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 index 2e2c2849..e726995f 100644 --- a/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java +++ b/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java @@ -17,29 +17,27 @@ package com.android.tv.dvr.ui; import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; import android.os.Bundle; -import android.support.v17.leanback.app.GuidedStepFragment; +import android.provider.Settings; import android.support.v17.leanback.widget.GuidanceStylist.Guidance; import android.support.v17.leanback.widget.GuidedAction; -import android.text.TextUtils; +import android.util.Log; import com.android.tv.R; -import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.ui.browse.DvrDetailsActivity; 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; + private static final String TAG = "DvrMissingStorageError"; + + private static final int ACTION_OK = 1; + private static final int ACTION_OPEN_STORAGE_SETTINGS = 2; @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); } @@ -55,25 +53,46 @@ public class DvrMissingStorageErrorFragment extends DvrGuidedStepFragment { 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)) + .id(ACTION_OK) + .title(android.R.string.ok) .build()); actions.add(new GuidedAction.Builder(activity) - .id(ACTION_FORGET_STORAGE) - .title(getResources().getString(R.string.dvr_action_error_forget_storage)) + .id(ACTION_OPEN_STORAGE_SETTINGS) + .title(getResources().getString(R.string.dvr_action_error_storage_settings)) .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); + public void onTrackedGuidedActionClicked(GuidedAction action) { + Activity activity = getActivity(); + if (activity instanceof DvrDetailsActivity) { + activity.finish(); + } else { + dismissDialog(); + } + if (action.getId() != ACTION_OPEN_STORAGE_SETTINGS) { return; } - dismissDialog(); + final Intent intent = new Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS); + try { + getContext().startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "Can't start internal storage settings activity", e); + } + } + + @Override + public String getTrackerPrefix() { + return "DvrMissingStorageErrorFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_OPEN_STORAGE_SETTINGS) { + return "open-storage-settings"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } } -}
\ 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 deleted file mode 100644 index 8c4c856c..00000000 --- a/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java +++ /dev/null @@ -1,82 +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.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/DvrPlaybackOverlayFragment.java b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java deleted file mode 100644 index 51ec93b8..00000000 --- a/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java +++ /dev/null @@ -1,304 +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.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/PrioritySettingsFragment.java b/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java index 158bd824..e4cb7243 100644 --- a/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java +++ b/src/com/android/tv/dvr/ui/DvrPrioritySettingsFragment.java @@ -20,7 +20,6 @@ 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; @@ -33,15 +32,13 @@ 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 com.android.tv.dvr.data.SeriesRecording; import java.util.ArrayList; import java.util.List; -/** - * Fragment for DVR series recording settings. - */ -public class PrioritySettingsFragment extends GuidedStepFragment { +/** Fragment for DVR series recording settings. */ +public class DvrPrioritySettingsFragment extends TrackedGuidedStepFragment { /** * Name of series recording id starting the fragment. * Type: Long @@ -124,7 +121,7 @@ public class PrioritySettingsFragment extends GuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { long actionId = action.getId(); if (actionId == ACTION_ID_SAVE) { DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); @@ -156,13 +153,27 @@ public class PrioritySettingsFragment extends GuidedStepFragment { } @Override + public String getTrackerPrefix() { + return "DvrPrioritySettingsFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_ID_SAVE) { + return "save"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } + + @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); @@ -248,4 +259,4 @@ public class PrioritySettingsFragment extends GuidedStepFragment { titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL); } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java index da6d1637..390e0928 100644 --- a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java +++ b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java @@ -32,9 +32,8 @@ 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.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment; import com.android.tv.util.Utils; @@ -48,18 +47,26 @@ import java.util.List; */ @TargetApi(Build.VERSION_CODES.N) public class DvrScheduleFragment extends DvrGuidedStepFragment { + /** + * Key for the whether to add the current program to series. + * Type: boolean + */ + public static final String KEY_ADD_CURRENT_PROGRAM_TO_SERIES = "add_current_program_to_series"; + 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; + private boolean mAddCurrentProgramToSeries; @Override public void onCreate(Bundle savedInstanceState) { Bundle args = getArguments(); if (args != null) { mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM); + mAddCurrentProgramToSeries = args.getBoolean(KEY_ADD_CURRENT_PROGRAM_TO_SERIES, false); } DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); SoftPreconditions.checkArgument(mProgram != null && mProgram.isEpisodic(), TAG, @@ -109,7 +116,7 @@ public class DvrScheduleFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_RECORD_EPISODE) { getDvrManager().addSchedule(mProgram); List<ScheduledRecording> conflicts = getDvrManager().getConflictingSchedules(mProgram); @@ -139,9 +146,28 @@ public class DvrScheduleFragment extends DvrGuidedStepFragment { .build(); getDvrManager().updateSeriesRecording(seriesRecording); } + DvrUiHelper.startSeriesSettingsActivity(getContext(), - seriesRecording.getId(), null, true, true, true); + seriesRecording.getId(), null, true, true, true, + mAddCurrentProgramToSeries ? mProgram : null); dismissDialog(); } } + + @Override + public String getTrackerPrefix() { + return "DvrSmallSizedStorageErrorFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_RECORD_EPISODE) { + return "record-episode"; + } else if (actionId == ACTION_RECORD_SERIES) { + return "record-series"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java index f57e4b05..667af34a 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java @@ -22,9 +22,6 @@ 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. @@ -42,7 +39,7 @@ public class DvrSeriesDeletionActivity extends Activity { 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(); + DvrSeriesDeletionFragment deletionFragment = new DvrSeriesDeletionFragment(); deletionFragment.setArguments(getIntent().getExtras()); GuidedStepFragment.addAsRoot(this, deletionFragment, R.id.dvr_settings_view_frame); } diff --git a/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java index 36e3cfc1..8bf8560f 100644 --- a/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionFragment.java @@ -33,9 +33,10 @@ 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.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.ui.GuidedActionsStylistWithDivider; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; @@ -47,7 +48,7 @@ import java.util.concurrent.TimeUnit; /** * Fragment for DVR series recording settings. */ -public class SeriesDeletionFragment extends GuidedStepFragment { +public class DvrSeriesDeletionFragment 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, @@ -218,8 +219,8 @@ public class SeriesDeletionFragment extends GuidedStepFragment { 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)); + Math.max(1, Utils.getRoundOffMinsFromMs(watchedPositionMs)), + Utils.getRoundOffMinsFromMs(durationMs)); } else { return getResources().getString(R.string.dvr_series_watched_info_seconds, Math.max(1, TimeUnit.MILLISECONDS.toSeconds(watchedPositionMs)), diff --git a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java index 1173df46..2c4bb3ea 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java @@ -25,22 +25,29 @@ 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.data.Program; import com.android.tv.dvr.DvrScheduleManager; -import com.android.tv.dvr.ScheduledRecording; -import com.android.tv.dvr.SeriesRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.ui.list.DvrSchedulesActivity; import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment; import java.util.List; public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { + /** + * The key for program list which will be passed to {@link DvrSeriesSchedulesFragment}. + * Type: List<{@link Program}> + */ + public static final String SERIES_SCHEDULED_KEY_PROGRAMS = "series_scheduled_key_programs"; + 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 List<Program> mPrograms; private int mSchedulesAddedCount = 0; private boolean mHasConflict = false; @@ -58,22 +65,25 @@ public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { } 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; } + mPrograms = (List<Program>) BigArguments.getArgument(SERIES_SCHEDULED_KEY_PROGRAMS); + BigArguments.reset(); mSchedulesAddedCount = TvApplication.getSingletons(getContext()).getDvrManager() .getAvailableScheduledRecording(mSeriesRecording.getId()).size(); + DvrScheduleManager dvrScheduleManager = + TvApplication.getSingletons(context).getDvrScheduleManager(); List<ScheduledRecording> conflictingRecordings = - mDvrScheduleManager.getConflictingSchedules(mSeriesRecording); + dvrScheduleManager.getConflictingSchedules(mSeriesRecording); mHasConflict = !conflictingRecordings.isEmpty(); for (ScheduledRecording recording : conflictingRecordings) { if (recording.getSeriesRecordingId() == mSeriesRecording.getId()) { ++mInThisSeriesConflictCount; - } else { + } else if (recording.getPriority() < mSeriesRecording.getPriority()) { ++mOutThisSeriesConflictCount; } } @@ -106,45 +116,63 @@ public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(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); + BigArguments.reset(); + BigArguments.setArgument(DvrSeriesSchedulesFragment + .SERIES_SCHEDULES_KEY_SERIES_PROGRAMS, mPrograms); startActivity(intent); } getActivity().finish(); } + @Override + public String getTrackerPrefix() { + return "DvrMissingStorageErrorFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_VIEW_SCHEDULES) { + return "view-schedules"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } + private String getDescription() { if (!mHasConflict) { return getResources().getQuantityString( - R.plurals.dvr_series_recording_scheduled_no_conflict, mSchedulesAddedCount, + R.plurals.dvr_series_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, + return getResources().getQuantityString( + R.plurals.dvr_series_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, + 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, + return getResources().getQuantityString( + R.plurals.dvr_series_scheduled_only_other_series_one_conflict, mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle()); } else { - return getResources().getQuantityString(R.plurals - .dvr_series_recording_scheduled_only_other_series_conflict, + return getResources().getQuantityString( + R.plurals.dvr_series_scheduled_only_other_series_many_conflicts, 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 index 3f7671b3..6dd20b3a 100644 --- a/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java @@ -17,7 +17,6 @@ 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; @@ -38,25 +37,34 @@ public class DvrSeriesSettingsActivity extends Activity { /** * Name of the boolean flag to decide if the series recording with empty schedule and recording * will be removed. + * Type: boolean */ 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. + * Type: boolean */ 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 + * Name of the program list. The list contains the programs which belong to the series. + * Type: List<{@link com.android.tv.data.Program}> */ - public static final String CHANNEL_ID_LIST = "channel_id_list"; + public static final String PROGRAM_LIST = "program_list"; /** * Name of the boolean flag to check if the confirm dialog should show view schedule option. + * Type: boolean */ public static final String SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG = "show_view_schedule_option_in_dialog"; + /** + * Name of the current program added to series. The current program will be recorded only when + * the series recording is initialized from media controller. But for other case, the current + * program won't be recorded. + */ + public static final String CURRENT_PROGRAM = "current_program"; + @Override public void onCreate(Bundle savedInstanceState) { TvApplication.setCurrentRunningProcess(this, true); @@ -66,7 +74,7 @@ public class DvrSeriesSettingsActivity extends Activity { SoftPreconditions.checkArgument(seriesRecordingId != -1); if (savedInstanceState == null) { - SeriesSettingsFragment settingFragment = new SeriesSettingsFragment(); + DvrSeriesSettingsFragment settingFragment = new DvrSeriesSettingsFragment(); settingFragment.setArguments(getIntent().getExtras()); GuidedStepFragment.addAsRoot(this, settingFragment, R.id.dvr_settings_view_frame); } diff --git a/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java index 6c05c9c6..f28382da 100644 --- a/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java +++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsFragment.java @@ -17,19 +17,13 @@ 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; @@ -38,14 +32,14 @@ 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 com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeasonEpisodeNumber; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.data.SeriesRecording.ChannelOption; +import com.android.tv.dvr.recorder.SeriesRecordingScheduler; + import java.util.ArrayList; -import java.util.Comparator; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -53,7 +47,7 @@ import java.util.Set; /** * Fragment for DVR series recording settings. */ -public class SeriesSettingsFragment extends GuidedStepFragment +public class DvrSeriesSettingsFragment extends GuidedStepFragment implements DvrDataManager.SeriesRecordingListener { private static final String TAG = "SeriesSettingsFragment"; private static final boolean DEBUG = false; @@ -66,15 +60,13 @@ public class SeriesSettingsFragment extends GuidedStepFragment 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 Program mCurrentProgram; private String mFragmentTitle; private String mProrityActionTitle; @@ -84,7 +76,7 @@ public class SeriesSettingsFragment extends GuidedStepFragment private String mChannelsActionAllText; private LongSparseArray<Channel> mId2Channel = new LongSparseArray<>(); private List<Channel> mChannels = new ArrayList<>(); - private EpisodicProgramLoadTask mEpisodicProgramLoadTask; + private List<Program> mPrograms; private GuidedAction mPriorityGuidedAction; private GuidedAction mChannelsGuidedAction; @@ -100,22 +92,24 @@ public class SeriesSettingsFragment extends GuidedStepFragment getActivity().finish(); return; } - mDvrManager = TvApplication.getSingletons(context).getDvrManager(); mShowViewScheduleOptionInDialog = getArguments().getBoolean( DvrSeriesSettingsActivity.SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG); + mCurrentProgram = getArguments().getParcelable(DvrSeriesSettingsActivity.CURRENT_PROGRAM); 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); + mPrograms = (List<Program>) BigArguments.getArgument( + DvrSeriesSettingsActivity.PROGRAM_LIST); + BigArguments.reset(); + if (mPrograms == null) { + getActivity().finish(); + return; + } + Set<Long> channelIds = new HashSet<>(); + ChannelDataManager channelDataManager = + TvApplication.getSingletons(context).getChannelDataManager(); + for (Program program : mPrograms) { + long channelId = program.getChannelId(); + if (channelIds.add(channelId)) { + Channel channel = channelDataManager.getChannel(channelId); if (channel != null) { mId2Channel.put(channel.getId(), channel); mChannels.add(channel); @@ -125,16 +119,14 @@ public class SeriesSettingsFragment extends GuidedStepFragment mChannelOption = mSeriesRecording.getChannelOption(); mSelectedChannelId = Channel.INVALID_ID; if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE) { - Channel channel = mChannelDataManager.getChannel(mSeriesRecording.getChannelId()); + Channel channel = channelDataManager.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); + mChannels.sort(Channel.CHANNEL_NUMBER_COMPARATOR); 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); @@ -144,23 +136,23 @@ public class SeriesSettingsFragment extends GuidedStepFragment } @Override + public void onResume() { + super.onResume(); + // To avoid the order of series's priority has changed, but series doesn't get update. + updatePriorityGuidedAction(); + } + + @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); + if (getFragmentManager().getBackStackEntryCount() == mBackStackCount && getArguments() + .getBoolean(DvrSeriesSettingsActivity.REMOVE_EMPTY_SERIES_RECORDING)) { + mDvrDataManager.checkAndRemoveEmptySeriesRecording(mSeriesRecordingId); } super.onDestroy(); } @@ -178,7 +170,6 @@ public class SeriesSettingsFragment extends GuidedStepFragment .id(ACTION_ID_PRIORITY) .title(mProrityActionTitle) .build(); - updatePriorityGuidedAction(false); actions.add(mPriorityGuidedAction); mChannelsGuidedAction = new GuidedAction.Builder(getActivity()) @@ -204,10 +195,6 @@ public class SeriesSettingsFragment extends GuidedStepFragment 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 @@ -218,28 +205,14 @@ public class SeriesSettingsFragment extends GuidedStepFragment 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; - } - } - } - }); + DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager(); + dvrManager.updateSeriesRecording(builder.build()); + if (mCurrentProgram != null && (mChannelOption == SeriesRecording.OPTION_CHANNEL_ALL + || mSelectedChannelId == mCurrentProgram.getChannelId())) { + dvrManager.addSchedule(mCurrentProgram); + } + updateSchedulesToSeries(); + showConfirmDialog(); } else { showConfirmDialog(); } @@ -247,9 +220,9 @@ public class SeriesSettingsFragment extends GuidedStepFragment finishGuidedStepFragments(); } else if (actionId == ACTION_ID_PRIORITY) { FragmentManager fragmentManager = getFragmentManager(); - PrioritySettingsFragment fragment = new PrioritySettingsFragment(); + DvrPrioritySettingsFragment fragment = new DvrPrioritySettingsFragment(); Bundle args = new Bundle(); - args.putLong(PrioritySettingsFragment.COME_FROM_SERIES_RECORDING_ID, + args.putLong(DvrPrioritySettingsFragment.COME_FROM_SERIES_RECORDING_ID, mSeriesRecording.getId()); fragment.setArguments(args); GuidedStepFragment.add(fragmentManager, fragment, R.id.dvr_settings_view_frame); @@ -281,7 +254,7 @@ public class SeriesSettingsFragment extends GuidedStepFragment private void updateChannelsGuidedAction(boolean notifyActionChanged) { if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ALL) { mChannelsGuidedAction.setDescription(mChannelsActionAllText); - } else { + } else if (mId2Channel.get(mSelectedChannelId) != null){ mChannelsGuidedAction.setDescription(mId2Channel.get(mSelectedChannelId) .getDisplayText()); } @@ -290,7 +263,7 @@ public class SeriesSettingsFragment extends GuidedStepFragment } } - private void updatePriorityGuidedAction(boolean notifyActionChanged) { + private void updatePriorityGuidedAction() { int totalSeriesCount = 0; int priorityOrder = 0; for (SeriesRecording seriesRecording : mDvrDataManager.getSeriesRecordings()) { @@ -312,49 +285,38 @@ public class SeriesSettingsFragment extends GuidedStepFragment mPriorityGuidedAction.setDescription(getString( R.string.dvr_series_settings_priority_rank, priorityOrder + 1)); } - if (notifyActionChanged) { - notifyActionChanged(findActionPositionById(ACTION_ID_PRIORITY)); - } + notifyActionChanged(findActionPositionById(ACTION_ID_PRIORITY)); } - private void collectChannelsInBackground() { - if (mEpisodicProgramLoadTask != null) { - mEpisodicProgramLoadTask.cancel(true); + private void updateSchedulesToSeries() { + List<Program> recordingCandidates = new ArrayList<>(); + Set<SeasonEpisodeNumber> scheduledEpisodes = new HashSet<>(); + for (ScheduledRecording r : mDvrDataManager.getScheduledRecordings(mSeriesRecordingId)) { + if (r.getState() != ScheduledRecording.STATE_RECORDING_FAILED + && r.getState() != ScheduledRecording.STATE_RECORDING_CLIPPED) { + scheduledEpisodes.add(new SeasonEpisodeNumber( + r.getSeriesRecordingId(), r.getSeasonNumber(), r.getEpisodeNumber())); + } } - 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"); + for (Program program : mPrograms) { + // Removes current programs and scheduled episodes out, matches the channel option. + if (program.getStartTimeUtcMillis() >= System.currentTimeMillis() + && mSeriesRecording.matchProgram(program) + && !scheduledEpisodes.contains(new SeasonEpisodeNumber( + mSeriesRecordingId, program.getSeasonNumber(), program.getEpisodeNumber()))) { + recordingCandidates.add(program); } - }.setLoadCurrentProgram(true) - .setLoadDisallowedProgram(true) - .setLoadScheduledEpisode(true) - .setIgnoreChannelOption(true); - mEpisodicProgramLoadTask.execute(); + } + if (recordingCandidates.isEmpty()) { + return; + } + List<Program> programsToSchedule = SeriesRecordingScheduler.pickOneProgramPerEpisode( + mDvrDataManager, Collections.singletonList(mSeriesRecording), recordingCandidates) + .get(mSeriesRecordingId); + if (!programsToSchedule.isEmpty()) { + TvApplication.getSingletons(getContext()).getDvrManager() + .addScheduleToSeriesRecording(mSeriesRecording, programsToSchedule); + } } private List<GuidedAction> buildChannelSubAction() { @@ -373,8 +335,8 @@ public class SeriesSettingsFragment extends GuidedStepFragment } private void showConfirmDialog() { - DvrUiHelper.StartSeriesScheduledDialogActivity( - getContext(), mSeriesRecording, mShowViewScheduleOptionInDialog); + DvrUiHelper.StartSeriesScheduledDialogActivity(getContext(), mSeriesRecording, + mShowViewScheduleOptionInDialog, mPrograms); finishGuidedStepFragments(); } @@ -382,16 +344,23 @@ public class SeriesSettingsFragment extends GuidedStepFragment public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { } @Override - public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { } + public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { + for (SeriesRecording series : seriesRecordings) { + if (series.getId() == mSeriesRecording.getId()) { + finishGuidedStepFragments(); + return; + } + } + } @Override public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) { for (SeriesRecording seriesRecording : seriesRecordings) { if (seriesRecording.getId() == mSeriesRecordingId) { mSeriesRecording = seriesRecording; - updatePriorityGuidedAction(true); + updatePriorityGuidedAction(); return; } } } -} +}
\ 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 index c3867886..baa45793 100644 --- a/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java +++ b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java @@ -25,15 +25,12 @@ 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 com.android.tv.dvr.data.ScheduledRecording; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -131,15 +128,8 @@ public class DvrStopRecordingFragment extends DvrGuidedStepFragment { 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()); + mSchedule.getProgramDisplayTitle(getContext())); } else { description = getString(R.string.dvr_stop_recording_dialog_description); } @@ -158,4 +148,19 @@ public class DvrStopRecordingFragment extends DvrGuidedStepFragment { .clickAction(GuidedAction.ACTION_ID_CANCEL) .build()); } + + @Override + public String getTrackerPrefix() { + return "DvrStopRecordingFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == ACTION_STOP) { + return "stop"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } }
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java index feaa2357..7b56cfc1 100644 --- a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java +++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java @@ -31,8 +31,8 @@ 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 com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import java.util.ArrayList; import java.util.List; @@ -78,7 +78,7 @@ public class DvrStopSeriesRecordingFragment extends DvrGuidedStepFragment { } @Override - public void onGuidedActionClicked(GuidedAction action) { + public void onTrackedGuidedActionClicked(GuidedAction action) { if (action.getId() == ACTION_STOP_SERIES_RECORDING) { ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); DvrManager dvrManager = singletons.getDvrManager(); @@ -101,4 +101,18 @@ public class DvrStopSeriesRecordingFragment extends DvrGuidedStepFragment { } dismissDialog(); } + + @Override + public String getTrackerPrefix() { + return "DvrStopSeriesRecordingFragment"; + } + + @Override + public String getTrackerLabelForGuidedAction(GuidedAction action) { + if (action.getId() == ACTION_STOP_SERIES_RECORDING) { + return "stop"; + } else { + return super.getTrackerLabelForGuidedAction(action); + } + } } diff --git a/src/com/android/tv/dvr/DvrUiHelper.java b/src/com/android/tv/dvr/ui/DvrUiHelper.java index c0d3b0c5..302fd6cd 100644 --- a/src/com/android/tv/dvr/DvrUiHelper.java +++ b/src/com/android/tv/dvr/ui/DvrUiHelper.java @@ -14,19 +14,27 @@ * limitations under the License. */ -package com.android.tv.dvr; +package com.android.tv.dvr.ui; import android.annotation.TargetApi; import android.app.Activity; +import android.app.ProgressDialog; import android.content.Context; +import android.content.DialogInterface; 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.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.ActivityOptionsCompat; +import android.text.Html; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.text.style.TextAppearanceSpan; import android.widget.ImageView; import android.widget.Toast; @@ -34,34 +42,40 @@ 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.BaseProgram; 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.dialog.HalfSizedDialogFragment; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.DvrStorageStatusManager; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.provider.EpisodicProgramLoadTask; 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.DvrNoFreeSpaceErrorDialogFragment; 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.browse.DvrBrowseActivity; +import com.android.tv.dvr.ui.browse.DvrDetailsActivity; +import com.android.tv.dvr.ui.list.DvrSchedulesActivity; import com.android.tv.dvr.ui.list.DvrSchedulesFragment; import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment; +import com.android.tv.dvr.ui.playback.DvrPlaybackActivity; +import com.android.tv.util.ToastUtils; import com.android.tv.util.Utils; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; /** * A helper class for DVR UI. @@ -69,94 +83,54 @@ import java.util.List; @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; + private static final String TAG = "DvrUiHelper"; - } + private static ProgressDialog sProgressDialog = null; /** * 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}. + * @param recordingRequestRunnable if the storage status is OK to record or users choose to + * perform the operation anyway, this Runnable will run. */ - 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; + public static void checkStorageStatusAndShowErrorMessage(Activity activity, String inputId, + Runnable recordingRequestRunnable) { + if (Utils.isBundledInput(inputId)) { + switch (TvApplication.getSingletons(activity).getDvrStorageStatusManager() + .getDvrStorageStatus()) { + case DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL: + showDvrSmallSizedStorageErrorDialog(activity); + return; + case DvrStorageStatusManager.STORAGE_STATUS_MISSING: + showDvrMissingStorageErrorDialog(activity); + return; + case DvrStorageStatusManager.STORAGE_STATUS_FREE_SPACE_INSUFFICIENT: + showDvrNoFreeSpaceErrorDialog(activity, recordingRequestRunnable); + return; + } } + recordingRequestRunnable.run(); } /** * Shows the schedule dialog. */ - public static void showScheduleDialog(MainActivity activity, Program program) { + public static void showScheduleDialog(Activity activity, Program program, + boolean addCurrentProgramToSeries) { if (SoftPreconditions.checkNotNull(program) == null) { return; } Bundle args = new Bundle(); args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program); + args.putBoolean(DvrScheduleFragment.KEY_ADD_CURRENT_PROGRAM_TO_SERIES, + addCurrentProgramToSeries); showDialogFragment(activity, new DvrScheduleDialogFragment(), args, true, true); } /** * Shows the recording duration options dialog. */ - public static void showChannelRecordDurationOptions(MainActivity activity, Channel channel) { + public static void showChannelRecordDurationOptions(Activity activity, Channel channel) { if (SoftPreconditions.checkNotNull(channel) == null) { return; } @@ -168,7 +142,7 @@ public class DvrUiHelper { /** * Shows the dialog which says that the new schedule conflicts with others. */ - public static void showScheduleConflictDialog(MainActivity activity, Program program) { + public static void showScheduleConflictDialog(Activity activity, Program program) { if (program == null) { return; } @@ -192,20 +166,47 @@ public class DvrUiHelper { /** * Shows DVR insufficient space error dialog. */ - public static void showDvrInsufficientSpaceErrorDialog(MainActivity activity) { - showDialogFragment(activity, new DvrInsufficientSpaceErrorDialogFragment(), null); + public static void showDvrInsufficientSpaceErrorDialog(MainActivity activity, + Set<String> failedScheduledRecordingInfoSet) { + Bundle args = new Bundle(); + ArrayList<String> failedScheduledRecordingInfoArray = + new ArrayList<>(failedScheduledRecordingInfoSet); + args.putStringArrayList(DvrInsufficientSpaceErrorFragment.FAILED_SCHEDULED_RECORDING_INFOS, + failedScheduledRecordingInfoArray); + showDialogFragment(activity, new DvrInsufficientSpaceErrorDialogFragment(), args); Utils.clearRecordingFailedReason(activity, TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); + Utils.clearFailedScheduledRecordingInfoSet(activity); + } + + /** + * Shows DVR no free space error dialog. + * + * @param recordingRequestRunnable the recording request to be executed when users choose + * {@link DvrGuidedStepFragment#ACTION_RECORD_ANYWAY}. + */ + public static void showDvrNoFreeSpaceErrorDialog(Activity activity, + Runnable recordingRequestRunnable) { + DvrHalfSizedDialogFragment fragment = new DvrNoFreeSpaceErrorDialogFragment(); + fragment.setOnActionClickListener(new HalfSizedDialogFragment.OnActionClickListener() { + @Override + public void onActionClick(long actionId) { + if (actionId == DvrGuidedStepFragment.ACTION_RECORD_ANYWAY) { + recordingRequestRunnable.run(); + } else if (actionId == DvrGuidedStepFragment.ACTION_DELETE_RECORDINGS) { + Intent intent = new Intent(activity, DvrBrowseActivity.class); + activity.startActivity(intent); + } + } + }); + showDialogFragment(activity, fragment, null); } /** * 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); + private static void showDvrMissingStorageErrorDialog(Activity activity) { + showDialogFragment(activity, new DvrMissingStorageErrorDialogFragment(), null); } /** @@ -231,7 +232,7 @@ public class DvrUiHelper { /** * Shows "already scheduled" dialog. */ - public static void showAlreadyScheduleDialog(MainActivity activity, Program program) { + public static void showAlreadyScheduleDialog(Activity activity, Program program) { if (program == null) { return; } @@ -243,7 +244,7 @@ public class DvrUiHelper { /** * Shows "already recorded" dialog. */ - public static void showAlreadyRecordedDialog(MainActivity activity, Program program) { + public static void showAlreadyRecordedDialog(Activity activity, Program program) { if (program == null) { return; } @@ -252,6 +253,87 @@ public class DvrUiHelper { showDialogFragment(activity, new DvrAlreadyRecordedDialogFragment(), args, false, true); } + /** + * Handle the request of recording a current program. It will handle creating schedules and + * shows the proper dialog and toast message respectively for timed-recording and program + * recording cases. + * + * @param addProgramToSeries denotes whether the program to be recorded should be added into + * the series recording when users choose to record the entire series. + */ + public static void requestRecordingCurrentProgram(Activity activity, + Channel channel, Program program, boolean addProgramToSeries) { + if (program == null) { + DvrUiHelper.showChannelRecordDurationOptions(activity, channel); + } else if (DvrUiHelper.handleCreateSchedule(activity, program, addProgramToSeries)) { + String msg = activity.getString(R.string.dvr_msg_current_program_scheduled, + program.getTitle(), Utils.toTimeString(program.getEndTimeUtcMillis(), false)); + Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show(); + } + } + + /** + * Handle the request of recording a future program. It will handle creating schedules and + * shows the proper toast message. + * + * @param addProgramToSeries denotes whether the program to be recorded should be added into + * the series recording when users choose to record the entire series. + */ + public static void requestRecordingFutureProgram(Activity activity, + Program program, boolean addProgramToSeries) { + if (DvrUiHelper.handleCreateSchedule(activity, program, addProgramToSeries)) { + String msg = activity.getString( + R.string.dvr_msg_program_scheduled, program.getTitle()); + ToastUtils.show(activity, msg, Toast.LENGTH_SHORT); + } + } + + /** + * 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}. + */ + private static boolean handleCreateSchedule(Activity activity, Program program, + boolean addProgramToSeries) { + 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 { + // 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; + } + SeriesRecording seriesRecording = dvrManager.getSeriesRecording(program); + if (seriesRecording == null || seriesRecording.isStopped()) { + DvrUiHelper.showScheduleDialog(activity, program, addProgramToSeries); + return false; + } else { + // Just add the schedule. + dvrManager.addSchedule(program); + } + } + return true; + } + private static void showDialogFragment(Activity activity, DvrHalfSizedDialogFragment dialogFragment, Bundle args) { showDialogFragment(activity, dialogFragment, args, false, false); @@ -291,6 +373,25 @@ public class DvrUiHelper { } /** + * Launches DVR playback activity for the give recorded program. + * + * @param programId the ID of the recorded program going to be played. + * @param seekTimeMs the seek position to initial playback. + * @param pinChecked {@code true} if the pin code for parental controls has already been + * verified, otherwise {@code false}. + */ + public static void startPlaybackActivity(Context context, long programId, + long seekTimeMs, boolean pinChecked) { + Intent intent = new Intent(context, DvrPlaybackActivity.class); + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, programId); + 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); + context.startActivity(intent); + } + + /** * Shows the schedules activity to resolve the tune conflict. */ public static void startSchedulesActivityForTuneConflict(Context context, Channel channel) { @@ -341,19 +442,66 @@ public class DvrUiHelper { /** * Shows the series settings activity. * - * @param channelIds Channel ID list which has programs belonging to the series. + * @param programs list of programs which belong to the series. */ public static void startSeriesSettingsActivity(Context context, long seriesRecordingId, - @Nullable long[] channelIds, boolean removeEmptySeriesSchedule, - boolean isWindowTranslucent, boolean showViewScheduleOptionInDialog) { + @Nullable List<Program> programs, boolean removeEmptySeriesSchedule, + boolean isWindowTranslucent, boolean showViewScheduleOptionInDialog, + Program currentProgram) { + SeriesRecording series = TvApplication.getSingletons(context).getDvrDataManager() + .getSeriesRecording(seriesRecordingId); + if (series == null) { + return; + } + if (programs != null) { + startSeriesSettingsActivityInternal(context, seriesRecordingId, programs, + removeEmptySeriesSchedule, isWindowTranslucent, + showViewScheduleOptionInDialog, currentProgram); + } else { + EpisodicProgramLoadTask episodicProgramLoadTask = + new EpisodicProgramLoadTask(context, series) { + @Override + protected void onPostExecute(List<Program> loadedPrograms) { + sProgressDialog.dismiss(); + sProgressDialog = null; + startSeriesSettingsActivityInternal(context, seriesRecordingId, + loadedPrograms == null ? Collections.EMPTY_LIST : loadedPrograms, + removeEmptySeriesSchedule, isWindowTranslucent, + showViewScheduleOptionInDialog, currentProgram); + } + }.setLoadCurrentProgram(true) + .setLoadDisallowedProgram(true) + .setLoadScheduledEpisode(true) + .setIgnoreChannelOption(true); + sProgressDialog = ProgressDialog.show(context, null, context.getString( + R.string.dvr_series_progress_message_reading_programs), true, true, + new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + episodicProgramLoadTask.cancel(true); + sProgressDialog = null; + } + }); + episodicProgramLoadTask.execute(); + } + } + + private static void startSeriesSettingsActivityInternal(Context context, long seriesRecordingId, + @NonNull List<Program> programs, boolean removeEmptySeriesSchedule, + boolean isWindowTranslucent, boolean showViewScheduleOptionInDialog, + Program currentProgram) { + SoftPreconditions.checkState(programs != null, + TAG, "Start series settings activity but programs is null"); Intent intent = new Intent(context, DvrSeriesSettingsActivity.class); intent.putExtra(DvrSeriesSettingsActivity.SERIES_RECORDING_ID, seriesRecordingId); - intent.putExtra(DvrSeriesSettingsActivity.CHANNEL_ID_LIST, channelIds); + BigArguments.reset(); + BigArguments.setArgument(DvrSeriesSettingsActivity.PROGRAM_LIST, programs); 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); + intent.putExtra(DvrSeriesSettingsActivity.CURRENT_PROGRAM, currentProgram); context.startActivity(intent); } @@ -361,7 +509,8 @@ public class DvrUiHelper { * Shows "series recording scheduled" dialog activity. */ public static void StartSeriesScheduledDialogActivity(Context context, - SeriesRecording seriesRecording, boolean showViewScheduleOptionInDialog) { + SeriesRecording seriesRecording, boolean showViewScheduleOptionInDialog, + List<Program> programs) { if (seriesRecording == null) { return; } @@ -370,6 +519,9 @@ public class DvrUiHelper { seriesRecording.getId()); intent.putExtra(DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION, showViewScheduleOptionInDialog); + BigArguments.reset(); + BigArguments.setArgument(DvrSeriesScheduledFragment.SERIES_SCHEDULED_KEY_PROGRAMS, + programs); context.startActivity(intent); } @@ -447,4 +599,51 @@ public class DvrUiHelper { Utils.toTimeString(endTimeMs, false)); Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); } -} + + /** + * Returns the styled schedule's title with its season and episode number. + */ + public static CharSequence getStyledTitleWithEpisodeNumber(Context context, + ScheduledRecording schedule, int episodeNumberStyleResId) { + return getStyledTitleWithEpisodeNumber(context, schedule.getProgramTitle(), + schedule.getSeasonNumber(), schedule.getEpisodeNumber(), episodeNumberStyleResId); + } + + /** + * Returns the styled program's title with its season and episode number. + */ + public static CharSequence getStyledTitleWithEpisodeNumber(Context context, + BaseProgram program, int episodeNumberStyleResId) { + return getStyledTitleWithEpisodeNumber(context, program.getTitle(), + program.getSeasonNumber(), program.getEpisodeNumber(), episodeNumberStyleResId); + } + + @NonNull + public static CharSequence getStyledTitleWithEpisodeNumber(Context context, String title, + String seasonNumber, String episodeNumber, int episodeNumberStyleResId) { + if (TextUtils.isEmpty(title)) { + return ""; + } + SpannableStringBuilder builder; + if (TextUtils.isEmpty(seasonNumber) || seasonNumber.equals("0")) { + builder = TextUtils.isEmpty(episodeNumber) ? new SpannableStringBuilder(title) : + new SpannableStringBuilder(Html.fromHtml( + context.getString(R.string.program_title_with_episode_number_no_season, + title, episodeNumber))); + } else { + builder = new SpannableStringBuilder(Html.fromHtml( + context.getString(R.string.program_title_with_episode_number, + title, seasonNumber, episodeNumber))); + } + Object[] spans = builder.getSpans(0, builder.length(), Object.class); + if (spans.length > 0) { + if (episodeNumberStyleResId != 0) { + builder.setSpan(new TextAppearanceSpan(context, episodeNumberStyleResId), + builder.getSpanStart(spans[0]), builder.getSpanEnd(spans[0]), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + builder.removeSpan(spans[0]); + } + return new SpannableString(builder); + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/FadeBackground.java b/src/com/android/tv/dvr/ui/FadeBackground.java new file mode 100644 index 00000000..4f06ebcf --- /dev/null +++ b/src/com/android/tv/dvr/ui/FadeBackground.java @@ -0,0 +1,70 @@ +/* + * 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.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.transition.Transition; +import android.transition.TransitionValues; +import android.transition.Visibility; +import android.util.AttributeSet; +import android.view.ViewGroup; + +import com.android.tv.R; + +/** + * This transition fades in/out of the background of the view by changing the background color. + */ +public class FadeBackground extends Transition { + private final int mMode; + + public FadeBackground(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FadeBackground); + mMode = a.getInt(R.styleable.FadeBackground_fadingMode, Visibility.MODE_IN); + a.recycle(); + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { } + + @Override + public void captureEndValues(TransitionValues transitionValues) { } + + @Override + public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, + TransitionValues endValues) { + if (startValues == null || endValues == null) { + return null; + } + Drawable background = endValues.view.getBackground(); + if (background instanceof ColorDrawable) { + int color = ((ColorDrawable) background).getColor(); + int transparentColor = Color.argb(0, Color.red(color), Color.green(color), + Color.blue(color)); + return mMode == Visibility.MODE_OUT + ? ObjectAnimator.ofArgb(background, "color", transparentColor) + : ObjectAnimator.ofArgb(background, "color", transparentColor, color); + } + return null; + } +} diff --git a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java deleted file mode 100644 index 1bf34310..00000000 --- a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java +++ /dev/null @@ -1,182 +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.app.Activity; -import android.content.Context; -import android.media.tv.TvContract; -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 com.android.tv.R; -import com.android.tv.TvApplication; -import com.android.tv.dvr.RecordedProgram; -import com.android.tv.data.Channel; -import com.android.tv.data.ChannelDataManager; -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 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; - - 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) { - RecordingCardView view = new RecordingCardView(mContext); - return new RecordedProgramViewHolder(view, mProgressBarColor); - } - - @Override - public void onBindViewHolder(ViewHolder viewHolder, Object o) { - final RecordedProgram program = (RecordedProgram) o; - final RecordingCardView cardView = (RecordingCardView) viewHolder.view; - 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); - } - cardView.setTitle(title); - String imageUri = null; - boolean isChannelLogo = false; - if (program.getPosterArtUri() != null) { - imageUri = program.getPosterArtUri(); - } else if (program.getThumbnailUri() != null) { - imageUri = program.getThumbnailUri(); - } else if (channel != null) { - imageUri = TvContract.buildChannelLogoUri(channel.getId()).toString(); - isChannelLogo = true; - } - cardView.setImageUri(imageUri, isChannelLogo); - int durationMinutes = - Math.max(1, (int) TimeUnit.MILLISECONDS.toMinutes(program.getDurationMillis())); - String durationString = getContext().getResources().getQuantityString( - R.plurals.dvr_program_duration, durationMinutes, durationMinutes); - cardView.setContent(getDescription(program), durationString); - 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) { - 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/RecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java deleted file mode 100644 index 4e19ec3f..00000000 --- a/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java +++ /dev/null @@ -1,87 +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.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/ScheduledRecordingPresenter.java b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java deleted file mode 100644 index 5f447f13..00000000 --- a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java +++ /dev/null @@ -1,177 +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.app.Activity; -import android.content.Context; -import android.media.tv.TvContract; -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 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.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 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 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, 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, mProgressBarColor); - } - - @Override - public void onBindViewHolder(ViewHolder baseHolder, Object o) { - final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; - final ScheduledRecording recording = (ScheduledRecording) o; - final RecordingCardView cardView = (RecordingCardView) viewHolder.view; - final Context context = viewHolder.view.getContext(); - - 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 { - cardView.setContent(Utils.getDurationString(context, recording.getStartTimeMs(), - recording.getStartTimeMs(), false, true, false, 0), null); - } - if (mDvrManager.isConflicting(recording)) { - cardView.setAffiliatedIcon(R.drawable.ic_warning_white_32dp); - } else { - 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; - viewHolder.mScheduledRecording = null; - cardView.reset(); - super.onUnbindViewHolder(viewHolder); - } - - 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/SortedArrayAdapter.java b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java index 393a5ff3..8c0af9ed 100644 --- a/src/com/android/tv/dvr/ui/SortedArrayAdapter.java +++ b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java @@ -20,11 +20,15 @@ import android.support.annotation.VisibleForTesting; import android.support.v17.leanback.widget.ArrayObjectAdapter; import android.support.v17.leanback.widget.PresenterSelector; +import com.android.tv.common.SoftPreconditions; + import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** * Keeps a set of items sorted @@ -35,16 +39,18 @@ public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { private final Comparator<T> mComparator; private final int mMaxItemCount; private int mExtraItemCount; + private final Set<Long> mIds = new HashSet<>(); - SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator) { + public SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator) { this(presenterSelector, comparator, Integer.MAX_VALUE); } - SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator, + public SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator, int maxItemCount) { super(presenterSelector); mComparator = comparator; mMaxItemCount = maxItemCount; + setHasStableIds(true); } /** @@ -56,7 +62,12 @@ public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { 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()))); + for (T item : itemsCopy) { + add(item, true); + if (size() == mMaxItemCount) { + break; + } + } } /** @@ -82,6 +93,9 @@ public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { * the end to save search time. */ public final void add(T item, boolean insertToEnd) { + long newItemId = getId(item); + SoftPreconditions.checkState(!mIds.contains(newItemId)); + mIds.add(newItemId); int i; if (insertToEnd) { i = findInsertPosition(item); @@ -89,8 +103,9 @@ public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { i = findInsertPositionBinary(item); } super.add(i, item); - if (size() > mMaxItemCount + mExtraItemCount) { - removeItems(mMaxItemCount, size() - mMaxItemCount - mExtraItemCount); + if (mMaxItemCount < Integer.MAX_VALUE && size() > mMaxItemCount + mExtraItemCount) { + Object removedItem = get(mMaxItemCount); + remove(removedItem); } } @@ -100,48 +115,97 @@ public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter { * They will be presented in their insertion order. */ public int addExtraItem(T item) { + long newItemId = getId(item); + SoftPreconditions.checkState(!mIds.contains(newItemId)); + mIds.add(newItemId); super.add(item); return ++mExtraItemCount; } + @Override + public boolean remove(Object item) { + return removeWithId((T) item); + } + /** * 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)); + int index = indexWithId(item); + return index >= 0 && index < size() && removeItems(index, 1) == 1; + } + + @Override + public int removeItems(int position, int count) { + int upperBound = Math.min(position + count, size()); + for (int i = position; i < upperBound; i++) { + mIds.remove(getId((T) get(i))); + } + if (upperBound > size() - mExtraItemCount) { + mExtraItemCount -= upperBound - Math.max(size() - mExtraItemCount, position); + } + return super.removeItems(position, count); + } + + @Override + public void replace(int position, Object item) { + boolean wasExtra = position >= size() - mExtraItemCount; + removeItems(position, 1); + if (!wasExtra) { + add(item); + } else { + addExtraItem((T) item); + } + } + + @Override + public void clear() { + mIds.clear(); + super.clear(); } /** - * Change an item in the list. + * Changes an item in the list. * @param item The item to change. */ public final void change(T item) { - int oldIndex = indexWithTypeAndId(item); + int oldIndex = indexWithId(item); if (oldIndex != -1) { T old = (T) get(oldIndex); if (mComparator.compare(old, item) == 0) { replace(oldIndex, item); return; } - removeItems(oldIndex, 1); + remove(old); } add(item); } /** + * Checks whether the item is in the list. + */ + public final boolean contains(T item) { + return indexWithId(item) != -1; + } + + @Override + public long getId(int position) { + return getId((T) get(position)); + } + + /** * 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); + protected abstract long getId(T item); - private int indexWithTypeAndId(T item) { + private int indexWithId(T item) { long id = getId(item); for (int i = 0; i < size() - mExtraItemCount; i++) { T r = (T) get(i); - if (r.getClass() == item.getClass() && getId(r) == id) { + if (getId(r) == id) { return i; } } diff --git a/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java b/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java new file mode 100644 index 00000000..5fe9c478 --- /dev/null +++ b/src/com/android/tv/dvr/ui/TrackedGuidedStepFragment.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2017 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.GuidedAction; + +import com.android.tv.TvApplication; +import com.android.tv.analytics.Tracker; + +/** A {@link GuidedStepFragment} with {@link Tracker} for analytics. */ +public abstract class TrackedGuidedStepFragment extends GuidedStepFragment { + private Tracker mTracker; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mTracker = TvApplication.getSingletons(context).getAnalytics().getDefaultTracker(); + } + + @Override + public void onDetach() { + mTracker = null; + super.onDetach(); + } + + @Override + public final void onGuidedActionClicked(GuidedAction action) { + super.onGuidedActionClicked(action); + if (mTracker != null) { + mTracker.sendMenuClicked( + getTrackerPrefix() + "-action-" + getTrackerLabelForGuidedAction(action)); + } + onTrackedGuidedActionClicked(action); + } + + public String getTrackerLabelForGuidedAction(GuidedAction action) { + long actionId = action.getId(); + if (actionId == GuidedAction.ACTION_ID_CANCEL) { + return "cancel"; + } else if (actionId == GuidedAction.ACTION_ID_NEXT) { + return "next"; + } else if (actionId == GuidedAction.ACTION_ID_CURRENT) { + return "current"; + } else if (actionId == GuidedAction.ACTION_ID_OK) { + return "ok"; + } else if (actionId == GuidedAction.ACTION_ID_CANCEL) { + return "cancel"; + } else if (actionId == GuidedAction.ACTION_ID_FINISH) { + return "finish"; + } else if (actionId == GuidedAction.ACTION_ID_CONTINUE) { + return "continue"; + } else if (actionId == GuidedAction.ACTION_ID_YES) { + return "yes"; + } else if (actionId == GuidedAction.ACTION_ID_NO) { + return "no"; + } else { + return "unknown-" + actionId; + } + } + + /** Delegated from {@link #onGuidedActionClicked(GuidedAction)} */ + public abstract void onTrackedGuidedActionClicked(GuidedAction action); + + /** The prefix used for analytics tracking, Usually the class name. */ + public abstract String getTrackerPrefix(); +} diff --git a/src/com/android/tv/dvr/ui/ActionPresenterSelector.java b/src/com/android/tv/dvr/ui/browse/ActionPresenterSelector.java index 8b8cd5c5..38a78f5d 100644 --- a/src/com/android/tv/dvr/ui/ActionPresenterSelector.java +++ b/src/com/android/tv/dvr/ui/browse/ActionPresenterSelector.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.graphics.drawable.Drawable; import android.support.v17.leanback.R; @@ -110,11 +110,7 @@ class ActionPresenterSelector extends PresenterSelector { .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); - } + vh.mButton.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null); CharSequence line1 = action.getLabel1(); CharSequence line2 = action.getLabel2(); @@ -130,7 +126,7 @@ class ActionPresenterSelector extends PresenterSelector { @Override public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { ActionViewHolder vh = (ActionViewHolder) viewHolder; - vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + vh.mButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null); vh.view.setPadding(0, 0, 0, 0); vh.mAction = null; } diff --git a/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.java new file mode 100644 index 00000000..bf18ddc0 --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/CurrentRecordingDetailsFragment.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.dvr.ui.browse; + +import android.content.Context; +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.dialog.HalfSizedDialogFragment; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.ui.DvrStopRecordingFragment; +import com.android.tv.dvr.ui.DvrUiHelper; + +/** + * {@link RecordingDetailsFragment} for current recording in DVR. + */ +public class CurrentRecordingDetailsFragment extends RecordingDetailsFragment { + private static final int ACTION_STOP_RECORDING = 1; + + private DvrDataManager mDvrDataManger; + private final DvrDataManager.ScheduledRecordingListener mScheduledRecordingListener = + new DvrDataManager.ScheduledRecordingListener() { + @Override + public void onScheduledRecordingAdded(ScheduledRecording... schedules) { } + + @Override + public void onScheduledRecordingRemoved(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + if (schedule.getId() == getRecording().getId()) { + getActivity().finish(); + return; + } + } + } + + @Override + public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + if (schedule.getId() == getRecording().getId() + && schedule.getState() + != ScheduledRecording.STATE_RECORDING_IN_PROGRESS) { + getActivity().finish(); + return; + } + } + } + }; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mDvrDataManger = TvApplication.getSingletons(context).getDvrDataManager(); + mDvrDataManger.addScheduledRecordingListener(mScheduledRecordingListener); + } + + @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.dvr_detail_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) { + DvrUiHelper.showStopRecordingDialog(getActivity(), + getRecording().getChannelId(), + DvrStopRecordingFragment.REASON_USER_STOP, + new HalfSizedDialogFragment.OnActionClickListener() { + @Override + public void onActionClick(long actionId) { + if (actionId == DvrStopRecordingFragment.ACTION_STOP) { + DvrManager dvrManager = + TvApplication.getSingletons(getContext()) + .getDvrManager(); + dvrManager.stopRecording(getRecording()); + getActivity().finish(); + } + } + }); + } + } + }; + } + + @Override + public void onDetach() { + if (mDvrDataManger != null) { + mDvrDataManger.removeScheduledRecordingListener(mScheduledRecordingListener); + } + super.onDetach(); + } +} diff --git a/src/com/android/tv/dvr/ui/browse/DetailsContent.java b/src/com/android/tv/dvr/ui/browse/DetailsContent.java new file mode 100644 index 00000000..c1fa05d7 --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/DetailsContent.java @@ -0,0 +1,317 @@ +/* + * 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.browse; + +import android.content.Context; +import android.media.tv.TvContract; +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.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.ui.DvrUiHelper; + +/** + * A class for details content. + */ +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 boolean mUsingChannelLogo; + + static DetailsContent createFromRecordedProgram(Context context, + RecordedProgram recordedProgram) { + return new DetailsContent.Builder() + .setChannelId(recordedProgram.getChannelId()) + .setProgramTitle(recordedProgram.getTitle()) + .setSeasonNumber(recordedProgram.getSeasonNumber()) + .setEpisodeNumber(recordedProgram.getEpisodeNumber()) + .setStartTimeUtcMillis(recordedProgram.getStartTimeUtcMillis()) + .setEndTimeUtcMillis(recordedProgram.getEndTimeUtcMillis()) + .setDescription(TextUtils.isEmpty(recordedProgram.getLongDescription()) + ? recordedProgram.getDescription() : recordedProgram.getLongDescription()) + .setPosterArtUri(recordedProgram.getPosterArtUri()) + .setThumbnailUri(recordedProgram.getThumbnailUri()) + .build(context); + } + + static DetailsContent createFromSeriesRecording(Context context, + SeriesRecording seriesRecording) { + return new DetailsContent.Builder() + .setChannelId(seriesRecording.getChannelId()) + .setTitle(seriesRecording.getTitle()) + .setDescription(TextUtils.isEmpty(seriesRecording.getLongDescription()) + ? seriesRecording.getDescription() : seriesRecording.getLongDescription()) + .setPosterArtUri(seriesRecording.getPosterUri()) + .setThumbnailUri(seriesRecording.getPhotoUri()) + .build(context); + } + + static DetailsContent createFromScheduledRecording(Context context, + ScheduledRecording scheduledRecording) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(scheduledRecording.getChannelId()); + String description = !TextUtils.isEmpty(scheduledRecording.getProgramDescription()) ? + scheduledRecording.getProgramDescription() + : scheduledRecording.getProgramLongDescription(); + if (TextUtils.isEmpty(description)) { + description = channel != null ? channel.getDescription() : null; + } + return new DetailsContent.Builder() + .setChannelId(scheduledRecording.getChannelId()) + .setProgramTitle(scheduledRecording.getProgramTitle()) + .setSeasonNumber(scheduledRecording.getSeasonNumber()) + .setEpisodeNumber(scheduledRecording.getEpisodeNumber()) + .setStartTimeUtcMillis(scheduledRecording.getStartTimeMs()) + .setEndTimeUtcMillis(scheduledRecording.getEndTimeMs()) + .setDescription(description) + .setPosterArtUri(scheduledRecording.getProgramPosterArtUri()) + .setThumbnailUri(scheduledRecording.getProgramThumbnailUri()) + .build(context); + } + + 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; + } + + /** + * Returns if image URIs are from its channels' logo. + */ + public boolean isUsingChannelLogo() { + return mUsingChannelLogo; + } + + /** + * 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; + mUsingChannelLogo = other.mUsingChannelLogo; + } + + /** + * A class for building details content. + */ + public static final class Builder { + private final DetailsContent mDetailsContent; + + private long mChannelId; + private String mProgramTitle; + private String mSeasonNumber; + private String mEpisodeNumber; + private String mPosterArtUri; + private String mThumbnailUri; + + 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; + } + + private Builder setProgramTitle(String programTitle) { + mProgramTitle = programTitle; + return this; + } + + private Builder setSeasonNumber(String seasonNumber) { + mSeasonNumber = seasonNumber; + return this; + } + + private Builder setEpisodeNumber(String episodeNumber) { + mEpisodeNumber = episodeNumber; + return this; + } + + private Builder setChannelId(long channelId) { + mChannelId = channelId; + return this; + } + + private Builder setPosterArtUri(String posterArtUri) { + mPosterArtUri = posterArtUri; + return this; + } + + private Builder setThumbnailUri(String thumbnailUri) { + mThumbnailUri = thumbnailUri; + return this; + } + + private void createStyledTitle(Context context, Channel channel) { + CharSequence title = DvrUiHelper.getStyledTitleWithEpisodeNumber(context, + mProgramTitle, mSeasonNumber, mEpisodeNumber, + R.style.text_appearance_card_view_episode_number); + if (TextUtils.isEmpty(title)) { + mDetailsContent.mTitle = channel != null ? channel.getDisplayName() + : context.getResources().getString(R.string.no_program_information); + } else { + mDetailsContent.mTitle = title; + } + } + + private void createImageUris(@Nullable Channel channel) { + mDetailsContent.mLogoImageUri = null; + mDetailsContent.mBackgroundImageUri = null; + mDetailsContent.mUsingChannelLogo = false; + if (!TextUtils.isEmpty(mPosterArtUri) && !TextUtils.isEmpty(mThumbnailUri)) { + mDetailsContent.mLogoImageUri = mPosterArtUri; + mDetailsContent.mBackgroundImageUri = mThumbnailUri; + } else if (!TextUtils.isEmpty(mPosterArtUri)) { + // thumbnailUri is empty + mDetailsContent.mLogoImageUri = mPosterArtUri; + mDetailsContent.mBackgroundImageUri = mPosterArtUri; + } else if (!TextUtils.isEmpty(mThumbnailUri)) { + // posterArtUri is empty + mDetailsContent.mLogoImageUri = mThumbnailUri; + mDetailsContent.mBackgroundImageUri = mThumbnailUri; + } + if (TextUtils.isEmpty(mDetailsContent.mLogoImageUri) && channel != null) { + String channelLogoUri = TvContract.buildChannelLogoUri(channel.getId()) + .toString(); + mDetailsContent.mLogoImageUri = channelLogoUri; + mDetailsContent.mBackgroundImageUri = channelLogoUri; + mDetailsContent.mUsingChannelLogo = true; + } + } + + /** + * Builds details content. + */ + public DetailsContent build(Context context) { + Channel channel = TvApplication.getSingletons(context).getChannelDataManager() + .getChannel(mChannelId); + if (mDetailsContent.mTitle == null) { + createStyledTitle(context, channel); + } + if (mDetailsContent.mBackgroundImageUri == null + && mDetailsContent.mLogoImageUri == null) { + createImageUris(channel); + } + 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/browse/DetailsContentPresenter.java index 175f05bc..09b57887 100644 --- a/src/com/android/tv/dvr/ui/DetailsContentPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/DetailsContentPresenter.java @@ -14,13 +14,14 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.app.Activity; import android.animation.Animator; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; +import android.content.Context; import android.graphics.Paint; import android.graphics.Paint.FontMetricsInt; import android.support.v17.leanback.widget.Presenter; @@ -29,6 +30,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; import android.widget.LinearLayout; import android.widget.TextView; @@ -38,13 +40,14 @@ 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}. + * Typically this Presenter will be used in a + * {@link android.support.v17.leanback.widget.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 { +class DetailsContentPresenter extends Presenter { /** * The ViewHolder for the {@link DetailsContentPresenter}. */ @@ -82,25 +85,38 @@ public class DetailsContentPresenter extends Presenter { return false; } final int bodyLines = mBody.getLineCount(); - final int maxLines = mFullTextMode ? bodyLines : + int maxLines = mFullTextMode ? bodyLines : (mTitle.getLineCount() > 1 ? mBodyMinLines : mBodyMaxLines); if (bodyLines > maxLines) { mReadMoreView.setVisibility(View.VISIBLE); mDescriptionContainer.setFocusable(true); + mDescriptionContainer.setClickable(true); mDescriptionContainer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { mFullTextMode = true; mReadMoreView.setVisibility(View.GONE); - mDescriptionContainer.setFocusable(false); + mDescriptionContainer.setFocusable(( + (AccessibilityManager) view.getContext() + .getSystemService( + Context.ACCESSIBILITY_SERVICE)) + .isEnabled()); + mDescriptionContainer.setClickable(false); mDescriptionContainer.setOnClickListener(null); + int oldMaxLines = mBody.getMaxLines(); mBody.setMaxLines(bodyLines); // Minus 1 from line difference to eliminate the space // originally occupied by "READ MORE" - showFullText((bodyLines - maxLines - 1) * mBodyLineSpacing); + showFullText((bodyLines - oldMaxLines - 1) * mBodyLineSpacing); } }); } + if (mReadMoreView.getVisibility() == View.VISIBLE + && mSubtitle.getVisibility() == View.VISIBLE) { + // If both "READ MORE" and subtitle is shown, the capable maximum lines + // will be one line less. + maxLines -= 1; + } if (mBody.getMaxLines() != maxLines) { mBody.setMaxLines(maxLines); return false; @@ -113,11 +129,30 @@ public class DetailsContentPresenter extends Presenter { public ViewHolder(final View view) { super(view); + view.addOnAttachStateChangeListener( + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + // In case predraw listener was removed in detach, make sure + // we have the proper layout. + addPreDrawListener(); + } + + @Override + public void onViewDetachedFromWindow(View v) { + removePreDrawListener(); + } + }); 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); + // We have to explicitly set focusable to true here for accessibility, since we might + // set the view's focusable state when we need to show "READ MORE", which would remove + // the default focusable state for accessibility. + mDescriptionContainer.setFocusable(((AccessibilityManager) view.getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE)).isEnabled()); mReadMoreView = (TextView) view.findViewById(R.id.dvr_details_description_read_more); FontMetricsInt titleFontMetricsInt = getFontMetricsInt(mTitle); @@ -129,7 +164,7 @@ public class DetailsContentPresenter extends Presenter { 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); + R.dimen.dvr_details_description_under_subtitle_baseline_margin); mTitleLineSpacing = view.getResources().getDimensionPixelSize( R.dimen.lb_details_description_title_line_spacing); @@ -276,22 +311,6 @@ public class DetailsContentPresenter extends Presenter { @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; diff --git a/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java b/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java index 6714ecd3..82fe9ce3 100644 --- a/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java +++ b/src/com/android/tv/dvr/ui/browse/DetailsViewBackgroundHelper.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.app.Activity; import android.graphics.drawable.BitmapDrawable; @@ -26,7 +26,7 @@ import android.support.v17.leanback.app.BackgroundManager; /** * The Background Helper. */ -public class DetailsViewBackgroundHelper { +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; diff --git a/src/com/android/tv/dvr/ui/DvrActivity.java b/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java index 45fb1cf1..07eec107 100644 --- a/src/com/android/tv/dvr/ui/DvrActivity.java +++ b/src/com/android/tv/dvr/ui/browse/DvrBrowseActivity.java @@ -14,9 +14,11 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.app.Activity; +import android.content.Intent; +import android.media.tv.TvInputManager; import android.os.Bundle; import com.android.tv.R; @@ -25,11 +27,26 @@ import com.android.tv.TvApplication; /** * {@link android.app.Activity} for DVR UI. */ -public class DvrActivity extends Activity { +public class DvrBrowseActivity extends Activity { + private DvrBrowseFragment mFragment; + @Override public void onCreate(Bundle savedInstanceState) { TvApplication.setCurrentRunningProcess(this, true); super.onCreate(savedInstanceState); setContentView(R.layout.dvr_main); + mFragment = (DvrBrowseFragment) getFragmentManager().findFragmentById(R.id.dvr_frame); + handleIntent(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + handleIntent(intent); + } + + private void handleIntent(Intent intent) { + if (TvInputManager.ACTION_VIEW_RECORDING_SCHEDULES.equals(intent.getAction())) { + mFragment.showScheduledRow(); + } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java b/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java index a6dd31d1..cb3a5745 100644 --- a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java +++ b/src/com/android/tv/dvr/ui/browse/DvrBrowseFragment.java @@ -14,10 +14,9 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.content.Context; -import android.media.tv.TvInputManager.TvInputCallback; import android.os.Bundle; import android.os.Handler; import android.support.v17.leanback.app.BrowseFragment; @@ -25,11 +24,11 @@ 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.Presenter; import android.support.v17.leanback.widget.TitleViewAdapter; -import android.text.TextUtils; import android.util.Log; +import android.view.View; +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; import com.android.tv.ApplicationSingletons; import com.android.tv.R; @@ -42,9 +41,10 @@ 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 com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.ui.SortedArrayAdapter; import java.util.ArrayList; import java.util.Arrays; @@ -64,12 +64,16 @@ public class DvrBrowseFragment extends BrowseFragment implements private static final int MAX_RECENT_ITEM_COUNT = 10; private static final int MAX_SCHEDULED_ITEM_COUNT = 4; + private boolean mShouldShowScheduleRow; + private boolean mEntranceTransitionEnded; + private RecordedProgramAdapter mRecentAdapter; private ScheduleAdapter mScheduleAdapter; private SeriesAdapter mSeriesAdapter; private RecordedProgramAdapter[] mGenreAdapters = new RecordedProgramAdapter[GenreItems.getGenreCount() + 1]; private ListRow mRecentRow; + private ListRow mScheduledRow; private ListRow mSeriesRow; private ListRow[] mGenreRows = new ListRow[GenreItems.getGenreCount() + 1]; private List<String> mGenreLabels; @@ -79,6 +83,20 @@ public class DvrBrowseFragment extends BrowseFragment implements private ClassPresenterSelector mPresenterSelector; private final HashMap<String, RecordedProgram> mSeriesId2LatestProgram = new HashMap<>(); private final Handler mHandler = new Handler(); + private final OnGlobalFocusChangeListener mOnGlobalFocusChangeListener = + new OnGlobalFocusChangeListener() { + @Override + public void onGlobalFocusChanged(View oldFocus, View newFocus) { + if (oldFocus instanceof RecordingCardView) { + ((RecordingCardView) oldFocus).expandTitle(false, true); + } + if (newFocus instanceof RecordingCardView) { + // If the header transition is ongoing, expand cards immediately without + // animation to make a smooth transition. + ((RecordingCardView) newFocus).expandTitle(true, !isInHeadersTransition()); + } + } + }; private final Comparator<Object> RECORDED_PROGRAM_COMPARATOR = new Comparator<Object>() { @Override @@ -104,7 +122,7 @@ public class DvrBrowseFragment extends BrowseFragment implements } }; - private final Comparator<Object> SCHEDULE_COMPARATOR = new Comparator<Object>() { + private static final Comparator<Object> SCHEDULE_COMPARATOR = new Comparator<Object>() { @Override public int compare(Object lhs, Object rhs) { if (lhs instanceof ScheduledRecording) { @@ -128,7 +146,7 @@ public class DvrBrowseFragment extends BrowseFragment implements public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) { if (mScheduleAdapter != null) { for (ScheduledRecording schedule : schedules) { - onScheduledRecordingStatusChanged(schedule); + onScheduledRecordingConflictStatusChanged(schedule); } } } @@ -154,16 +172,12 @@ public class DvrBrowseFragment extends BrowseFragment implements new ScheduledRecordingPresenter(context)) .addClassPresenter(RecordedProgram.class, new RecordedProgramPresenter(context)) .addClassPresenter(SeriesRecording.class, new SeriesRecordingPresenter(context)) - .addClassPresenter(FullScheduleCardHolder.class, new FullSchedulesCardPresenter()); + .addClassPresenter(FullScheduleCardHolder.class, + new FullSchedulesCardPresenter(context)); mGenreLabels = new ArrayList<>(Arrays.asList(GenreItems.getLabels(context))); mGenreLabels.add(getString(R.string.dvr_main_others)); - setupUiElements(); - setupAdapters(); - mDvrScheudleManager.addOnConflictStateChangeListener(mOnConflictStateChangeListener); - prepareEntranceTransition(); - if (mDvrDataManager.isInitialized()) { - startEntranceTransition(); - } else { + prepareUiElements(); + if (!startBrowseIfDvrInitialized()) { if (!mDvrDataManager.isDvrScheduleLoadFinished()) { mDvrDataManager.addDvrScheduleLoadFinishedListener(this); } @@ -174,6 +188,19 @@ public class DvrBrowseFragment extends BrowseFragment implements } @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + view.getViewTreeObserver().addOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener); + } + + @Override + public void onDestroyView() { + getView().getViewTreeObserver() + .removeOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener); + super.onDestroyView(); + } + + @Override public void onDestroy() { if (DEBUG) Log.d(TAG, "onDestroy"); mHandler.removeCallbacks(mUpdateRowsRunnable); @@ -195,25 +222,13 @@ public class DvrBrowseFragment extends BrowseFragment implements @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(); - } + startBrowseIfDvrInitialized(); mDvrDataManager.removeDvrScheduleLoadFinishedListener(this); } @Override public void onRecordedProgramLoadFinished() { - for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) { - handleRecordedProgramAdded(recordedProgram, true); - } - updateRows(); - if (mDvrDataManager.isInitialized()) { - startEntranceTransition(); - } + startBrowseIfDvrInitialized(); mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); } @@ -270,6 +285,18 @@ public class DvrBrowseFragment extends BrowseFragment implements } } + private void onScheduledRecordingConflictStatusChanged(ScheduledRecording... schedules) { + for (ScheduledRecording schedule : schedules) { + if (needToShowScheduledRecording(schedule)) { + if (mScheduleAdapter.contains(schedule)) { + mScheduleAdapter.change(schedule); + } + } else { + mScheduleAdapter.removeWithId(schedule); + } + } + } + @Override public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { handleSeriesRecordingsAdded(Arrays.asList(seriesRecordings)); @@ -295,44 +322,80 @@ public class DvrBrowseFragment extends BrowseFragment implements super.showTitle(flags); } - private void setupUiElements() { + @Override + protected void onEntranceTransitionEnd() { + super.onEntranceTransitionEnd(); + if (mShouldShowScheduleRow) { + showScheduledRowInternal(); + } + mEntranceTransitionEnded = true; + } + + void showScheduledRow() { + if (!mEntranceTransitionEnded) { + setHeadersState(HEADERS_HIDDEN); + mShouldShowScheduleRow = true; + } else { + showScheduledRowInternal(); + } + } + + private void showScheduledRowInternal() { + setSelectedPosition(mRowsAdapter.indexOf(mScheduledRow), true, null); + if (getHeadersState() == HEADERS_ENABLED) { + startHeadersTransition(false); + } + mShouldShowScheduleRow = false; + } + + private void prepareUiElements() { setBadgeDrawable(getActivity().getDrawable(R.drawable.ic_dvr_badge)); setHeadersState(HEADERS_ENABLED); setHeadersTransitionOnBackEnabled(false); setBrandColor(getResources().getColor(R.color.program_guide_side_panel_background, null)); + mRowsAdapter = new ArrayObjectAdapter(new DvrListRowPresenter(getContext())); + setAdapter(mRowsAdapter); + prepareEntranceTransition(); } - 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); + private boolean startBrowseIfDvrInitialized() { + if (mDvrDataManager.isInitialized()) { + // Setup rows + 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); + mRecentRow = new ListRow(new HeaderItem( + getString(R.string.dvr_main_recent)), mRecentAdapter); + mScheduledRow = new ListRow(new HeaderItem( + getString(R.string.dvr_main_scheduled)), mScheduleAdapter); + mSeriesRow = new ListRow(new HeaderItem( + getString(R.string.dvr_main_series)), mSeriesAdapter); + mRowsAdapter.add(mScheduledRow); + updateRows(); + // Initialize listeners + mDvrDataManager.addRecordedProgramListener(this); + mDvrDataManager.addScheduledRecordingListener(this); + mDvrDataManager.addSeriesRecordingListener(this); + mDvrScheudleManager.addOnConflictStateChangeListener(mOnConflictStateChangeListener); + startEntranceTransition(); + return true; + } + return false; } private void handleRecordedProgramAdded(RecordedProgram recordedProgram, @@ -589,10 +652,11 @@ public class DvrBrowseFragment extends BrowseFragment implements @Override public long getId(Object item) { + // We takes the inverse number for the ID of recorded programs to make the ID stable. if (item instanceof SeriesRecording) { return ((SeriesRecording) item).getId(); } else if (item instanceof RecordedProgram) { - return ((RecordedProgram) item).getId(); + return -((RecordedProgram) item).getId() - 1; } else { return -1; } diff --git a/src/com/android/tv/dvr/ui/DvrDetailsActivity.java b/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java index 806c775c..35d21db8 100644 --- a/src/com/android/tv/dvr/ui/DvrDetailsActivity.java +++ b/src/com/android/tv/dvr/ui/browse/DvrDetailsActivity.java @@ -14,19 +14,23 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.app.Activity; import android.os.Bundle; import android.support.v17.leanback.app.DetailsFragment; +import android.transition.Transition; +import android.transition.Transition.TransitionListener; +import android.view.View; import com.android.tv.R; import com.android.tv.TvApplication; +import com.android.tv.dialog.PinDialogFragment; /** * Activity to show details view in DVR. */ -public class DvrDetailsActivity extends Activity { +public class DvrDetailsActivity extends Activity implements PinDialogFragment.OnPinCheckedListener { /** * Name of record id added to the Intent. */ @@ -68,6 +72,8 @@ public class DvrDetailsActivity extends Activity { */ public static final int SERIES_RECORDING_VIEW = 4; + private PinDialogFragment.OnPinCheckedListener mOnPinCheckedListener; + @Override public void onCreate(Bundle savedInstanceState) { TvApplication.setCurrentRunningProcess(this, true); @@ -94,5 +100,55 @@ public class DvrDetailsActivity extends Activity { getFragmentManager().beginTransaction() .replace(R.id.dvr_details_view_frame, detailsFragment).commit(); } + + // This is a workaround for the focus on O device + addTransitionListener(); + } + + @Override + public void onPinChecked(boolean checked, int type, String rating) { + if (mOnPinCheckedListener != null) { + mOnPinCheckedListener.onPinChecked(checked, type, rating); + } + } + + void setOnPinCheckListener(PinDialogFragment.OnPinCheckedListener listener) { + mOnPinCheckedListener = listener; + } + + private void addTransitionListener() { + getWindow() + .getSharedElementEnterTransition() + .addListener( + new TransitionListener() { + @Override + public void onTransitionStart(Transition transition) { + // Do nothing + } + + @Override + public void onTransitionEnd(Transition transition) { + View actions = findViewById(R.id.details_overview_actions); + if (actions != null) { + actions.requestFocus(); + } + } + + @Override + public void onTransitionCancel(Transition transition) { + // Do nothing + + } + + @Override + public void onTransitionPause(Transition transition) { + // Do nothing + } + + @Override + public void onTransitionResume(Transition transition) { + // Do nothing + } + }); } } diff --git a/src/com/android/tv/dvr/ui/DvrDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java index 21f9c4b4..19fb7117 100644 --- a/src/com/android/tv/dvr/ui/DvrDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/DvrDetailsFragment.java @@ -14,16 +14,14 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; 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; @@ -36,20 +34,18 @@ 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.common.SoftPreconditions; 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.dialog.PinDialogFragment.OnPinCheckedListener; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.util.ImageLoader; import com.android.tv.util.ToastUtils; @@ -163,26 +159,6 @@ abstract class DvrDetailsFragment extends DetailsFragment { 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) { @@ -233,10 +209,11 @@ abstract class DvrDetailsFragment extends DetailsFragment { Toast.LENGTH_SHORT); return; } + long programId = recordedProgram.getId(); ParentalControlSettings parental = TvApplication.getSingletons(getActivity()) .getTvInputManagerHelper().getParentalControlSettings(); if (!parental.isParentalControlsEnabled()) { - launchPlaybackActivity(recordedProgram, seekTimeMs, false); + DvrUiHelper.startPlaybackActivity(getContext(), programId, seekTimeMs, false); return; } ChannelDataManager channelDataManager = @@ -246,21 +223,12 @@ abstract class DvrDetailsFragment extends DetailsFragment { 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); + TvContentRating[] ratings = recordedProgram.getContentRatings(); + TvContentRating blockRatings = parental.getBlockedRating(ratings); if (blockRatings != null) { checkPinToPlay(recordedProgram, seekTimeMs); } else { - launchPlaybackActivity(recordedProgram, seekTimeMs, false); + DvrUiHelper.startPlaybackActivity(getContext(), programId, seekTimeMs, false); } } @@ -279,26 +247,21 @@ abstract class DvrDetailsFragment extends DetailsFragment { } 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); - } + SoftPreconditions.checkState(getActivity() instanceof DvrDetailsActivity); + if (getActivity() instanceof DvrDetailsActivity) { + ((DvrDetailsActivity) getActivity()).setOnPinCheckListener(new OnPinCheckedListener() { + @Override + public void onPinChecked(boolean checked, int type, String rating) { + ((DvrDetailsActivity) getActivity()).setOnPinCheckListener(null); + if (checked && type == PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM) { + DvrUiHelper.startPlaybackActivity(getContext(), recordedProgram.getId(), + 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); + } + }); + PinDialogFragment.create(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM) + .show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG); } - intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, pinChecked); - getActivity().startActivity(intent); } private static class MyImageLoaderCallback extends diff --git a/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java b/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java new file mode 100644 index 00000000..df0e61c1 --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/DvrItemPresenter.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tv.dvr.ui.browse; + +import android.app.Activity; +import android.content.Context; +import android.support.annotation.CallSuper; +import android.support.v17.leanback.widget.Presenter; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; + +import com.android.tv.common.SoftPreconditions; +import com.android.tv.dvr.ui.DvrUiHelper; + +import java.util.HashSet; +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 com.android.tv.dvr.data.ScheduledRecording}, + * {@link com.android.tv.dvr.data.RecordedProgram}, and + * {@link com.android.tv.dvr.data.SeriesRecording}. + */ +public abstract class DvrItemPresenter<T> extends Presenter { + protected final Context mContext; + private final Set<DvrItemViewHolder> mBoundViewHolders = new HashSet<>(); + private final OnClickListener mOnClickListener = onCreateOnClickListener(); + + protected class DvrItemViewHolder extends ViewHolder { + DvrItemViewHolder(RecordingCardView view) { + super(view); + } + + protected RecordingCardView getView() { + return (RecordingCardView) view; + } + + protected void onBound(T item) { } + + protected void onUnbound() { } + } + + DvrItemPresenter(Context context) { + mContext = context; + } + + @Override + public final ViewHolder onCreateViewHolder(ViewGroup parent) { + return onCreateDvrItemViewHolder(); + } + + @Override + public final void onBindViewHolder(ViewHolder baseHolder, Object item) { + DvrItemViewHolder viewHolder; + T dvrItem; + try { + viewHolder = (DvrItemViewHolder) baseHolder; + Class<T> itemType = (Class<T>) item.getClass(); + dvrItem = itemType.cast(item); + } catch (ClassCastException e) { + SoftPreconditions.checkState(false); + return; + } + viewHolder.view.setTag(item); + viewHolder.view.setOnClickListener(mOnClickListener); + onBindDvrItemViewHolder(viewHolder, dvrItem); + viewHolder.onBound(dvrItem); + mBoundViewHolders.add(viewHolder); + } + + @Override + @CallSuper + public void onUnbindViewHolder(ViewHolder baseHolder) { + DvrItemViewHolder viewHolder = (DvrItemViewHolder) baseHolder; + mBoundViewHolders.remove(viewHolder); + viewHolder.onUnbound(); + viewHolder.view.setTag(null); + viewHolder.view.setOnClickListener(null); + } + + /** + * 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); + } + } + + /** + * This method will be called when a {@link DvrItemViewHolder} is needed to be created. + */ + abstract protected DvrItemViewHolder onCreateDvrItemViewHolder(); + + /** + * This method will be called when a {@link DvrItemViewHolder} is bound to a DVR item. + */ + abstract protected void onBindDvrItemViewHolder(DvrItemViewHolder viewHolder, T item); + + /** + * Returns context. + */ + protected Context getContext() { + return mContext; + } + + /** + * 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/browse/DvrListRowPresenter.java b/src/com/android/tv/dvr/ui/browse/DvrListRowPresenter.java new file mode 100644 index 00000000..37a72eaf --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/DvrListRowPresenter.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2017 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.browse; + +import android.content.Context; +import android.support.v17.leanback.widget.ListRowPresenter; +import android.view.ViewGroup; + +import com.android.tv.R; + +/** A list row presenter to display expand/fold card views list. */ +public class DvrListRowPresenter extends ListRowPresenter { + public DvrListRowPresenter(Context context) { + super(); + setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + setExpandedRowHeight( + context.getResources() + .getDimensionPixelSize(R.dimen.dvr_library_expanded_row_height)); + } +} diff --git a/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java b/src/com/android/tv/dvr/ui/browse/FullScheduleCardHolder.java index d4d4d8ab..311137a9 100644 --- a/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java +++ b/src/com/android/tv/dvr/ui/browse/FullScheduleCardHolder.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; /** * Special object for schedule preview; diff --git a/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java b/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java index 7dd85f45..94c67eec 100644 --- a/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/FullSchedulesCardPresenter.java @@ -14,17 +14,17 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.content.Context; -import android.support.v17.leanback.widget.Presenter; +import android.graphics.drawable.Drawable; 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.dvr.data.ScheduledRecording; +import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.util.Utils; import java.util.Collections; @@ -33,23 +33,28 @@ import java.util.List; /** * Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}. */ -public class FullSchedulesCardPresenter extends Presenter { +class FullSchedulesCardPresenter extends DvrItemPresenter<Object> { + private final Drawable mIconDrawable; + private final String mCardTitleText; + + FullSchedulesCardPresenter(Context context) { + super(context); + mIconDrawable = mContext.getDrawable(R.drawable.dvr_full_schedule); + mCardTitleText = mContext.getString(R.string.dvr_full_schedule_card_view_title); + } + @Override - public ViewHolder onCreateViewHolder(ViewGroup parent) { - Context context = parent.getContext(); - RecordingCardView view = new RecordingCardView(context); - return new ScheduledRecordingViewHolder(view); + public DvrItemViewHolder onCreateDvrItemViewHolder() { + return new DvrItemViewHolder(new RecordingCardView(mContext)); } @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(); + public void onBindDvrItemViewHolder(DvrItemViewHolder vh, Object o) { + final RecordingCardView cardView = (RecordingCardView) vh.view; - 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) + cardView.setTitle(mCardTitleText); + cardView.setImage(mIconDrawable); + List<ScheduledRecording> scheduledRecordings = TvApplication.getSingletons(mContext) .getDvrDataManager().getAvailableScheduledRecordings(); int fullDays = 0; if (!scheduledRecordings.isEmpty()) { @@ -57,28 +62,23 @@ public class FullSchedulesCardPresenter extends Presenter { Collections.max(scheduledRecordings, ScheduledRecording.START_TIME_COMPARATOR) .getStartTimeMs()) + 1; } - cardView.setContent(context.getResources().getQuantityString( + cardView.setContent(mContext.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(); + public void onUnbindViewHolder(ViewHolder vh) { + ((RecordingCardView) vh.view).reset(); + super.onUnbindViewHolder(vh); } - private static final class ScheduledRecordingViewHolder extends ViewHolder { - ScheduledRecordingViewHolder(RecordingCardView view) { - super(view); - } + @Override + protected View.OnClickListener onCreateOnClickListener() { + return new View.OnClickListener() { + @Override + public void onClick(View view) { + DvrUiHelper.startSchedulesActivity(mContext, null); + } + }; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java index e698b8a2..eb9cb26c 100644 --- a/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/RecordedProgramDetailsFragment.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.content.res.Resources; import android.media.tv.TvInputManager; @@ -22,18 +22,16 @@ 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; +import com.android.tv.dvr.data.RecordedProgram; /** - * {@link DetailsFragment} for recorded program in DVR. + * {@link android.support.v17.leanback.app.DetailsFragment} for recorded program in DVR. */ public class RecordedProgramDetailsFragment extends DvrDetailsFragment implements DvrDataManager.RecordedProgramListener { @@ -44,7 +42,6 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment private DvrWatchedPositionManager mDvrWatchedPositionManager; private RecordedProgram mRecordedProgram; - private DetailsContent mDetailsContent; private boolean mPaused; private DvrDataManager mDvrDataManager; @@ -59,7 +56,8 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment public void onCreateInternal() { mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity()) .getDvrWatchedPositionManager(); - setDetailsOverviewRow(mDetailsContent); + setDetailsOverviewRow(DetailsContent + .createFromRecordedProgram(getContext(), mRecordedProgram)); } @Override @@ -87,26 +85,7 @@ public class RecordedProgramDetailsFragment extends DvrDetailsFragment 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(); + return mRecordedProgram != null; } @Override diff --git a/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java new file mode 100644 index 00000000..5fe162b6 --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/RecordedProgramPresenter.java @@ -0,0 +1,142 @@ +/* + * 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.browse; + +import android.content.Context; +import android.media.tv.TvInputManager; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.util.Utils; + +/** + * Presents a {@link RecordedProgram} in the {@link DvrBrowseFragment}. + */ +public class RecordedProgramPresenter extends DvrItemPresenter<RecordedProgram> { + private final DvrWatchedPositionManager mDvrWatchedPositionManager; + private String mTodayString; + private String mYesterdayString; + private final int mProgressBarColor; + private final boolean mShowEpisodeTitle; + private final boolean mExpandTitleWhenFocused; + + protected final class RecordedProgramViewHolder extends DvrItemViewHolder + implements WatchedPositionChangedListener { + private RecordedProgram mProgram; + private boolean mShowProgress; + + public RecordedProgramViewHolder(RecordingCardView view, Integer progressColor) { + super(view); + if (progressColor == null) { + mShowProgress = false; + } else { + mShowProgress = true; + view.setProgressBarColor(progressColor); + } + } + + 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); + } + } + + @Override + protected void onBound(RecordedProgram program) { + mProgram = program; + if (mShowProgress) { + mDvrWatchedPositionManager.addListener(this, program.getId()); + setProgressBar(mDvrWatchedPositionManager.getWatchedPosition(program.getId())); + } else { + getView().setProgressBar(null); + } + } + + @Override + protected void onUnbound() { + if (mShowProgress) { + mDvrWatchedPositionManager.removeListener(this, mProgram.getId()); + } + getView().reset(); + } + } + + RecordedProgramPresenter(Context context, boolean showEpisodeTitle, + boolean expandTitleWhenFocused) { + super(context); + mTodayString = mContext.getString(R.string.dvr_date_today); + mYesterdayString = mContext.getString(R.string.dvr_date_yesterday); + mDvrWatchedPositionManager = + TvApplication.getSingletons(mContext).getDvrWatchedPositionManager(); + mProgressBarColor = mContext.getResources() + .getColor(R.color.play_controls_progress_bar_watched); + mShowEpisodeTitle = showEpisodeTitle; + mExpandTitleWhenFocused = expandTitleWhenFocused; + } + + public RecordedProgramPresenter(Context context) { + this(context, false, false); + } + + @Override + public DvrItemViewHolder onCreateDvrItemViewHolder() { + return new RecordedProgramViewHolder( + new RecordingCardView(mContext, mExpandTitleWhenFocused), mProgressBarColor); + } + + @Override + public void onBindDvrItemViewHolder(DvrItemViewHolder baseHolder, RecordedProgram program) { + final RecordedProgramViewHolder viewHolder = (RecordedProgramViewHolder) baseHolder; + final RecordingCardView cardView = viewHolder.getView(); + DetailsContent details = DetailsContent.createFromRecordedProgram(mContext, program); + cardView.setTitle(mShowEpisodeTitle ? + program.getEpisodeDisplayTitle(mContext) : details.getTitle()); + cardView.setImageUri(details.getLogoImageUri(), details.isUsingChannelLogo()); + cardView.setContent(generateMajorContent(program), generateMinorContent(program)); + cardView.setDetailBackgroundImageUri(details.getBackgroundImageUri()); + } + + private String generateMajorContent(RecordedProgram program) { + int dateDifference = Utils.computeDateDifference(program.getStartTimeUtcMillis(), + System.currentTimeMillis()); + if (dateDifference == 0) { + return mTodayString; + } else if (dateDifference == 1) { + return mYesterdayString; + } else { + return Utils.getDurationString(mContext, program.getStartTimeUtcMillis(), + program.getStartTimeUtcMillis(), false, true, false, 0); + } + } + + private String generateMinorContent(RecordedProgram program) { + int durationMinutes = Math.max(1, Utils.getRoundOffMinsFromMs(program.getDurationMillis())); + return mContext.getResources().getQuantityString( + R.plurals.dvr_program_duration, durationMinutes, durationMinutes); + } +} diff --git a/src/com/android/tv/dvr/ui/RecordingCardView.java b/src/com/android/tv/dvr/ui/browse/RecordingCardView.java index 51c3b03b..767addc8 100644 --- a/src/com/android/tv/dvr/ui/RecordingCardView.java +++ b/src/com/android/tv/dvr/ui/browse/RecordingCardView.java @@ -14,51 +14,70 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; +import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; -import android.net.Uri; 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.FrameLayout; 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.dvr.data.RecordedProgram; +import com.android.tv.ui.ViewUtils; import com.android.tv.util.ImageLoader; /** - * A CardView for displaying info about a {@link com.android.tv.dvr.ScheduledRecording} or - * {@link RecordedProgram} or - * {@link com.android.tv.dvr.SeriesRecording}. + * A CardView for displaying info about a {@link com.android.tv.dvr.data.ScheduledRecording} + * or {@link RecordedProgram} or {@link com.android.tv.dvr.data.SeriesRecording}. */ -class RecordingCardView extends BaseCardView { +public class RecordingCardView extends BaseCardView { + // This value should be the same with + // android.support.v17.leanback.widget.FocusHighlightHelper.BrowseItemFocusHighlight.DURATION_MS + private final static int ANIMATION_DURATION = 150; private final ImageView mImageView; private final int mImageWidth; private final int mImageHeight; private String mImageUri; - private final TextView mTitleView; private final TextView mMajorContentView; private final TextView mMinorContentView; private final ProgressBar mProgressBar; private final View mAffiliatedIconContainer; private final ImageView mAffiliatedIcon; private final Drawable mDefaultImage; + private final FrameLayout mTitleArea; + private final TextView mFoldedTitleView; + private final TextView mExpandedTitleView; + private final ValueAnimator mExpandTitleAnimator; + private final int mFoldedTitleHeight; + private final int mExpandedTitleHeight; + private final boolean mExpandTitleWhenFocused; + private boolean mExpanded; + private String mDetailBackgroundImageUri; - 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)); + public RecordingCardView(Context context) { + this(context, false); } - RecordingCardView(Context context, int imageWidth, int imageHeight) { + public RecordingCardView(Context context, boolean expandTitleWhenFocused) { + this(context, context.getResources().getDimensionPixelSize( + R.dimen.dvr_library_card_image_layout_width), context.getResources() + .getDimensionPixelSize(R.dimen.dvr_library_card_image_layout_height), + expandTitleWhenFocused); + } + + public RecordingCardView(Context context, int imageWidth, int imageHeight, + boolean expandTitleWhenFocused) { super(context); //TODO(dvr): move these to the layout XML. setCardType(BaseCardView.CARD_TYPE_INFO_UNDER_WITH_EXTRA); @@ -75,13 +94,81 @@ class RecordingCardView extends BaseCardView { 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); mMajorContentView = (TextView) findViewById(R.id.content_major); mMinorContentView = (TextView) findViewById(R.id.content_minor); + mTitleArea = (FrameLayout) findViewById(R.id.title_area); + mFoldedTitleView = (TextView) findViewById(R.id.title_one_line); + mExpandedTitleView = (TextView) findViewById(R.id.title_two_lines); + mFoldedTitleHeight = getResources() + .getDimensionPixelSize(R.dimen.dvr_library_card_folded_title_height); + mExpandedTitleHeight = getResources() + .getDimensionPixelSize(R.dimen.dvr_library_card_expanded_title_height); + mExpandTitleAnimator = ValueAnimator.ofFloat(0.0f, 1.0f).setDuration(ANIMATION_DURATION); + mExpandTitleAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + float value = (Float) valueAnimator.getAnimatedValue(); + mExpandedTitleView.setAlpha(value); + mFoldedTitleView.setAlpha(1.0f - value); + ViewUtils.setLayoutHeight(mTitleArea, (int) (mFoldedTitleHeight + + (mExpandedTitleHeight - mFoldedTitleHeight) * value)); + } + }); + mExpandTitleWhenFocused = expandTitleWhenFocused; + } + + @Override + protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + // Preload the background image going to be used in detail fragments here to prevent + // loading and drawing background images during activity transitions. + if (gainFocus) { + if (!TextUtils.isEmpty(mDetailBackgroundImageUri)) { + ImageLoader.loadBitmap(getContext(), mDetailBackgroundImageUri, + Integer.MAX_VALUE, Integer.MAX_VALUE, null); + } + } + if (mExpandTitleWhenFocused) { + if (gainFocus) { + expandTitle(true, true); + } else { + expandTitle(false, true); + } + } + } + + /** + * Expands/folds the title area to show program title with two/one lines. + * + * @param expand {@code true} to expand the title area, or {@code false} to fold it. + * @param withAnimation {@code true} to expand/fold with animation. + */ + public void expandTitle(boolean expand, boolean withAnimation) { + if (expand != mExpanded && mFoldedTitleView.getLayout().getEllipsisCount(0) > 0) { + if (withAnimation) { + if (expand) { + mExpandTitleAnimator.start(); + } else { + mExpandTitleAnimator.reverse(); + } + } else { + if (expand) { + mFoldedTitleView.setAlpha(0.0f); + mExpandedTitleView.setAlpha(1.0f); + ViewUtils.setLayoutHeight(mTitleArea, mExpandedTitleHeight); + } else { + mFoldedTitleView.setAlpha(1.0f); + mExpandedTitleView.setAlpha(0.0f); + ViewUtils.setLayoutHeight(mTitleArea, mFoldedTitleHeight); + } + } + mExpanded = expand; + } } void setTitle(CharSequence title) { - mTitleView.setText(title); + mFoldedTitleView.setText(title); + mExpandedTitleView.setText(title); } void setContent(CharSequence majorContent, CharSequence minorContent) { @@ -118,6 +205,11 @@ class RecordingCardView extends BaseCardView { mProgressBar.getProgressDrawable().setTint(color); } + /** + * Sets the image URI of the poster should be shown on the card view. + + * @param isChannelLogo {@code true} if the image is from channels' logo. + */ void setImageUri(String uri, boolean isChannelLogo) { if (isChannelLogo) { mImageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); @@ -134,7 +226,7 @@ class RecordingCardView extends BaseCardView { } /** - * Set image to card view. + * Sets the {@link Drawable} of the poster should be shown on the card view. */ public void setImage(Drawable image) { if (image != null) { @@ -142,6 +234,10 @@ class RecordingCardView extends BaseCardView { } } + /** + * Sets the affiliated icon of the card view, which will be displayed at the lower-right corner + * of the poster. + */ public void setAffiliatedIcon(int imageResId) { if (imageResId > 0) { mAffiliatedIconContainer.setVisibility(View.VISIBLE); @@ -152,6 +248,14 @@ class RecordingCardView extends BaseCardView { } /** + * Sets the background image URI of the card view, which will be displayed as background when + * the view is clicked and shows its details fragment. + */ + public void setDetailBackgroundImageUri(String uri) { + mDetailBackgroundImageUri = uri; + } + + /** * Returns image view. */ public ImageView getImageView() { @@ -178,8 +282,9 @@ class RecordingCardView extends BaseCardView { } public void reset() { - mTitleView.setText(null); + mFoldedTitleView.setText(null); + mExpandedTitleView.setText(null); setContent(null, null); mImageView.setImageDrawable(mDefaultImage); } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java new file mode 100644 index 00000000..56ec357f --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/RecordingDetailsFragment.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.tv.dvr.ui.browse; + +import android.os.Bundle; +import android.support.v17.leanback.app.DetailsFragment; + +import com.android.tv.TvApplication; +import com.android.tv.dvr.data.ScheduledRecording; + +/** + * {@link DetailsFragment} for recordings in DVR. + */ +abstract class RecordingDetailsFragment extends DvrDetailsFragment { + private ScheduledRecording mRecording; + + @Override + protected void onCreateInternal() { + setDetailsOverviewRow(DetailsContent + .createFromScheduledRecording(getContext(), mRecording)); + } + + @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; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java index 60816bb5..958f8bf8 100644 --- a/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingDetailsFragment.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.content.res.Resources; import android.os.Bundle; @@ -26,7 +26,7 @@ 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; +import com.android.tv.dvr.ui.DvrUiHelper; /** * {@link RecordingDetailsFragment} for scheduled recording in DVR. @@ -66,7 +66,7 @@ public class ScheduledRecordingDetailsFragment extends RecordingDetailsFragment 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.getString(R.string.dvr_detail_cancel_recording), null, res.getDrawable(R.drawable.ic_dvr_cancel_32dp))); return adapter; } diff --git a/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.java new file mode 100644 index 00000000..273d3d19 --- /dev/null +++ b/src/com/android/tv/dvr/ui/browse/ScheduledRecordingPresenter.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.browse; + +import android.content.Context; +import android.os.Handler; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.dvr.DvrManager; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.util.Utils; + +import java.util.concurrent.TimeUnit; + +/** + * Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}. + */ +class ScheduledRecordingPresenter extends DvrItemPresenter<ScheduledRecording> { + private static final long PROGRESS_UPDATE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(5); + + private final DvrManager mDvrManager; + private final int mProgressBarColor; + + private final class ScheduledRecordingViewHolder extends DvrItemViewHolder { + 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, int progressBarColor) { + super(view); + view.setProgressBarColor(progressBarColor); + } + + @Override + protected void onBound(ScheduledRecording recording) { + mScheduledRecording = recording; + updateProgressBar(); + startUpdateProgressBar(); + } + + @Override + protected void onUnbound() { + stopUpdateProgressBar(); + mScheduledRecording = null; + getView().reset(); + } + + 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) { + super(context); + mDvrManager = TvApplication.getSingletons(mContext).getDvrManager(); + mProgressBarColor = mContext.getResources() + .getColor(R.color.play_controls_recording_icon_color_on_focus); + } + + @Override + public DvrItemViewHolder onCreateDvrItemViewHolder() { + return new ScheduledRecordingViewHolder(new RecordingCardView(mContext), mProgressBarColor); + } + + @Override + public void onBindDvrItemViewHolder(DvrItemViewHolder baseHolder, + ScheduledRecording recording) { + final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder; + final RecordingCardView cardView = viewHolder.getView(); + DetailsContent details = DetailsContent.createFromScheduledRecording(mContext, recording); + cardView.setTitle(details.getTitle()); + cardView.setImageUri(details.getLogoImageUri(), details.isUsingChannelLogo()); + cardView.setAffiliatedIcon(mDvrManager.isConflicting(recording) ? + R.drawable.ic_warning_white_32dp : 0); + cardView.setContent(generateMajorContent(recording), null); + cardView.setDetailBackgroundImageUri(details.getBackgroundImageUri()); + } + + private String generateMajorContent(ScheduledRecording recording) { + int dateDifference = Utils.computeDateDifference(System.currentTimeMillis(), + recording.getStartTimeMs()); + if (dateDifference <= 0) { + return mContext.getString(R.string.dvr_date_today_time, + Utils.getDurationString(mContext, recording.getStartTimeMs(), + recording.getEndTimeMs(), false, false, true, 0)); + } else if (dateDifference == 1) { + return mContext.getString(R.string.dvr_date_tomorrow_time, + Utils.getDurationString(mContext, recording.getStartTimeMs(), + recording.getEndTimeMs(), false, false, true, 0)); + } else { + return Utils.getDurationString(mContext, recording.getStartTimeMs(), + recording.getStartTimeMs(), false, true, false, 0); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java index e9e391d4..c2aa8e98 100644 --- a/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java +++ b/src/com/android/tv/dvr/ui/browse/SeriesRecordingDetailsFragment.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; import android.content.res.Resources; import android.graphics.drawable.Drawable; @@ -28,7 +28,6 @@ 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; @@ -37,14 +36,12 @@ 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 com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.ui.DvrUiHelper; +import com.android.tv.dvr.ui.SortedArrayAdapter; import java.util.Collections; import java.util.Comparator; @@ -67,7 +64,6 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement // 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; @@ -85,7 +81,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement 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); + mRecordedProgramPresenter = new RecordedProgramPresenter(getContext(), true, true); super.onCreate(savedInstanceState); } @@ -93,7 +89,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement protected void onCreateInternal() { mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity()) .getDvrWatchedPositionManager(); - setDetailsOverviewRow(mDetailsContent); + setDetailsOverviewRow(DetailsContent.createFromSeriesRecording(getContext(), mSeries)); setupRecordedProgramsRow(); mDvrDataManager.addSeriesRecordingListener(this); mDvrDataManager.addRecordedProgramListener(this); @@ -149,7 +145,6 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement } mRecordedPrograms = mDvrDataManager.getRecordedPrograms(mSeries.getId()); Collections.sort(mRecordedPrograms, RecordedProgram.SEASON_REVERSED_EPISODE_COMPARATOR); - mDetailsContent = createDetailsContent(); return true; } @@ -158,22 +153,10 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement DetailsOverviewRowPresenter rowPresenter) { ClassPresenterSelector presenterSelector = new ClassPresenterSelector(); presenterSelector.addClassPresenter(DetailsOverviewRow.class, rowPresenter); - presenterSelector.addClassPresenter(ListRow.class, new ListRowPresenter()); + presenterSelector.addClassPresenter(ListRow.class, new DvrListRowPresenter(getContext())); 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()); @@ -203,10 +186,7 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement mDvrDataManager.removeSeriesRecordingListener(this); mDvrDataManager.removeRecordedProgramListener(this); if (mSeries != null) { - DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager(); - if (dvrManager.canRemoveSeriesRecording(mSeries.getId())) { - dvrManager.removeSeriesRecording(mSeries.getId()); - } + mDvrDataManager.checkAndRemoveEmptySeriesRecording(mSeries.getId()); } mRecordedProgramPresenter.unbindAllViewHolders(); } @@ -265,7 +245,6 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { for (SeriesRecording series : seriesRecordings) { if (series.getId() == mSeries.getId()) { - mSeries = null; getActivity().finish(); return; } @@ -372,4 +351,4 @@ public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implement return program.getId(); } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java b/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java index c2c0f596..e508259d 100644 --- a/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java +++ b/src/com/android/tv/dvr/ui/browse/SeriesRecordingPresenter.java @@ -14,42 +14,36 @@ * limitations under the License. */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.browse; -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 com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import java.util.List; /** * Presents a {@link SeriesRecording} in {@link DvrBrowseFragment}. */ -public class SeriesRecordingPresenter extends DvrItemPresenter { - private final ChannelDataManager mChannelDataManager; +class SeriesRecordingPresenter extends DvrItemPresenter<SeriesRecording> { private final DvrDataManager mDvrDataManager; private final DvrManager mDvrManager; private final DvrWatchedPositionManager mWatchedPositionManager; - private static final class SeriesRecordingViewHolder extends ViewHolder implements + private final class SeriesRecordingViewHolder extends DvrItemViewHolder implements WatchedPositionChangedListener, ScheduledRecordingListener, RecordedProgramListener { private SeriesRecording mSeriesRecording; private RecordingCardView mCardView; @@ -138,7 +132,8 @@ public class SeriesRecordingPresenter extends DvrItemPresenter { // Do nothing } - public void onBound(SeriesRecording seriesRecording) { + @Override + protected void onBound(SeriesRecording seriesRecording) { mSeriesRecording = seriesRecording; mDvrDataManager.addScheduledRecordingListener(this); mDvrDataManager.addRecordedProgramListener(this); @@ -152,10 +147,12 @@ public class SeriesRecordingPresenter extends DvrItemPresenter { updateCardViewContent(); } - public void onUnbound() { + @Override + protected void onUnbound() { mDvrDataManager.removeScheduledRecordingListener(this); mDvrDataManager.removeRecordedProgramListener(this); mWatchedPositionManager.removeListener(this); + getView().reset(); } private void updateCardViewContent() { @@ -186,29 +183,28 @@ public class SeriesRecordingPresenter extends DvrItemPresenter { } public SeriesRecordingPresenter(Context context) { + super(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); + public DvrItemViewHolder onCreateDvrItemViewHolder() { + return new SeriesRecordingViewHolder(new RecordingCardView(mContext), mDvrDataManager, + mDvrManager, mWatchedPositionManager); } @Override - public void onBindViewHolder(ViewHolder baseHolder, Object o) { + public void onBindDvrItemViewHolder(DvrItemViewHolder baseHolder, SeriesRecording series) { 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); + final RecordingCardView cardView = viewHolder.getView(); + viewHolder.onBound(series); + DetailsContent details = DetailsContent.createFromSeriesRecording(mContext, series); + cardView.setTitle(details.getTitle()); + cardView.setImageUri(details.getLogoImageUri(), details.isUsingChannelLogo()); + cardView.setDetailBackgroundImageUri(details.getBackgroundImageUri()); } @Override @@ -217,18 +213,4 @@ public class SeriesRecordingPresenter extends DvrItemPresenter { ((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/list/BaseDvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java index d28f026c..b9407b15 100644 --- a/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java +++ b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java @@ -29,7 +29,7 @@ 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.data.ScheduledRecording; /** * A base fragment to show the list of schedule recordings. @@ -40,7 +40,8 @@ public abstract class BaseDvrSchedulesFragment extends DetailsFragment /** * The key for scheduled recording which has be selected in the list. */ - public static String SCHEDULES_KEY_SCHEDULED_RECORDING = "schedules_key_scheduled_recording"; + public static final String SCHEDULES_KEY_SCHEDULED_RECORDING = + "schedules_key_scheduled_recording"; private ScheduleRowAdapter mRowsAdapter; private TextView mEmptyInfoScreenView; diff --git a/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java index f6e6ac26..a0410bb3 100644 --- a/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesActivity.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.list; import android.app.Activity; import android.app.ProgressDialog; @@ -24,15 +24,13 @@ 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 com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.provider.EpisodicProgramLoadTask; +import com.android.tv.dvr.recorder.SeriesRecordingScheduler; +import com.android.tv.dvr.ui.BigArguments; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -72,33 +70,47 @@ public class DvrSchedulesActivity extends Activity { 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(); + if (BigArguments.getArgument(DvrSeriesSchedulesFragment + .SERIES_SCHEDULES_KEY_SERIES_PROGRAMS) != null) { + // The programs will be passed to the DvrSeriesSchedulesFragment, so don't need + // to reset the BigArguments. + showDvrSeriesSchedulesFragment(getIntent().getExtras()); + } else { + final ProgressDialog dialog = ProgressDialog.show(this, null, getString( + R.string.dvr_series_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(); + BigArguments.reset(); + BigArguments.setArgument( + DvrSeriesSchedulesFragment.SERIES_SCHEDULES_KEY_SERIES_PROGRAMS, + programs == null ? Collections.EMPTY_LIST : programs); + showDvrSeriesSchedulesFragment(args); + } + }.setLoadCurrentProgram(true) + .setLoadDisallowedProgram(true) + .setLoadScheduledEpisode(true) + .setIgnoreChannelOption(true) + .execute(); + } } else { finish(); } } + + private void showDvrSeriesSchedulesFragment(Bundle args) { + DvrSeriesSchedulesFragment schedulesFragment = new DvrSeriesSchedulesFragment(); + schedulesFragment.setArguments(args); + getFragmentManager().beginTransaction().add( + R.id.fragment_container, schedulesFragment).commit(); + } } diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java index 722c9b6e..3cbb500a 100644 --- a/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java +++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java @@ -18,12 +18,9 @@ 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.data.ScheduledRecording; import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.DateHeaderRowPresenter; /** diff --git a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java index 42a1e72b..57e7a88f 100644 --- a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java +++ b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java @@ -17,6 +17,7 @@ package com.android.tv.dvr.ui.list; import android.annotation.TargetApi; +import android.content.Context; import android.database.ContentObserver; import android.media.tv.TvContract.Programs; import android.net.Uri; @@ -35,11 +36,13 @@ 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; 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 com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.provider.EpisodicProgramLoadTask; +import com.android.tv.dvr.ui.BigArguments; +import java.util.Collections; import java.util.List; /** @@ -47,20 +50,22 @@ import java.util.List; */ @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. + * Type: {@link SeriesRecording} */ 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. + * The key for programs which belong to the series recording whose scheduled recording list + * will be displayed. + * Type: List<{@link Program}> */ public static final String SERIES_SCHEDULES_KEY_SERIES_PROGRAMS = "series_schedules_key_series_programs"; private ChannelDataManager mChannelDataManager; + private DvrDataManager mDvrDataManager; private SeriesRecording mSeriesRecording; private List<Program> mPrograms; private EpisodicProgramLoadTask mProgramLoadTask; @@ -87,20 +92,22 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { && getRowsAdapter() instanceof SeriesScheduleRowAdapter) { ((SeriesScheduleRowAdapter) getRowsAdapter()) .onSeriesRecordingUpdated(r); + mSeriesRecording = r; + updateEmptyMessage(); 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 Handler mHandler = new Handler(Looper.getMainLooper()); + private final ContentObserver mContentObserver = new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange, Uri uri) { + super.onChange(selfChange, uri); + executeProgramLoadingTask(); + } + }; private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() { @Override @@ -120,17 +127,28 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { } @Override - public void onCreate(Bundle savedInstanceState) { + public void onAttach(Context context) { + super.onAttach(context); Bundle args = getArguments(); if (args != null) { mSeriesRecording = args.getParcelable(SERIES_SCHEDULES_KEY_SERIES_RECORDING); - mPrograms = args.getParcelableArrayList(SERIES_SCHEDULES_KEY_SERIES_PROGRAMS); + mPrograms = (List<Program>) BigArguments.getArgument( + SERIES_SCHEDULES_KEY_SERIES_PROGRAMS); + BigArguments.reset(); } + if (args == null || mPrograms == null) { + getActivity().finish(); + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ApplicationSingletons singletons = TvApplication.getSingletons(getContext()); - singletons.getDvrDataManager().addSeriesRecordingListener(mSeriesRecordingListener); mChannelDataManager = singletons.getChannelDataManager(); mChannelDataManager.addListener(mChannelListener); + mDvrDataManager = singletons.getDvrDataManager(); + mDvrDataManager.addSeriesRecordingListener(mSeriesRecordingListener); getContext().getContentResolver().registerContentObserver(Programs.CONTENT_URI, true, mContentObserver); } @@ -144,8 +162,16 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { private void onProgramsUpdated() { ((SeriesScheduleRowAdapter) getRowsAdapter()).setPrograms(mPrograms); + updateEmptyMessage(); + } + + private void updateEmptyMessage() { if (mPrograms == null || mPrograms.isEmpty()) { - showEmptyMessage(R.string.dvr_series_schedules_empty_state); + if (mSeriesRecording.getState() == SeriesRecording.STATE_SERIES_STOPPED) { + showEmptyMessage(R.string.dvr_series_schedules_stopped_empty_state); + } else { + showEmptyMessage(R.string.dvr_series_schedules_empty_state); + } } else { hideEmptyMessage(); } @@ -158,15 +184,15 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { mProgramLoadTask = null; } getContext().getContentResolver().unregisterContentObserver(mContentObserver); + mHandler.removeCallbacksAndMessages(null); mChannelDataManager.removeListener(mChannelListener); - TvApplication.getSingletons(getContext()).getDvrDataManager() - .removeSeriesRecordingListener(mSeriesRecordingListener); + mDvrDataManager.removeSeriesRecordingListener(mSeriesRecordingListener); super.onDestroy(); } @Override public SchedulesHeaderRowPresenter onCreateHeaderRowPresenter() { - return new SeriesRecordingHeaderRowPresenter(getContext()); + return new SchedulesHeaderRowPresenter.SeriesRecordingHeaderRowPresenter(getContext()); } @Override @@ -195,7 +221,7 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { mProgramLoadTask = new EpisodicProgramLoadTask(getContext(), mSeriesRecording) { @Override protected void onPostExecute(List<Program> programs) { - mPrograms = programs; + mPrograms = programs == null ? Collections.EMPTY_LIST : programs; onProgramsUpdated(); } }; @@ -205,4 +231,4 @@ public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment { .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 index 23aebf59..2af832ec 100644 --- a/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java +++ b/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java @@ -19,13 +19,14 @@ 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; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording.Builder; +import com.android.tv.dvr.ui.DvrUiHelper; /** * A class for the episodic program. */ -public class EpisodicProgramRow extends ScheduleRow { +class EpisodicProgramRow extends ScheduleRow { private final String mInputId; private final Program mProgram; @@ -65,7 +66,7 @@ public class EpisodicProgramRow extends ScheduleRow { @Override public String getProgramTitleWithEpisodeNumber(Context context) { - return mProgram.getTitleWithEpisodeNumber(context); + return DvrUiHelper.getStyledTitleWithEpisodeNumber(context, mProgram, 0).toString(); } @Override diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRow.java b/src/com/android/tv/dvr/ui/list/ScheduleRow.java index 3fc92e8a..91ba393a 100644 --- a/src/com/android/tv/dvr/ui/list/ScheduleRow.java +++ b/src/com/android/tv/dvr/ui/list/ScheduleRow.java @@ -20,12 +20,13 @@ import android.content.Context; import android.support.annotation.Nullable; import com.android.tv.common.SoftPreconditions; -import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.ui.DvrUiHelper; /** * A class for schedule recording row. */ -public class ScheduleRow { +class ScheduleRow { private final SchedulesHeaderRow mHeaderRow; @Nullable private ScheduledRecording mSchedule; private boolean mStopRecordingRequested; @@ -166,7 +167,8 @@ public class ScheduleRow { * Returns the program title with episode number. */ public String getProgramTitleWithEpisodeNumber(Context context) { - return mSchedule != null ? mSchedule.getProgramTitleWithEpisodeNumber(context) : null; + return mSchedule != null ? DvrUiHelper.getStyledTitleWithEpisodeNumber(context, + mSchedule, 0).toString() : null; } /** diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java index 9cc82653..97d60473 100644 --- a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java @@ -30,8 +30,8 @@ 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.dvr.data.ScheduledRecording; import com.android.tv.util.Utils; import java.util.ArrayList; @@ -43,7 +43,7 @@ import java.util.concurrent.TimeUnit; /** * An adapter for {@link ScheduleRow}. */ -public class ScheduleRowAdapter extends ArrayObjectAdapter { +class ScheduleRowAdapter extends ArrayObjectAdapter { private static final String TAG = "ScheduleRowAdapter"; private static final boolean DEBUG = false; diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java index 1257e725..dc4e3c41 100644 --- a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java +++ b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java @@ -42,25 +42,24 @@ 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.dialog.HalfSizedDialogFragment; 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.data.ScheduledRecording; import com.android.tv.dvr.ui.DvrStopRecordingFragment; -import com.android.tv.dvr.ui.HalfSizedDialogFragment; +import com.android.tv.dvr.ui.DvrUiHelper; 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 { +class ScheduleRowPresenter extends RowPresenter { private static final String TAG = "ScheduleRowPresenter"; @Retention(RetentionPolicy.SOURCE) @@ -345,7 +344,9 @@ public class ScheduleRowPresenter extends RowPresenter { viewHolder.mInfoContainer.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - onInfoClicked(row); + if (isInfoClickable(row)) { + onInfoClicked(row); + } } }); @@ -366,8 +367,7 @@ public class ScheduleRowPresenter extends RowPresenter { viewHolder.mTimeView.setText(onGetRecordingTimeText(row)); String programInfoText = onGetProgramInfoText(row); if (TextUtils.isEmpty(programInfoText)) { - int durationMins = - Math.max((int) TimeUnit.MILLISECONDS.toMinutes(row.getDuration()), 1); + int durationMins = Math.max(1, Utils.getRoundOffMinsFromMs(row.getDuration())); programInfoText = mContext.getResources().getQuantityString( R.plurals.dvr_schedules_recording_duration, durationMins, durationMins); } @@ -403,6 +403,7 @@ public class ScheduleRowPresenter extends RowPresenter { } else { viewHolder.whiteBackInfo(); } + viewHolder.mInfoContainer.setFocusable(isInfoClickable(row)); updateActionContainer(viewHolder, viewHolder.isSelected()); } @@ -454,11 +455,13 @@ public class ScheduleRowPresenter extends RowPresenter { /** * 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); - } + protected void onInfoClicked(ScheduleRow row) { + DvrUiHelper.startDetailsActivity((Activity) mContext, row.getSchedule(), null, true); + } + + private boolean isInfoClickable(ScheduleRow row) { + return row.getSchedule() != null + && (row.getSchedule().isNotStarted() || row.getSchedule().isInProgress()); } /** @@ -545,7 +548,7 @@ public class ScheduleRowPresenter extends RowPresenter { // This row has been deleted. return; } - if (row.isOnAir() && row.isRecordingInProgress() && !row.isStopRecordingRequested()) { + if (row.isRecordingInProgress() && !row.isStopRecordingRequested()) { row.setStopRecordingRequested(true); mDvrManager.stopRecording(row.getSchedule()); CharSequence deletedInfo = onGetProgramInfoText(row); @@ -670,10 +673,9 @@ public class ScheduleRowPresenter extends RowPresenter { hideActionView(viewHolder.mFirstActionContainer, View.GONE); } }; - if (mLastFocusedViewId == R.id.action_first_container - || mLastFocusedViewId == R.id.action_second_container) { - mLastFocusedViewId = R.id.info_container; - } + mLastFocusedViewId = R.id.info_container; + SoftPreconditions.checkState(viewHolder.mInfoContainer.isFocusable(), TAG, + "No focusable view in this row: " + viewHolder); break; } View view = viewHolder.view.findViewById(mLastFocusedViewId); @@ -683,8 +685,10 @@ public class ScheduleRowPresenter extends RowPresenter { // requestFocus() explicitly. if (view.hasFocus()) { viewHolder.mPendingAnimationRunnable.run(); - } else { + } else if (view.isFocusable()){ view.requestFocus(); + } else { + viewHolder.view.requestFocus(); } } } else { @@ -737,10 +741,10 @@ public class ScheduleRowPresenter extends RowPresenter { @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 (row.isRecordingInProgress()) { + return new int[]{ACTION_STOP_RECORDING}; + } else if (row.isOnAir()) { + if (row.isRecordingNotStarted()) { if (canResolveConflict()) { // The "START" action can change the conflict states. return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_START_RECORDING}; diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java index 0fb0924d..715ecb8c 100644 --- a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java @@ -16,12 +16,15 @@ package com.android.tv.dvr.ui.list; -import com.android.tv.dvr.SeriesRecording; +import com.android.tv.data.Program; +import com.android.tv.dvr.data.SeriesRecording; + +import java.util.List; /** * A base class for the rows for schedules' header. */ -public abstract class SchedulesHeaderRow { +abstract class SchedulesHeaderRow { private String mTitle; private String mDescription; private int mItemCount; @@ -98,11 +101,20 @@ public abstract class SchedulesHeaderRow { */ public static class SeriesRecordingHeaderRow extends SchedulesHeaderRow { private SeriesRecording mSeriesRecording; + private List<Program> mPrograms; public SeriesRecordingHeaderRow(String title, String description, int itemCount, - SeriesRecording series) { + SeriesRecording series, List<Program> programs) { super(title, description, itemCount); mSeriesRecording = series; + mPrograms = programs; + } + + /** + * Returns the list of programs which belong to the series. + */ + public List<Program> getPrograms() { + return mPrograms; } /** @@ -119,4 +131,4 @@ public abstract class SchedulesHeaderRow { mSeriesRecording = seriesRecording; } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java index 69c33a96..fe2033ba 100644 --- a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java +++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java @@ -30,15 +30,14 @@ 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.data.SeriesRecording; +import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; /** * A base class for RowPresenter for {@link SchedulesHeaderRow} */ -public abstract class SchedulesHeaderRowPresenter extends RowPresenter { +abstract class SchedulesHeaderRowPresenter extends RowPresenter { private Context mContext; public SchedulesHeaderRowPresenter(Context context) { @@ -79,7 +78,7 @@ public abstract class SchedulesHeaderRowPresenter extends RowPresenter { } /** - * A presenter for {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}. + * A presenter for {@link SchedulesHeaderRow.DateHeaderRow}. */ public static class DateHeaderRowPresenter extends SchedulesHeaderRowPresenter { public DateHeaderRowPresenter(Context context) { @@ -93,7 +92,7 @@ public abstract class SchedulesHeaderRowPresenter extends RowPresenter { /** * A ViewHolder for - * {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}. + * {@link SchedulesHeaderRow.DateHeaderRow}. */ public static class DateHeaderRowViewHolder extends SchedulesHeaderRowViewHolder { public DateHeaderRowViewHolder(Context context, ViewGroup parent) { @@ -152,9 +151,9 @@ public abstract class SchedulesHeaderRowPresenter extends RowPresenter { 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); + header.getSeriesRecording().getId(), + header.getPrograms(), false, false, false, null); } }); headerViewHolder.mToggleStartStopButton.setOnClickListener(new OnClickListener() { @@ -169,9 +168,9 @@ public abstract class SchedulesHeaderRowPresenter extends RowPresenter { .build(); TvApplication.getSingletons(getContext()).getDvrManager() .updateSeriesRecording(seriesRecording); - // TODO: pass channel list for settings. DvrUiHelper.startSeriesSettingsActivity(getContext(), - header.getSeriesRecording().getId(), null, false, false, false); + header.getSeriesRecording().getId(), + header.getPrograms(), false, false, false, null); } else { DvrUiHelper.showCancelAllSeriesRecordingDialog( (DvrSchedulesActivity) view.getContext(), @@ -182,11 +181,8 @@ public abstract class SchedulesHeaderRowPresenter extends RowPresenter { } private void setTextDrawable(TextView textView, Drawable drawableStart) { - if (mLtr) { - textView.setCompoundDrawablesWithIntrinsicBounds(drawableStart, null, null, null); - } else { - textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawableStart, null); - } + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, null, + null); } /** diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java index 3b493774..6b6de8b8 100644 --- a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java @@ -31,8 +31,8 @@ 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.data.ScheduledRecording; +import com.android.tv.dvr.data.SeriesRecording; import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow; import com.android.tv.util.Utils; @@ -46,7 +46,7 @@ import java.util.Map; * An adapter for series schedule row. */ @TargetApi(Build.VERSION_CODES.N) -public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { +class SeriesScheduleRowAdapter extends ScheduleRowAdapter { private static final String TAG = "SeriesRowAdapter"; private static final boolean DEBUG = false; @@ -96,7 +96,7 @@ public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { Collections.sort(sortedPrograms); List<EpisodicProgramRow> rows = new ArrayList<>(); mHeaderRow = new SeriesRecordingHeaderRow(mSeriesRecording.getTitle(), - null, sortedPrograms.size(), mSeriesRecording); + null, sortedPrograms.size(), mSeriesRecording, programs); for (Program program : sortedPrograms) { ScheduledRecording schedule = mDataManager.getScheduledRecordingForProgramId(program.getId()); @@ -145,7 +145,7 @@ public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { if (index != -1) { EpisodicProgramRow row = (EpisodicProgramRow) get(index); if (!row.isStartRecordingRequested()) { - row.setSchedule(schedule); + setScheduleToRow(row, schedule); notifyArrayItemRangeChanged(index, 1); } } @@ -195,12 +195,10 @@ public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { if (!isStartOrStopRequested()) { executePendingUpdate(); } - row.setSchedule(schedule); + setScheduleToRow(row, schedule); } - } else if (willBeKept(schedule)) { - row.setSchedule(schedule); } else { - row.setSchedule(null); + setScheduleToRow(row, schedule); } notifyArrayItemRangeChanged(index, 1); } @@ -213,6 +211,14 @@ public class SeriesScheduleRowAdapter extends ScheduleRowAdapter { } } + private void setScheduleToRow(ScheduleRow row, ScheduledRecording schedule) { + if (schedule != null && willBeKept(schedule)) { + row.setSchedule(schedule); + } else { + row.setSchedule(null); + } + } + private int findRowIndexByProgramId(long programId) { for (int i = 0; i < size(); i++) { Object item = get(i); diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java index 5d88579a..c8503e0d 100644 --- a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java +++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java @@ -22,13 +22,13 @@ 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.dvr.ui.DvrUiHelper; import com.android.tv.util.Utils; /** * A RowPresenter for series schedule row. */ -public class SeriesScheduleRowPresenter extends ScheduleRowPresenter { +class SeriesScheduleRowPresenter extends ScheduleRowPresenter { private static final String TAG = "SeriesRowPresenter"; private boolean mLtr; @@ -74,13 +74,8 @@ public class SeriesScheduleRowPresenter extends ScheduleRowPresenter { 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); - } + viewHolder.getProgramTitleView().setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_warning_gray600_36dp, 0, 0, 0); } else { viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); } @@ -88,9 +83,7 @@ public class SeriesScheduleRowPresenter extends ScheduleRowPresenter { @Override protected void onInfoClicked(ScheduleRow row) { - if (row.getSchedule() != null) { - DvrUiHelper.startSchedulesActivity(getContext(), row.getSchedule()); - } + DvrUiHelper.startSchedulesActivity(getContext(), row.getSchedule()); } @Override diff --git a/src/com/android/tv/dvr/DvrPlaybackActivity.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java index 5deda44a..6824cfe2 100644 --- a/src/com/android/tv/dvr/DvrPlaybackActivity.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackActivity.java @@ -14,32 +14,38 @@ * limitations under the License */ -package com.android.tv.dvr; +package com.android.tv.dvr.ui.playback; import android.app.Activity; +import android.content.ContentUris; import android.content.Intent; import android.content.res.Configuration; +import android.net.Uri; 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; +import com.android.tv.dialog.PinDialogFragment.OnPinCheckedListener; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.util.Utils; /** * Activity to play a {@link RecordedProgram}. */ -public class DvrPlaybackActivity extends Activity { +public class DvrPlaybackActivity extends Activity implements OnPinCheckedListener { private static final String TAG = "DvrPlaybackActivity"; private static final boolean DEBUG = false; private DvrPlaybackOverlayFragment mOverlayFragment; + private OnPinCheckedListener mOnPinCheckedListener; @Override public void onCreate(Bundle savedInstanceState) { TvApplication.setCurrentRunningProcess(this, true); if (DEBUG) Log.d(TAG, "onCreate"); super.onCreate(savedInstanceState); + setIntent(createProgramIntent(getIntent())); setContentView(R.layout.activity_dvr_playback); mOverlayFragment = (DvrPlaybackOverlayFragment) getFragmentManager() .findFragmentById(R.id.dvr_playback_controls_fragment); @@ -54,7 +60,8 @@ public class DvrPlaybackActivity extends Activity { @Override protected void onNewIntent(Intent intent) { - mOverlayFragment.onNewIntent(intent); + setIntent(createProgramIntent(intent)); + mOverlayFragment.onNewIntent(createProgramIntent(intent)); } @Override @@ -64,4 +71,24 @@ public class DvrPlaybackActivity extends Activity { mOverlayFragment.onWindowSizeChanged((int) (newConfig.screenWidthDp * density), (int) (newConfig.screenHeightDp * density)); } + + private Intent createProgramIntent(Intent intent) { + if (Intent.ACTION_VIEW.equals(intent.getAction())) { + Uri uri = intent.getData(); + long recordedProgramId = ContentUris.parseId(uri); + intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, recordedProgramId); + } + return intent; + } + + @Override + public void onPinChecked(boolean checked, int type, String rating) { + if (mOnPinCheckedListener != null) { + mOnPinCheckedListener.onPinChecked(checked, type, rating); + } + } + + void setOnPinCheckListener(OnPinCheckedListener listener) { + mOnPinCheckedListener = listener; + } }
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackCardPresenter.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackCardPresenter.java new file mode 100644 index 00000000..8ef0041d --- /dev/null +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackCardPresenter.java @@ -0,0 +1,45 @@ +/* + * 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.playback; + +import android.content.Context; + +import com.android.tv.R; +import com.android.tv.dvr.ui.browse.RecordedProgramPresenter; +import com.android.tv.dvr.ui.browse.RecordingCardView; + +/** + * This class is used to generate Views and bind Objects for related recordings in DVR playback. + */ +class DvrPlaybackCardPresenter extends RecordedProgramPresenter { + 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 DvrItemViewHolder onCreateDvrItemViewHolder() { + return new RecordedProgramViewHolder(new RecordingCardView( + getContext(), mRelatedRecordingCardWidth, mRelatedRecordingCardHeight, true), null); + } +} diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackControlHelper.java index 0bc4ecb1..1a6ae187 100644 --- a/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackControlHelper.java @@ -14,66 +14,80 @@ * limitations under the License */ -package com.android.tv.dvr.ui; +package com.android.tv.dvr.ui.playback; import android.app.Activity; +import android.content.Context; 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.media.tv.TvTrackInfo; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v17.leanback.media.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.ArrayObjectAdapter; import android.support.v17.leanback.widget.PlaybackControlsRow; +import android.support.v17.leanback.widget.PlaybackControlsRow.ClosedCaptioningAction; +import android.support.v17.leanback.widget.PlaybackControlsRow.MultiAction; 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; +import java.util.ArrayList; /** * 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"; +class DvrPlaybackControlHelper extends PlaybackControlGlue { + private static final String TAG = "DvrPlaybackControlHelpr"; private static final boolean DEBUG = false; - /** - * Indicates the ID of the media under playback is unknown. - */ - public static int UNKNOWN_MEDIA_ID = -1; + private static final int AUDIO_ACTION_ID = 1001; private int mPlaybackState = PlaybackState.STATE_NONE; private int mPlaybackSpeedLevel; private int mPlaybackSpeedId; private boolean mReadyToControl; + private final DvrPlaybackOverlayFragment mFragment; private final MediaController mMediaController; private final MediaController.Callback mMediaControllerCallback = new MediaControllerCallback(); private final TransportControls mTransportControls; private final int mExtraPaddingTopForNoDescription; + private final MultiAction mClosedCaptioningAction; + private final MultiAction mMultiAudioAction; + private ArrayObjectAdapter mSecondaryActionsAdapter; - public DvrPlaybackControlHelper(Activity activity, DvrPlaybackOverlayFragment overlayFragment) { - super(activity, overlayFragment, new int[TimeShiftUtils.MAX_SPEED_LEVEL + 1]); + DvrPlaybackControlHelper(Activity activity, DvrPlaybackOverlayFragment overlayFragment) { + super(activity, new int[TimeShiftUtils.MAX_SPEED_LEVEL + 1]); + mFragment = overlayFragment; mMediaController = activity.getMediaController(); mMediaController.registerCallback(mMediaControllerCallback); mTransportControls = mMediaController.getTransportControls(); mExtraPaddingTopForNoDescription = activity.getResources() .getDimensionPixelOffset(R.dimen.dvr_playback_controls_extra_padding_top); + mClosedCaptioningAction = new ClosedCaptioningAction(activity); + mMultiAudioAction = new MultiAudioAction(activity); + createControlsRowPresenter(); } - @Override - public PlaybackControlsRowPresenter createControlsRowAndPresenter() { + void createControlsRow() { PlaybackControlsRow controlsRow = new PlaybackControlsRow(this); setControlsRow(controlsRow); + mSecondaryActionsAdapter = (ArrayObjectAdapter) controlsRow.getSecondaryActionsAdapter(); + } + + private void createControlsRowPresenter() { AbstractDetailsDescriptionPresenter detailsPresenter = new AbstractDetailsDescriptionPresenter() { @Override @@ -112,30 +126,31 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { .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; + setControlsRowPresenter(presenter); } @Override - public boolean onKey(View v, int keyCode, KeyEvent event) { + public void onActionClicked(Action action) { 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)); + int trackType; + if (action.getId() == mClosedCaptioningAction.getId()) { + trackType = TvTrackInfo.TYPE_SUBTITLE; + } else if (action.getId() == AUDIO_ACTION_ID) { + trackType = TvTrackInfo.TYPE_AUDIO; + } else { + super.onActionClicked(action); + return; + } + ArrayList<TvTrackInfo> trackInfos = mFragment.getTracks(trackType); + if (!trackInfos.isEmpty()) { + showSideFragment(trackInfos, mFragment.getSelectedTrackId(trackType)); } - return super.onKey(v, keyCode, event); } - return false; + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + return mReadyToControl && super.onKey(v, keyCode, event); } @Override @@ -158,10 +173,10 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { /** * Returns the ID of the media under playback. */ - public long getMediaId() { + public String getMediaId() { MediaMetadata mediaMetadata = mMediaController.getMetadata(); - return mediaMetadata == null ? UNKNOWN_MEDIA_ID - : mediaMetadata.getLong(MediaMetadata.METADATA_KEY_MEDIA_ID); + return mediaMetadata == null ? null + : mediaMetadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID); } @Override @@ -213,12 +228,45 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { /** * Unregister media controller's callback. */ - public void unregisterCallback() { + void unregisterCallback() { mMediaController.unregisterCallback(mMediaControllerCallback); } + /** + * Update the secondary controls row. + * @param hasClosedCaption {@code true} to show the closed caption selection button, + * {@code false} to hide it. + * @param hasMultiAudio {@code true} to show the audio track selection button, + * {@code false} to hide it. + */ + void updateSecondaryRow(boolean hasClosedCaption, boolean hasMultiAudio) { + if (hasClosedCaption) { + if (mSecondaryActionsAdapter.indexOf(mClosedCaptioningAction) < 0) { + mSecondaryActionsAdapter.add(0, mClosedCaptioningAction); + } + } else { + mSecondaryActionsAdapter.remove(mClosedCaptioningAction); + } + if (hasMultiAudio) { + if (mSecondaryActionsAdapter.indexOf(mMultiAudioAction) < 0) { + mSecondaryActionsAdapter.add(mMultiAudioAction); + } + } else { + mSecondaryActionsAdapter.remove(mMultiAudioAction); + } + getHost().notifyPlaybackRowChanged(); + } + + @Nullable + Boolean hasSecondaryRow() { + if (mSecondaryActionsAdapter == null) { + return null; + } + return mSecondaryActionsAdapter.size() != 0; + } + @Override - protected void startPlayback(int speedId) { + public void play(int speedId) { if (getCurrentSpeedId() == speedId) { return; } @@ -232,23 +280,16 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { } @Override - protected void pausePlayback() { + public void pause() { mTransportControls.pause(); } - @Override - protected void skipToNext() { - // Do nothing. - } - - @Override - protected void skipToPrevious() { - // Do nothing. - } - - @Override - protected void onRowChanged(PlaybackControlsRow row) { - // Do nothing. + /** + * Notifies closed caption being enabled/disabled to update related UI. + */ + void onSubtitleTrackStateChanged(boolean enabled) { + mClosedCaptioningAction.setIndex(enabled ? + ClosedCaptioningAction.ON : ClosedCaptioningAction.OFF); } private void onStateChanged(int state, long positionMs, int speedLevel) { @@ -297,6 +338,19 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { onStateChanged(); } + private void showSideFragment(ArrayList<TvTrackInfo> trackInfos, String selectedTrackId) { + Bundle args = new Bundle(); + args.putParcelableArrayList(DvrPlaybackSideFragment.TRACK_INFOS, trackInfos); + args.putString(DvrPlaybackSideFragment.SELECTED_TRACK_ID, selectedTrackId); + DvrPlaybackSideFragment sideFragment = new DvrPlaybackSideFragment(); + sideFragment.setArguments(args); + mFragment.getFragmentManager().beginTransaction() + .hide(mFragment) + .replace(R.id.dvr_playback_side_fragment, sideFragment) + .addToBackStack(null) + .commit(); + } + private class MediaControllerCallback extends MediaController.Callback { @Override public void onPlaybackStateChanged(PlaybackState state) { @@ -307,7 +361,13 @@ public class DvrPlaybackControlHelper extends PlaybackControlGlue { @Override public void onMetadataChanged(MediaMetadata metadata) { DvrPlaybackControlHelper.this.onMetadataChanged(); - ((DvrPlaybackOverlayFragment) getFragment()).onMediaControllerUpdated(); + } + } + + private static class MultiAudioAction extends MultiAction { + MultiAudioAction(Context context) { + super(AUDIO_ACTION_ID); + setDrawables(new Drawable[]{context.getDrawable(R.drawable.ic_tvoption_multi_track)}); } } }
\ No newline at end of file diff --git a/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java index 9759a856..843d2dbe 100644 --- a/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackMediaSessionHelper.java @@ -14,7 +14,7 @@ * limitations under the License */ -package com.android.tv.dvr; +package com.android.tv.dvr.ui.playback; import android.app.Activity; import android.content.Intent; @@ -31,14 +31,16 @@ import android.text.TextUtils; 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.ChannelDataManager; -import com.android.tv.dvr.ui.DvrPlaybackOverlayFragment; +import com.android.tv.dvr.DvrWatchedPositionManager; +import com.android.tv.dvr.data.RecordedProgram; import com.android.tv.util.ImageLoader; import com.android.tv.util.TimeShiftUtils; import com.android.tv.util.Utils; -public class DvrPlaybackMediaSessionHelper { +class DvrPlaybackMediaSessionHelper { private static final String TAG = "DvrPlaybackMediaSessionHelper"; private static final boolean DEBUG = false; @@ -102,6 +104,7 @@ public class DvrPlaybackMediaSessionHelper { } if (mMediaSession != null) { mMediaSession.release(); + mMediaSession = null; } } @@ -179,83 +182,88 @@ public class DvrPlaybackMediaSessionHelper { cardTitleText = (channel != null) ? channel.getDisplayName() : mActivity.getString(R.string.no_program_information); } - updateMediaMetadata(program.getId(), cardTitleText, program.getDescription(), - mProgramDurationMs, null, 0); + final MediaMetadata currentMetadata = updateMetadataTextInfo(program.getId(), cardTitleText, + program.getDescription(), mProgramDurationMs); String posterArtUri = program.getPosterArtUri(); if (posterArtUri == null) { posterArtUri = TvContract.buildChannelLogoUri(program.getChannelId()).toString(); } - updatePosterArt(program, cardTitleText, program.getDescription(), - mProgramDurationMs, null, posterArtUri); + updatePosterArt(program, currentMetadata, null, posterArtUri); mMediaSession.setActive(true); } - private void updatePosterArt(RecordedProgram program, String cardTitleText, - String cardSubtitleText, long duration, + private void updatePosterArt(RecordedProgram program, MediaMetadata currentMetadata, @Nullable Bitmap posterArt, @Nullable String posterArtUri) { if (posterArt != null) { - updateMediaMetadata(program.getId(), cardTitleText, - cardSubtitleText, duration, posterArt, 0); + updateMetadataImageInfo(program, currentMetadata, posterArt, 0); } else if (posterArtUri != null) { ImageLoader.loadBitmap(mActivity, posterArtUri, mNowPlayingCardWidth, - mNowPlayingCardHeight, new ProgramPosterArtCallback( - mActivity, program, cardTitleText, cardSubtitleText, duration)); + mNowPlayingCardHeight, + new ProgramPosterArtCallback(mActivity, program, currentMetadata)); } else { - updateMediaMetadata(program.getId(), cardTitleText, - cardSubtitleText, duration, null, R.drawable.default_now_card); + updateMetadataImageInfo(program, currentMetadata, 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; + private final RecordedProgram mRecordedProgram; + private final MediaMetadata mCurrentMetadata; public ProgramPosterArtCallback(Activity activity, RecordedProgram program, - String cardTitleText, String cardSubtitleText, long duration) { + MediaMetadata metadata) { super(activity); mRecordedProgram = program; - mCardTitleText = cardTitleText; - mCardSubtitleText = cardSubtitleText; - mDuration = duration; + mCurrentMetadata = metadata; } @Override public void onBitmapLoaded(Activity activity, @Nullable Bitmap posterArt) { if (isCurrentProgram(mRecordedProgram)) { - updatePosterArt(mRecordedProgram, mCardTitleText, - mCardSubtitleText, mDuration, posterArt, null); + updatePosterArt(mRecordedProgram, mCurrentMetadata, 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); - } + private MediaMetadata updateMetadataTextInfo(final long programId, final String title, + final String subtitle, final long duration) { + MediaMetadata.Builder builder = new MediaMetadata.Builder(); + builder.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, Long.toString(programId)) + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putLong(MediaMetadata.METADATA_KEY_DURATION, duration); + if (subtitle != null) { + builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle); + } + MediaMetadata metadata = builder.build(); + mMediaSession.setMetadata(metadata); + return metadata; + } + + private void updateMetadataImageInfo(final RecordedProgram program, + final MediaMetadata currentMetadata, final Bitmap posterArt, final int imageResId) { + if (mMediaSession != null && (posterArt != null || imageResId != 0)) { + MediaMetadata.Builder builder = new MediaMetadata.Builder(currentMetadata); + if (posterArt != null) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt); mMediaSession.setMetadata(builder.build()); - return null; + } else { + new AsyncTask<Void, Void, Bitmap>() { + @Override + protected Bitmap doInBackground(Void... arg0) { + return BitmapFactory.decodeResource(mActivity.getResources(), imageResId); + } + + @Override + protected void onPostExecute(Bitmap programPosterArt) { + if (mMediaSession != null && programPosterArt != null + && isCurrentProgram(program)) { + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, programPosterArt); + mMediaSession.setMetadata(builder.build()); + } + } + }.execute(); } - }.execute(); + } } // An event was triggered by MediaController.TransportControls and must be handled here. diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java new file mode 100644 index 00000000..783ae682 --- /dev/null +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackOverlayFragment.java @@ -0,0 +1,494 @@ +/* + * 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.playback; + +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.hardware.display.DisplayManager; +import android.media.tv.TvContentRating; +import android.media.tv.TvTrackInfo; +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.PlaybackFragment; +import android.support.v17.leanback.app.PlaybackFragmentGlueHost; +import android.support.v17.leanback.widget.ArrayObjectAdapter; +import android.support.v17.leanback.widget.BaseOnItemViewClickedListener; +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.Presenter; +import android.support.v17.leanback.widget.RowPresenter; +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.util.Log; + +import com.android.tv.R; +import com.android.tv.TvApplication; +import com.android.tv.data.BaseProgram; +import com.android.tv.dialog.PinDialogFragment; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.data.RecordedProgram; +import com.android.tv.dvr.data.SeriesRecording; +import com.android.tv.dvr.ui.SortedArrayAdapter; +import com.android.tv.dvr.ui.browse.DvrListRowPresenter; +import com.android.tv.dvr.ui.browse.RecordingCardView; +import com.android.tv.parental.ContentRatingsManager; +import com.android.tv.util.TvSettings; +import com.android.tv.util.TvTrackInfoUtils; +import com.android.tv.util.Utils; + +import java.util.List; +import java.util.ArrayList; + +public class DvrPlaybackOverlayFragment extends PlaybackFragment { + // TODO: Handles audio focus. Deals with block and ratings. + private static final String TAG = "DvrPlaybackOverlayFrag"; + 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 DvrPlayer mDvrPlayer; + 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 mVerticalPaddingBase; + private int mPaddingWithoutRelatedRow; + private int mPaddingWithoutSecondaryRow; + private int mWindowWidth; + private int mWindowHeight; + private float mAppliedAspectRatio; + private float mWindowAspectRatio; + private boolean mPinChecked; + private boolean mStarted; + private DvrPlayer.OnTrackSelectedListener mOnSubtitleTrackSelectedListener = + new DvrPlayer.OnTrackSelectedListener() { + @Override + public void onTrackSelected(String selectedTrackId) { + mPlaybackControlHelper.onSubtitleTrackStateChanged(selectedTrackId != null); + mRowsAdapter.notifyArrayItemRangeChanged(0, 1); + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); + super.onCreate(savedInstanceState); + mVerticalPaddingBase = getActivity().getResources() + .getDimensionPixelOffset(R.dimen.dvr_playback_overlay_padding_top_base); + mPaddingWithoutRelatedRow = getActivity().getResources() + .getDimensionPixelOffset(R.dimen.dvr_playback_overlay_padding_top_no_related_row); + mPaddingWithoutSecondaryRow = getActivity().getResources() + .getDimensionPixelOffset(R.dimen.dvr_playback_overlay_padding_top_no_secondary_row); + mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager(); + mContentRatingsManager = TvApplication.getSingletons(getContext()) + .getTvInputManagerHelper().getContentRatingsManager(); + if (!mDvrDataManager.isRecordedProgramLoadFinished()) { + mDvrDataManager.addRecordedProgramLoadFinishedListener( + new DvrDataManager.OnRecordedProgramLoadFinishedListener() { + @Override + public void onRecordedProgramLoadFinished() { + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + if (handleIntent(getActivity().getIntent(), true)) { + setUpRows(); + preparePlayback(getActivity().getIntent()); + } + } + } + ); + } else if (!handleIntent(getActivity().getIntent(), true)) { + 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(PlaybackFragment.BG_LIGHT); + setFadingEnabled(true); + } + + @Override + public void onStart() { + super.onStart(); + mStarted = true; + updateVerticalPosition(); + } + + @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); + mDvrPlayer = new DvrPlayer(mTvView); + mMediaSessionHelper = new DvrPlaybackMediaSessionHelper( + getActivity(), MEDIA_SESSION_TAG, mDvrPlayer, this); + mPlaybackControlHelper = new DvrPlaybackControlHelper(getActivity(), this); + mRelatedRecordingsRow = getRelatedRecordingsRow(); + mDvrPlayer.setOnTracksAvailabilityChangedListener( + new DvrPlayer.OnTracksAvailabilityChangedListener() { + @Override + public void onTracksAvailabilityChanged(boolean hasClosedCaption, + boolean hasMultiAudio) { + mPlaybackControlHelper.updateSecondaryRow(hasClosedCaption, hasMultiAudio); + if (hasClosedCaption) { + mDvrPlayer.setOnTrackSelectedListener(TvTrackInfo.TYPE_SUBTITLE, + mOnSubtitleTrackSelectedListener); + selectBestMatchedTrack(TvTrackInfo.TYPE_SUBTITLE); + } else { + mDvrPlayer.setOnTrackSelectedListener(TvTrackInfo.TYPE_SUBTITLE, null); + } + if (hasMultiAudio) { + selectBestMatchedTrack(TvTrackInfo.TYPE_AUDIO); + } + updateVerticalPosition(); + mPlaybackControlHelper.getHost().notifyPlaybackRowChanged(); + } + }); + mDvrPlayer.setOnAspectRatioChangedListener(new DvrPlayer.OnAspectRatioChangedListener() { + @Override + public void onAspectRatioChanged(float videoAspectRatio) { + updateAspectRatio(videoAspectRatio); + } + }); + mPinChecked = getActivity().getIntent() + .getBooleanExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, false); + mDvrPlayer.setOnContentBlockedListener( + new DvrPlayer.OnContentBlockedListener() { + @Override + public void onContentBlocked(TvContentRating contentRating) { + if (mPinChecked) { + mTvView.unblockContent(contentRating); + return; + } + mBlockScreenView.setVisibility(View.VISIBLE); + getActivity().getMediaController().getTransportControls().pause(); + ((DvrPlaybackActivity) getActivity()) + .setOnPinCheckListener( + new PinDialogFragment.OnPinCheckedListener() { + @Override + public void onPinChecked( + boolean checked, int type, String rating) { + ((DvrPlaybackActivity) getActivity()) + .setOnPinCheckListener(null); + if (checked) { + mPinChecked = true; + mTvView.unblockContent(contentRating); + mBlockScreenView.setVisibility(View.GONE); + getActivity() + .getMediaController() + .getTransportControls() + .play(); + } + } + }); + PinDialogFragment.create( + PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_DVR, + contentRating.flattenToString()) + .show( + getActivity().getFragmentManager(), + PinDialogFragment.DIALOG_TAG); + } + }); + setOnItemViewClickedListener(new BaseOnItemViewClickedListener() { + @Override + public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, + RowPresenter.ViewHolder rowViewHolder, Object row) { + if (itemViewHolder.view instanceof RecordingCardView) { + setFadingEnabled(false); + long programId = ((RecordedProgram) itemViewHolder.view.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); + } + } + }); + if (mProgram != null) { + setUpRows(); + preparePlayback(getActivity().getIntent()); + } + } + + @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) { + if (mDvrDataManager.isRecordedProgramLoadFinished() && handleIntent(intent, false)) { + 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); + } + + /** + * Returns next recorded episode in the same series as now playing program. + */ + public RecordedProgram getNextEpisode(RecordedProgram program) { + int position = mRelatedRecordingsRowAdapter.findInsertPosition(program); + if (position == mRelatedRecordingsRowAdapter.size()) { + return null; + } else { + return (RecordedProgram) mRelatedRecordingsRowAdapter.get(position); + } + } + + /** + * Returns the tracks of the give type of the current playback. + + * @param trackType Should be {@link TvTrackInfo#TYPE_SUBTITLE} + * or {@link TvTrackInfo#TYPE_AUDIO}. Or returns {@code null}. + */ + public ArrayList<TvTrackInfo> getTracks(int trackType) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + return mDvrPlayer.getAudioTracks(); + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + return mDvrPlayer.getSubtitleTracks(); + } + return null; + } + + /** + * Returns the ID of the selected track of the given type. + */ + public String getSelectedTrackId(int trackType) { + return mDvrPlayer.getSelectedTrackId(trackType); + } + + /** + * Returns the language setting of the given track type. + + * @param trackType Should be {@link TvTrackInfo#TYPE_SUBTITLE} + * or {@link TvTrackInfo#TYPE_AUDIO}. + * @return {@code null} if no language has been set for the given track type. + */ + TvTrackInfo getTrackSetting(int trackType) { + return TvSettings.getDvrPlaybackTrackSettings(getContext(), trackType); + } + + /** + * Selects the given audio or subtitle track for DVR playback. + * @param trackType Should be {@link TvTrackInfo#TYPE_SUBTITLE} + * or {@link TvTrackInfo#TYPE_AUDIO}. + * @param selectedTrack {@code null} to disable the audio or subtitle track according to + * trackType. + */ + void selectTrack(int trackType, TvTrackInfo selectedTrack) { + if (mDvrPlayer.isPlaybackPrepared()) { + mDvrPlayer.selectTrack(trackType, selectedTrack); + } + } + + private boolean handleIntent(Intent intent, boolean finishActivity) { + mProgram = getProgramFromIntent(intent); + if (mProgram == null) { + Toast.makeText(getActivity(), getString(R.string.dvr_program_not_found), + Toast.LENGTH_SHORT).show(); + if (finishActivity) { + getActivity().finish(); + } + return false; + } + return true; + } + + private void selectBestMatchedTrack(int trackType) { + TvTrackInfo selectedTrack = getTrackSetting(trackType); + if (selectedTrack != null) { + TvTrackInfo bestMatchedTrack = TvTrackInfoUtils.getBestTrackInfo(getTracks(trackType), + selectedTrack.getId(), selectedTrack.getLanguage(), + trackType == TvTrackInfo.TYPE_AUDIO ? selectedTrack.getAudioChannelCount() : 0); + if (bestMatchedTrack != null && (trackType == TvTrackInfo.TYPE_AUDIO || Utils + .isEqualLanguage(bestMatchedTrack.getLanguage(), + selectedTrack.getLanguage()))) { + selectTrack(trackType, bestMatchedTrack); + return; + } + } + if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + // Disables closed captioning if there's no matched language. + selectTrack(TvTrackInfo.TYPE_SUBTITLE, null); + } + } + + private void updateAspectRatio(float videoAspectRatio) { + if (videoAspectRatio <= 0) { + // We don't have video's width or height information, use window's aspect ratio. + videoAspectRatio = mWindowAspectRatio; + } + if (Math.abs(mAppliedAspectRatio - videoAspectRatio) < DISPLAY_ASPECT_RATIO_EPSILON) { + // No need to change + return; + } + if (Math.abs(mWindowAspectRatio - videoAspectRatio) < DISPLAY_ASPECT_RATIO_EPSILON) { + ((ViewGroup) mTvView.getParent()).setPadding(0, 0, 0, 0); + } else 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)); + mPlaybackControlHelper.updateSecondaryRow(false, false); + getActivity().getMediaController().getTransportControls().prepare(); + updateRelatedRecordingsRow(); + } + + private void updateRelatedRecordingsRow() { + boolean wasEmpty = (mRelatedRecordingsRowAdapter.size() == 0); + mRelatedRecordingsRowAdapter.clear(); + long programId = mProgram.getId(); + String seriesId = mProgram.getSeriesId(); + SeriesRecording seriesRecording = mDvrDataManager.getSeriesRecording(seriesId); + if (seriesRecording != null) { + if (DEBUG) Log.d(TAG, "Update related recordings with:" + seriesId); + List<RecordedProgram> relatedPrograms = + mDvrDataManager.getRecordedPrograms(seriesRecording.getId()); + for (RecordedProgram program : relatedPrograms) { + if (programId != program.getId()) { + mRelatedRecordingsRowAdapter.add(program); + } + } + } + if (mRelatedRecordingsRowAdapter.size() == 0) { + mRowsAdapter.remove(mRelatedRecordingsRow); + } else if (wasEmpty){ + mRowsAdapter.add(mRelatedRecordingsRow); + } + updateVerticalPosition(); + mRowsAdapter.notifyArrayItemRangeChanged(1, 1); + } + + private void setUpRows() { + mPlaybackControlHelper.createControlsRow(); + mPlaybackControlHelper.setHost(new PlaybackFragmentGlueHost(this)); + mRowsAdapter = (ArrayObjectAdapter) getAdapter(); + ClassPresenterSelector selector = + (ClassPresenterSelector) mRowsAdapter.getPresenterSelector(); + selector.addClassPresenter(ListRow.class, new DvrListRowPresenter(getContext())); + mRowsAdapter.setPresenterSelector(selector); + if (mStarted) { + // If it's started before setting up rows, vertical position has not been updated and + // should be updated here. + updateVerticalPosition(); + } + } + + 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 void updateVerticalPosition() { + Boolean hasSecondaryRow = mPlaybackControlHelper.hasSecondaryRow(); + if (hasSecondaryRow == null) { + return; + } + + int verticalPadding = mVerticalPaddingBase; + if (mRelatedRecordingsRowAdapter.size() == 0) { + verticalPadding += mPaddingWithoutRelatedRow; + } + if (!hasSecondaryRow) { + verticalPadding += mPaddingWithoutSecondaryRow; + } + Fragment fragment = getChildFragmentManager().findFragmentById(R.id.playback_controls_dock); + View view = fragment == null ? null : fragment.getView(); + if (view != null) { + view.setTranslationY(verticalPadding); + } + } + + private class RelatedRecordingsAdapter extends SortedArrayAdapter<BaseProgram> { + RelatedRecordingsAdapter(DvrPlaybackCardPresenter presenter) { + super(new SinglePresenterSelector(presenter), BaseProgram.EPISODE_COMPARATOR); + } + + @Override + public long getId(BaseProgram item) { + return item.getId(); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/ui/playback/DvrPlaybackSideFragment.java b/src/com/android/tv/dvr/ui/playback/DvrPlaybackSideFragment.java new file mode 100644 index 00000000..e49870f1 --- /dev/null +++ b/src/com/android/tv/dvr/ui/playback/DvrPlaybackSideFragment.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.playback; + +import android.media.tv.TvTrackInfo; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v17.leanback.app.GuidedStepFragment; +import android.support.v17.leanback.widget.GuidedAction; +import android.text.TextUtils; +import android.transition.Transition; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.tv.R; +import com.android.tv.util.TvSettings; + +import java.util.List; +import java.util.Locale; + +/** + * Fragment for DVR playback closed-caption/multi-audio settings. + */ +public class DvrPlaybackSideFragment extends GuidedStepFragment { + /** + * The tag for passing track infos to side fragments. + */ + public static final String TRACK_INFOS = "dvr_key_track_infos"; + /** + * The tag for passing selected track's ID to side fragments. + */ + public static final String SELECTED_TRACK_ID = "dvr_key_selected_track_id"; + + private static final int ACTION_ID_NO_SUBTITLE = -1; + private static final int CHECK_SET_ID = 1; + + private List<TvTrackInfo> mTrackInfos; + private String mSelectedTrackId; + private TvTrackInfo mSelectedTrack; + private int mTrackType; + private DvrPlaybackOverlayFragment mOverlayFragment; + + @Override + public void onCreate(Bundle savedInstanceState) { + mTrackInfos = getArguments().getParcelableArrayList(TRACK_INFOS); + mTrackType = mTrackInfos.get(0).getType(); + mSelectedTrackId = getArguments().getString(SELECTED_TRACK_ID); + mOverlayFragment = ((DvrPlaybackOverlayFragment) getFragmentManager() + .findFragmentById(R.id.dvr_playback_controls_fragment)); + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateBackgroundView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View backgroundView = super.onCreateBackgroundView(inflater, container, savedInstanceState); + backgroundView.setBackgroundColor(getResources() + .getColor(R.color.lb_playback_controls_background_light)); + return backgroundView; + } + + @Override + public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) { + if (mTrackType == TvTrackInfo.TYPE_SUBTITLE) { + actions.add(new GuidedAction.Builder(getActivity()) + .id(ACTION_ID_NO_SUBTITLE) + .title(getString(R.string.closed_caption_option_item_off)) + .checkSetId(CHECK_SET_ID) + .checked(mSelectedTrackId == null) + .build()); + } + for (int i = 0; i < mTrackInfos.size(); i++) { + TvTrackInfo info = mTrackInfos.get(i); + boolean checked = TextUtils.equals(info.getId(), mSelectedTrackId); + GuidedAction action = new GuidedAction.Builder(getActivity()) + .id(i) + .title(getTrackLabel(info, i)) + .checkSetId(CHECK_SET_ID) + .checked(checked) + .build(); + actions.add(action); + if (checked) { + mSelectedTrack = info; + } + } + } + + @Override + public void onGuidedActionFocused(GuidedAction action) { + int actionId = (int) action.getId(); + mOverlayFragment.selectTrack(mTrackType, actionId < 0 ? null : mTrackInfos.get(actionId)); + } + + @Override + public void onGuidedActionClicked(GuidedAction action) { + int actionId = (int) action.getId(); + mSelectedTrack = actionId < 0 ? null : mTrackInfos.get(actionId); + TvSettings.setDvrPlaybackTrackSettings(getContext(), mTrackType, mSelectedTrack); + getFragmentManager().popBackStack(); + } + + @Override + public void onStart() { + super.onStart(); + // Workaround: when overlay fragment is faded out, any focus will lost due to overlay + // fragment's implementation. So we disable overlay fragment's fading here to prevent + // losing focus while users are interacting with the side fragment. + mOverlayFragment.setFadingEnabled(false); + } + + @Override + public void onStop() { + super.onStop(); + // We disable fading of overlay fragment to prevent side fragment from losing focus, + // therefore we should resume it here. + mOverlayFragment.setFadingEnabled(true); + mOverlayFragment.selectTrack(mTrackType, mSelectedTrack); + } + + private String getTrackLabel(TvTrackInfo track, int trackIndex) { + if (track.getLanguage() != null) { + return new Locale(track.getLanguage()).getDisplayName(); + } + return track.getType() == TvTrackInfo.TYPE_SUBTITLE ? + getString(R.string.closed_caption_unknown_language, trackIndex + 1) + : getString(R.string.multi_audio_unknown_language); + } + + @Override + protected void onProvideFragmentTransitions() { + super.onProvideFragmentTransitions(); + // Excludes the background scrim from transition to prevent the blinking caused by + // hiding the overlay fragment and sliding in the side fragment at the same time. + Transition t = getEnterTransition(); + if (t != null) { + t.excludeTarget(R.id.guidedstep_background, true); + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/dvr/DvrPlayer.java b/src/com/android/tv/dvr/ui/playback/DvrPlayer.java index 5656655c..7226c666 100644 --- a/src/com/android/tv/dvr/DvrPlayer.java +++ b/src/com/android/tv/dvr/ui/playback/DvrPlayer.java @@ -14,20 +14,24 @@ * limitations under the License */ -package com.android.tv.dvr; +package com.android.tv.dvr.ui.playback; import android.media.PlaybackParams; +import android.media.session.PlaybackState; 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.text.TextUtils; import android.util.Log; +import com.android.tv.dvr.data.RecordedProgram; + +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; -public class DvrPlayer { +class DvrPlayer { private static final String TAG = "DvrPlayer"; private static final boolean DEBUG = false; @@ -47,12 +51,19 @@ public class DvrPlayer { private long mInitialSeekPositionMs; private final TvView mTvView; private DvrPlayerCallback mCallback; - private AspectRatioChangedListener mAspectRatioChangedListener; - private ContentBlockedListener mContentBlockedListener; + private OnAspectRatioChangedListener mOnAspectRatioChangedListener; + private OnContentBlockedListener mOnContentBlockedListener; + private OnTracksAvailabilityChangedListener mOnTracksAvailabilityChangedListener; + private OnTrackSelectedListener mOnAudioTrackSelectedListener; + private OnTrackSelectedListener mOnSubtitleTrackSelectedListener; + private String mSelectedAudioTrackId; + private String mSelectedSubtitleTrackId; private float mAspectRatio = Float.NaN; private int mPlaybackState = PlaybackState.STATE_NONE; private long mTimeShiftCurrentPositionMs; private boolean mPauseOnPrepared; + private boolean mHasClosedCaption; + private boolean mHasMultiAudio; private final PlaybackParams mPlaybackParams = new PlaybackParams(); private final DvrPlayerCallback mEmptyCallback = new DvrPlayerCallback(); private long mStartPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; @@ -75,22 +86,40 @@ public class DvrPlayer { public void onPlaybackEnded() { } } - public interface AspectRatioChangedListener { + public interface OnAspectRatioChangedListener { /** * Called when the Video's aspect ratio is changed. + * + * @param videoAspectRatio The aspect ratio of video. 0 stands for unknown ratios. + * Listeners should handle it carefully. */ void onAspectRatioChanged(float videoAspectRatio); } - public interface ContentBlockedListener { + public interface OnContentBlockedListener { /** * Called when the Video's aspect ratio is changed. */ void onContentBlocked(TvContentRating rating); } + public interface OnTracksAvailabilityChangedListener { + /** + * Called when the Video's subtitle or audio tracks are changed. + */ + void onTracksAvailabilityChanged(boolean hasClosedCaption, boolean hasMultiAudio); + } + + public interface OnTrackSelectedListener { + /** + * Called when certain subtitle or audio track is selected. + */ + void onTrackSelected(String selectedTrackId); + } + public DvrPlayer(TvView tvView) { mTvView = tvView; + mTvView.setCaptionEnabled(true); mPlaybackParams.setSpeed(1.0f); setTvViewCallbacks(); setCallback(null); @@ -236,6 +265,8 @@ public class DvrPlayer { mTimeShiftCurrentPositionMs = 0; mPlaybackParams.setSpeed(1.0f); mProgram = null; + mSelectedAudioTrackId = null; + mSelectedSubtitleTrackId = null; } /** @@ -250,17 +281,51 @@ public class DvrPlayer { } /** - * Sets listener to aspect ratio changing. + * Sets the listener to aspect ratio changing. + */ + public void setOnAspectRatioChangedListener(OnAspectRatioChangedListener listener) { + mOnAspectRatioChangedListener = listener; + } + + /** + * Sets the listener to content blocking. */ - public void setAspectRatioChangedListener(AspectRatioChangedListener listener) { - mAspectRatioChangedListener = listener; + public void setOnContentBlockedListener(OnContentBlockedListener listener) { + mOnContentBlockedListener = listener; } /** - * Sets listener to content blocking. + * Sets the listener to tracks changing. */ - public void setContentBlockedListener(ContentBlockedListener listener) { - mContentBlockedListener = listener; + public void setOnTracksAvailabilityChangedListener( + OnTracksAvailabilityChangedListener listener) { + mOnTracksAvailabilityChangedListener = listener; + } + + /** + * Sets the listener to tracks of the given type being selected. + * + * @param trackType should be either {@link TvTrackInfo#TYPE_AUDIO} + * or {@link TvTrackInfo#TYPE_SUBTITLE}. + */ + public void setOnTrackSelectedListener(int trackType, OnTrackSelectedListener listener) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + mOnAudioTrackSelectedListener = listener; + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + mOnSubtitleTrackSelectedListener = listener; + } + } + + /** + * Gets the listener to tracks of the given type being selected. + */ + public OnTrackSelectedListener getOnTrackSelectedListener(int trackType) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + return mOnAudioTrackSelectedListener; + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + return mOnSubtitleTrackSelectedListener; + } + return null; } /** @@ -306,6 +371,32 @@ public class DvrPlayer { } /** + * Returns the subtitle tracks of the current playback. + */ + public ArrayList<TvTrackInfo> getSubtitleTracks() { + return new ArrayList<>(mTvView.getTracks(TvTrackInfo.TYPE_SUBTITLE)); + } + + /** + * Returns the audio tracks of the current playback. + */ + public ArrayList<TvTrackInfo> getAudioTracks() { + return new ArrayList<>(mTvView.getTracks(TvTrackInfo.TYPE_AUDIO)); + } + + /** + * Returns the ID of the selected track of the given type. + */ + public String getSelectedTrackId(int trackType) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + return mSelectedAudioTrackId; + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + return mSelectedSubtitleTrackId; + } + return null; + } + + /** * Returns if playback of the recorded program is started. */ public boolean isPlaybackPrepared() { @@ -313,6 +404,41 @@ public class DvrPlayer { && mPlaybackState != PlaybackState.STATE_CONNECTING; } + /** + * Selects the given track. + * + * @return ID of the selected track. + */ + String selectTrack(int trackType, TvTrackInfo selectedTrack) { + String oldSelectedTrackId = getSelectedTrackId(trackType); + String newSelectedTrackId = selectedTrack == null ? null : selectedTrack.getId(); + if (!TextUtils.equals(oldSelectedTrackId, newSelectedTrackId)) { + if (selectedTrack == null) { + mTvView.selectTrack(trackType, null); + return null; + } else { + List<TvTrackInfo> tracks = mTvView.getTracks(trackType); + if (tracks != null && tracks.contains(selectedTrack)) { + mTvView.selectTrack(trackType, newSelectedTrackId); + return newSelectedTrackId; + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE && oldSelectedTrackId != null) { + // Track not found, disabled closed caption. + mTvView.selectTrack(trackType, null); + return null; + } + } + } + return oldSelectedTrackId; + } + + private void setSelectedTrackId(int trackType, String trackId) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + mSelectedAudioTrackId = trackId; + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + mSelectedSubtitleTrackId = trackId; + } + } + private void setPlaybackSpeed(int speed) { mPlaybackParams.setSpeed(speed); mTvView.timeShiftSetPlaybackParams(mPlaybackParams); @@ -369,28 +495,60 @@ public class DvrPlayer { if (status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE && mPlaybackState == PlaybackState.STATE_CONNECTING) { mTimeShiftPlayAvailable = true; + if (mStartPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) { + // onTimeShiftStatusChanged is sometimes called after + // onTimeShiftStartPositionChanged is called. In this case, + // resumeToWatchedPositionIfNeeded needs to be called here. + resumeToWatchedPositionIfNeeded(); + } } } @Override - public void onTrackSelected(String inputId, int type, String trackId) { - if (trackId == null || type != TvTrackInfo.TYPE_VIDEO - || mAspectRatioChangedListener == null) { - return; + public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) { + boolean hasClosedCaption = + !mTvView.getTracks(TvTrackInfo.TYPE_SUBTITLE).isEmpty(); + boolean hasMultiAudio = mTvView.getTracks(TvTrackInfo.TYPE_AUDIO).size() > 1; + if ((hasClosedCaption != mHasClosedCaption || hasMultiAudio != mHasMultiAudio) + && mOnTracksAvailabilityChangedListener != null) { + mOnTracksAvailabilityChangedListener + .onTracksAvailabilityChanged(hasClosedCaption, hasMultiAudio); } - 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; + mHasClosedCaption = hasClosedCaption; + mHasMultiAudio = hasMultiAudio; + } + + @Override + public void onTrackSelected(String inputId, int type, String trackId) { + if (type == TvTrackInfo.TYPE_AUDIO || type == TvTrackInfo.TYPE_SUBTITLE) { + setSelectedTrackId(type, trackId); + OnTrackSelectedListener listener = getOnTrackSelectedListener(type); + if (listener != null) { + listener.onTrackSelected(trackId); + } + } else if (type == TvTrackInfo.TYPE_VIDEO && trackId != null + && mOnAspectRatioChangedListener != null) { + List<TvTrackInfo> trackInfos = mTvView.getTracks(TvTrackInfo.TYPE_VIDEO); + if (trackInfos != null) { + for (TvTrackInfo trackInfo : trackInfos) { + if (trackInfo.getId().equals(trackId)) { + float videoAspectRatio; + int videoWidth = trackInfo.getVideoWidth(); + int videoHeight = trackInfo.getVideoHeight(); + if (videoWidth > 0 && videoHeight > 0) { + videoAspectRatio = trackInfo.getVideoPixelAspectRatio() + * trackInfo.getVideoWidth() / trackInfo.getVideoHeight(); + } else { + // Aspect ratio is unknown. Pass the message to listeners. + videoAspectRatio = 0; + } + if (DEBUG) Log.d(TAG, "Aspect Ratio: " + videoAspectRatio); + if (mAspectRatio != videoAspectRatio || videoAspectRatio == 0) { + mOnAspectRatioChangedListener + .onAspectRatioChanged(videoAspectRatio); + mAspectRatio = videoAspectRatio; + return; + } } } } @@ -399,8 +557,8 @@ public class DvrPlayer { @Override public void onContentBlocked(String inputId, TvContentRating rating) { - if (mContentBlockedListener != null) { - mContentBlockedListener.onContentBlocked(rating); + if (mOnContentBlockedListener != null) { + mOnContentBlockedListener.onContentBlocked(rating); } } }); diff --git a/src/com/android/tv/experiments/ExperimentFlag.java b/src/com/android/tv/experiments/ExperimentFlag.java index 8f60c2b5..c0cbd643 100644 --- a/src/com/android/tv/experiments/ExperimentFlag.java +++ b/src/com/android/tv/experiments/ExperimentFlag.java @@ -16,12 +16,19 @@ package com.android.tv.experiments; +import android.support.annotation.VisibleForTesting; /** * Experiments return values based on user, device and other criteria. */ public final class ExperimentFlag<T> { - private final T mDefaultValue; + + private static boolean sAllowOverrides = false; + + @VisibleForTesting + public static void initForTest() { + sAllowOverrides = true; + } /** Returns a boolean experiment */ public static ExperimentFlag<Boolean> createFlag( @@ -30,6 +37,11 @@ public final class ExperimentFlag<T> { defaultValue); } + private final T mDefaultValue; + + private T mOverrideValue = null; + private boolean mOverridden = false; + private ExperimentFlag( T defaultValue) { mDefaultValue = defaultValue; @@ -37,6 +49,22 @@ public final class ExperimentFlag<T> { /** Returns value for this experiment */ public T get() { - return mDefaultValue; + return sAllowOverrides && mOverridden ? mOverrideValue : mDefaultValue; } + + @VisibleForTesting + public void override(T t) { + if (sAllowOverrides) { + mOverridden = true; + mOverrideValue = t; + } + } + + @VisibleForTesting + public void resetOverride() { + mOverridden = false; + } + + + } diff --git a/src/com/android/tv/experiments/Experiments.java b/src/com/android/tv/experiments/Experiments.java index f16c8d1e..53cce979 100644 --- a/src/com/android/tv/experiments/Experiments.java +++ b/src/com/android/tv/experiments/Experiments.java @@ -23,12 +23,15 @@ import com.android.tv.common.BuildConfig; /** * Set of experiments visible in AOSP. * - * <p> - * This file is maintained by hand. + * <p>This file is maintained by hand. */ public final class Experiments { public static final ExperimentFlag<Boolean> CLOUD_EPG = createFlag( - false); + true); + + public static final ExperimentFlag<Boolean> ENABLE_UNRATED_CONTENT_SETTINGS = + createFlag( + false); /** * Allow developer features such as the dev menu and other aids. diff --git a/src/com/android/tv/guide/GenreListAdapter.java b/src/com/android/tv/guide/GenreListAdapter.java index 2913599c..ce19eb2d 100644 --- a/src/com/android/tv/guide/GenreListAdapter.java +++ b/src/com/android/tv/guide/GenreListAdapter.java @@ -17,6 +17,7 @@ package com.android.tv.guide; import android.content.Context; +import android.support.annotation.MainThread; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.view.LayoutInflater; @@ -32,7 +33,7 @@ import java.util.List; /** * Adapts the genre items obtained from {@link GenreItems} to the program guide side panel. */ -public class GenreListAdapter extends RecyclerView.Adapter<GenreListAdapter.GenreRowHolder> { +class GenreListAdapter extends RecyclerView.Adapter<GenreListAdapter.GenreRowHolder> { private static final String TAG = "GenreListAdapter"; private static final boolean DEBUG = false; @@ -41,7 +42,7 @@ public class GenreListAdapter extends RecyclerView.Adapter<GenreListAdapter.Genr private final ProgramGuide mProgramGuide; private String[] mGenreLabels; - public GenreListAdapter(Context context, ProgramManager programManager, ProgramGuide guide) { + GenreListAdapter(Context context, ProgramManager programManager, ProgramGuide guide) { mContext = context; mProgramManager = programManager; mProgramManager.addListener(new ProgramManager.ListenerAdapter() { @@ -79,16 +80,28 @@ public class GenreListAdapter extends RecyclerView.Adapter<GenreListAdapter.Genr @Override public GenreRowHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); + itemView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View view) { + // Animation is not meaningful now, skip it. + view.getStateListAnimator().jumpToCurrentState(); + } + + @Override + public void onViewDetachedFromWindow(View view) { + // Do nothing + } + }); return new GenreRowHolder(itemView, mProgramGuide); } - public static class GenreRowHolder extends RecyclerView.ViewHolder implements + static class GenreRowHolder extends RecyclerView.ViewHolder implements View.OnFocusChangeListener { private final ProgramGuide mProgramGuide; private int mGenreId; - // Should be called from main thread. - public GenreRowHolder(View itemView, ProgramGuide programGuide) { + @MainThread + GenreRowHolder(View itemView, ProgramGuide programGuide) { super(itemView); mProgramGuide = programGuide; } diff --git a/src/com/android/tv/guide/GuideUtils.java b/src/com/android/tv/guide/GuideUtils.java index 5d11f061..403d00b5 100644 --- a/src/com/android/tv/guide/GuideUtils.java +++ b/src/com/android/tv/guide/GuideUtils.java @@ -16,30 +16,38 @@ package com.android.tv.guide; +import android.graphics.Rect; +import android.support.annotation.NonNull; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import java.util.ArrayList; import java.util.concurrent.TimeUnit; -public class GuideUtils { +class GuideUtils { + private static final int INVALID_INDEX = -1; private static int sWidthPerHour = 0; /** * Sets the width in pixels that corresponds to an hour in program guide. * Assume that this is called from main thread only, so, no synchronization. */ - public static void setWidthPerHour(int widthPerHour) { + static void setWidthPerHour(int widthPerHour) { sWidthPerHour = widthPerHour; } /** * Gets the number of pixels in program guide table that corresponds to the given milliseconds. */ - public static int convertMillisToPixel(long millis) { + static int convertMillisToPixel(long millis) { return (int) (millis * sWidthPerHour / TimeUnit.HOURS.toMillis(1)); } /** * Gets the number of pixels in program guide table that corresponds to the given range. */ - public static int convertMillisToPixel(long startMillis, long endMillis) { + static int convertMillisToPixel(long startMillis, long endMillis) { // Convert to pixels first to avoid accumulation of rounding errors. return GuideUtils.convertMillisToPixel(endMillis) - GuideUtils.convertMillisToPixel(startMillis); @@ -48,9 +56,101 @@ public class GuideUtils { /** * Gets the time in millis that corresponds to the given pixels in the program guide. */ - public static long convertPixelToMillis(int pixel) { + static long convertPixelToMillis(int pixel) { return pixel * TimeUnit.HOURS.toMillis(1) / sWidthPerHour; } + /** + * Return the view should be focused in the given program row according to the focus range. + + * @param keepCurrentProgramFocused If {@code true}, focuses on the current program if possible, + * else falls back the general logic. + */ + static View findNextFocusedProgram(View programRow, int focusRangeLeft, + int focusRangeRight, boolean keepCurrentProgramFocused) { + ArrayList<View> focusables = new ArrayList<>(); + findFocusables(programRow, focusables); + + if (keepCurrentProgramFocused) { + // Select the current program if possible. + for (int i = 0; i < focusables.size(); ++i) { + View focusable = focusables.get(i); + if (focusable instanceof ProgramItemView + && isCurrentProgram((ProgramItemView) focusable)) { + return focusable; + } + } + } + + // Find the largest focusable among fully overlapped focusables. + int maxFullyOverlappedWidth = Integer.MIN_VALUE; + int maxPartiallyOverlappedWidth = Integer.MIN_VALUE; + int nextFocusIndex = INVALID_INDEX; + for (int i = 0; i < focusables.size(); ++i) { + View focusable = focusables.get(i); + Rect focusableRect = new Rect(); + focusable.getGlobalVisibleRect(focusableRect); + if (focusableRect.left <= focusRangeLeft && focusRangeRight <= focusableRect.right) { + // the old focused range is fully inside the focusable, return directly. + return focusable; + } else if (focusRangeLeft <= focusableRect.left + && focusableRect.right <= focusRangeRight) { + // the focusable is fully inside the old focused range, choose the widest one. + int width = focusableRect.width(); + if (width > maxFullyOverlappedWidth) { + nextFocusIndex = i; + maxFullyOverlappedWidth = width; + } + } else if (maxFullyOverlappedWidth == Integer.MIN_VALUE) { + int overlappedWidth = (focusRangeLeft <= focusableRect.left) ? + focusRangeRight - focusableRect.left + : focusableRect.right - focusRangeLeft; + if (overlappedWidth > maxPartiallyOverlappedWidth) { + nextFocusIndex = i; + maxPartiallyOverlappedWidth = overlappedWidth; + } + } + } + if (nextFocusIndex != INVALID_INDEX) { + return focusables.get(nextFocusIndex); + } + return null; + } + + /** + * Returns {@code true} if the program displayed in the give + * {@link com.android.tv.guide.ProgramItemView} is a current program. + */ + static boolean isCurrentProgram(ProgramItemView view) { + return view.getTableEntry().isCurrentProgram(); + } + + /** + * Returns {@code true} if the given view is a descendant of the give container. + */ + static boolean isDescendant(ViewGroup container, View view) { + if (view == null) { + return false; + } + for (ViewParent p = view.getParent(); p != null; p = p.getParent()) { + if (p == container) { + return true; + } + } + return false; + } + + private static void findFocusables(View v, ArrayList<View> outFocusable) { + if (v.isFocusable()) { + outFocusable.add(v); + } + if (v instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) v; + for (int i = 0; i < viewGroup.getChildCount(); ++i) { + findFocusables(viewGroup.getChildAt(i), outFocusable); + } + } + } + private GuideUtils() { } } diff --git a/src/com/android/tv/guide/ProgramGrid.java b/src/com/android/tv/guide/ProgramGrid.java index 77de5827..58436425 100644 --- a/src/com/android/tv/guide/ProgramGrid.java +++ b/src/com/android/tv/guide/ProgramGrid.java @@ -20,17 +20,15 @@ import android.content.Context; import android.content.res.Resources; import android.graphics.Rect; import android.support.v17.leanback.widget.VerticalGridView; -import android.support.v7.widget.RecyclerView.LayoutManager; import android.util.AttributeSet; import android.util.Log; +import android.util.Range; import android.view.View; -import android.view.ViewGroup; import android.view.ViewTreeObserver; import com.android.tv.R; import com.android.tv.ui.OnRepeatedKeyInterceptListener; -import java.util.ArrayList; import java.util.concurrent.TimeUnit; /** @@ -52,7 +50,7 @@ public class ProgramGrid extends VerticalGridView { clearUpDownFocusState(newFocus); } mNextFocusByUpDown = null; - if (newFocus != ProgramGrid.this && contains(newFocus)) { + if (GuideUtils.isDescendant(ProgramGrid.this, newFocus)) { mLastFocusedView = newFocus; } } @@ -90,8 +88,9 @@ public class ProgramGrid extends VerticalGridView { private View mLastFocusedView; private final Rect mTempRect = new Rect(); + private int mLastUpDownDirection; - private boolean mKeepCurrentProgram; + private boolean mKeepCurrentProgramFocused; private ChildFocusListener mChildFocusListener; private final OnRepeatedKeyInterceptListener mOnRepeatedKeyInterceptListener; @@ -132,21 +131,6 @@ public class ProgramGrid extends VerticalGridView { setOnKeyInterceptListener(mOnRepeatedKeyInterceptListener); } - /** - * Initializes ProgramGrid. It should be called before the view is actually attached to - * Window. - */ - public void initialize(ProgramManager programManager) { - mProgramManager = programManager; - } - - /** - * Registers a listener focus events occurring on children to the {@code ProgramGrid}. - */ - public void setChildFocusListener(ChildFocusListener childFocusListener) { - mChildFocusListener = childFocusListener; - } - @Override public void requestChildFocus(View child, View focused) { if (mChildFocusListener != null) { @@ -173,11 +157,11 @@ public class ProgramGrid extends VerticalGridView { @Override public View focusSearch(View focused, int direction) { mNextFocusByUpDown = null; - if (focused == null || !contains(focused)) { + if (focused == null || (focused != this && !GuideUtils.isDescendant(this, focused))) { return super.focusSearch(focused, direction); } if (direction == View.FOCUS_UP || direction == View.FOCUS_DOWN) { - updateUpDownFocusState(focused); + updateUpDownFocusState(focused, direction); View nextFocus = focusFind(focused, direction); if (nextFocus != null) { return nextFocus; @@ -186,15 +170,85 @@ public class ProgramGrid extends VerticalGridView { return super.focusSearch(focused, direction); } + @Override + public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { + if (mLastFocusedView != null && mLastFocusedView.isShown()) { + if (mLastFocusedView.requestFocus()) { + return true; + } + } + return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + // It is required to properly handle OnRepeatedKeyInterceptListener. If the focused + // item's are at the almost end of screen, focus change to the next item doesn't work. + // It restricts that a focus item's position cannot be too far from the desired position. + View focusedView = findFocus(); + if (focusedView != null && mOnRepeatedKeyInterceptListener.isFocusAccelerated()) { + int[] location = new int[2]; + getLocationOnScreen(location); + int[] focusedLocation = new int[2]; + focusedView.getLocationOnScreen(focusedLocation); + int y = focusedLocation[1] - location[1]; + int minY = (mSelectionRow - 1) * mRowHeight; + if (y < minY) scrollBy(0, y - minY); + int maxY = (mSelectionRow + 1) * mRowHeight + mDetailHeight; + if (y > maxY) scrollBy(0, y - maxY); + } + updateInputLogo(); + } + + @Override + public void onViewRemoved(View view) { + // It is required to ensure input logo showing when the scroll is moved to most bottom. + updateInputLogo(); + } + + /** + * Initializes ProgramGrid. It should be called before the view is actually attached to + * Window. + */ + void initialize(ProgramManager programManager) { + mProgramManager = programManager; + } + + /** + * Registers a listener focus events occurring on children to the {@code ProgramGrid}. + */ + void setChildFocusListener(ChildFocusListener childFocusListener) { + mChildFocusListener = childFocusListener; + } + + void onItemSelectionReset() { + getViewTreeObserver().addOnPreDrawListener(mPreDrawListener); + } + /** * Resets focus states. If the logic to keep the last focus needs to be cleared, it should * be called. */ - public void resetFocusState() { + void resetFocusState() { mLastFocusedView = null; clearUpDownFocusState(null); } + /** Returns the currently focused item's horizontal range. */ + Range<Integer> getFocusRange() { + return new Range<>(mFocusRangeLeft, mFocusRangeRight); + } + + /** Returns if the next focused item should be the current program if possible. */ + boolean isKeepCurrentProgramFocused() { + return mKeepCurrentProgramFocused; + } + + /** Returns the last up/down move direction of browsing */ + int getLastUpDownDirection() { + return mLastUpDownDirection; + } + private View focusFind(View focused, int direction) { int focusedChildIndex = getFocusedChildIndex(); if (focusedChildIndex == INVALID_INDEX) { @@ -204,85 +258,26 @@ public class ProgramGrid extends VerticalGridView { int nextChildIndex = direction == View.FOCUS_UP ? focusedChildIndex - 1 : focusedChildIndex + 1; if (nextChildIndex < 0 || nextChildIndex >= getChildCount()) { - return focused; - } - View nextChild = getChildAt(nextChildIndex); - ArrayList<View> focusables = new ArrayList<>(); - findFocusables(nextChild, focusables); - - int index = INVALID_INDEX; - if (mKeepCurrentProgram) { - // Select the current program if possible. - for (int i = 0; i < focusables.size(); ++i) { - View focusable = focusables.get(i); - if (!(focusable instanceof ProgramItemView)) { - continue; - } - if (((ProgramItemView) focusable).getTableEntry().isCurrentProgram()) { - index = i; - break; - } - } - if (index != INVALID_INDEX) { - mNextFocusByUpDown = focusables.get(index); - return mNextFocusByUpDown; - } else { - mKeepCurrentProgram = false; - } - } - - // Find the largest focusable among fully overlapped focusables. - int maxWidth = Integer.MIN_VALUE; - for (int i = 0; i < focusables.size(); ++i) { - View focusable = focusables.get(i); - Rect focusableRect = mTempRect; - focusable.getGlobalVisibleRect(focusableRect); - if (mFocusRangeLeft <= focusableRect.left && focusableRect.right <= mFocusRangeRight) { - int width = focusableRect.width(); - if (width > maxWidth) { - index = i; - maxWidth = width; - } - } else if (focusableRect.left <= mFocusRangeLeft - && mFocusRangeRight <= focusableRect.right) { - // focusableRect contains [mLeft, mRight]. - index = i; - break; - } - } - if (index != INVALID_INDEX) { - mNextFocusByUpDown = focusables.get(index); - return mNextFocusByUpDown; - } - - // Find the largest overlapped view among partially overlapped focusables. - maxWidth = Integer.MIN_VALUE; - for (int i = 0; i < focusables.size(); ++i) { - View focusable = focusables.get(i); - Rect focusableRect = mTempRect; - focusable.getGlobalVisibleRect(focusableRect); - if (mFocusRangeLeft <= focusableRect.left && focusableRect.left <= mFocusRangeRight) { - int overlappedWidth = mFocusRangeRight - focusableRect.left; - if (overlappedWidth > maxWidth) { - index = i; - maxWidth = overlappedWidth; - } - } else if (mFocusRangeLeft <= focusableRect.right - && focusableRect.right <= mFocusRangeRight) { - int overlappedWidth = focusableRect.right - mFocusRangeLeft; - if (overlappedWidth > maxWidth) { - index = i; - maxWidth = overlappedWidth; - } + // Wraparound if reached head or end + if (getSelectedPosition() == 0) { + scrollToPosition(getAdapter().getItemCount() - 1); + return null; + } else if (getSelectedPosition() == getAdapter().getItemCount() - 1) { + scrollToPosition(0); + return null; } + return focused; } - if (index != INVALID_INDEX) { - mNextFocusByUpDown = focusables.get(index); - return mNextFocusByUpDown; + View nextFocusedProgram = GuideUtils.findNextFocusedProgram(getChildAt(nextChildIndex), + mFocusRangeLeft, mFocusRangeRight, mKeepCurrentProgramFocused); + if (nextFocusedProgram != null) { + nextFocusedProgram.getGlobalVisibleRect(mTempRect); + mNextFocusByUpDown = nextFocusedProgram; + + } else { + Log.w(TAG, "focusFind doesn't find proper focusable"); } - - Log.w(TAG, "focusFind doesn't find proper focusable"); - return null; + return nextFocusedProgram; } // Returned value is not the position of VerticalGridView. But it's the index of ViewGroup @@ -296,7 +291,8 @@ public class ProgramGrid extends VerticalGridView { return INVALID_INDEX; } - private void updateUpDownFocusState(View focused) { + private void updateUpDownFocusState(View focused, int direction) { + mLastUpDownDirection = direction; int rightMostFocusablePosition = getRightMostFocusablePosition(); Rect focusedRect = mTempRect; @@ -319,11 +315,13 @@ public class ProgramGrid extends VerticalGridView { } private void clearUpDownFocusState(View focus) { + mLastUpDownDirection = 0; mFocusRangeLeft = 0; mFocusRangeRight = getRightMostFocusablePosition(); mNextFocusByUpDown = null; - mKeepCurrentProgram = focus != null && focus instanceof ProgramItemView - && ((ProgramItemView) focus).getTableEntry().isCurrentProgram(); + // If focus is not a program item, drop focus to the current program when back to the grid + mKeepCurrentProgramFocused = !(focus instanceof ProgramItemView) + || GuideUtils.isCurrentProgram((ProgramItemView) focus); } private int getRightMostFocusablePosition() { @@ -333,56 +331,6 @@ public class ProgramGrid extends VerticalGridView { return mTempRect.right - GuideUtils.convertMillisToPixel(FOCUS_AREA_RIGHT_MARGIN_MILLIS); } - private boolean contains(View v) { - if (v == this) { - return true; - } - if (v == null || v == v.getRootView()) { - return false; - } - return contains((View) v.getParent()); - } - - public void onItemSelectionReset() { - getViewTreeObserver().addOnPreDrawListener(mPreDrawListener); - } - - @Override - public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { - if (mLastFocusedView != null && mLastFocusedView.isShown()) { - if (mLastFocusedView.requestFocus()) { - return true; - } - } - return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); - } - - @Override - protected void onScrollChanged(int l, int t, int oldl, int oldt) { - // It is required to properly handle OnRepeatedKeyInterceptListener. If the focused - // item's are at the almost end of screen, focus change to the next item doesn't work. - // It restricts that a focus item's position cannot be too far from the desired position. - View focusedView = findFocus(); - if (focusedView != null && mOnRepeatedKeyInterceptListener.isFocusAccelerated()) { - int[] location = new int[2]; - getLocationOnScreen(location); - int[] focusedLocation = new int[2]; - focusedView.getLocationOnScreen(focusedLocation); - int y = focusedLocation[1] - location[1]; - int minY = (mSelectionRow - 1) * mRowHeight; - if (y < minY) scrollBy(0, y - minY); - int maxY = (mSelectionRow + 1) * mRowHeight + mDetailHeight; - if (y > maxY) scrollBy(0, y - maxY); - } - updateInputLogo(); - } - - @Override - public void onViewRemoved(View view) { - // It is required to ensure input logo showing when the scroll is moved to most bottom. - updateInputLogo(); - } - private int getFirstVisibleChildIndex() { final LayoutManager mLayoutManager = getLayoutManager(); int top = mLayoutManager.getPaddingTop(); @@ -398,7 +346,7 @@ public class ProgramGrid extends VerticalGridView { return -1; } - public void updateInputLogo() { + private void updateInputLogo() { int childCount = getChildCount(); if (childCount == 0) { return; @@ -409,25 +357,13 @@ public class ProgramGrid extends VerticalGridView { } View childView = getChildAt(firstVisibleChildIndex); int childAdapterPosition = getChildAdapterPosition(childView); - ((ProgramTableAdapter.ProgramRowHolder) getChildViewHolder(childView)) + ((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView)) .updateInputLogo(childAdapterPosition, true); for (int i = firstVisibleChildIndex + 1; i < childCount; i++) { childView = getChildAt(i); - ((ProgramTableAdapter.ProgramRowHolder) getChildViewHolder(childView)) + ((ProgramTableAdapter.ProgramRowViewHolder) getChildViewHolder(childView)) .updateInputLogo(childAdapterPosition, false); childAdapterPosition = getChildAdapterPosition(childView); } } - - private static void findFocusables(View v, ArrayList<View> outFocusable) { - if (v.isFocusable()) { - outFocusable.add(v); - } - if (v instanceof ViewGroup) { - ViewGroup viewGroup = (ViewGroup) v; - for (int i = 0; i < viewGroup.getChildCount(); ++i) { - findFocusables(viewGroup.getChildAt(i), outFocusable); - } - } - } } diff --git a/src/com/android/tv/guide/ProgramGuide.java b/src/com/android/tv/guide/ProgramGuide.java index 120b3dba..dd5444e2 100644 --- a/src/com/android/tv/guide/ProgramGuide.java +++ b/src/com/android/tv/guide/ProgramGuide.java @@ -48,7 +48,7 @@ import com.android.tv.ChannelTuner; import com.android.tv.Features; import com.android.tv.MainActivity; import com.android.tv.R; -import com.android.tv.analytics.DurationTimer; +import com.android.tv.util.DurationTimer; import com.android.tv.analytics.Tracker; import com.android.tv.common.WeakHandler; import com.android.tv.data.ChannelDataManager; @@ -143,6 +143,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { private int mLastRequestedGenreId = GenreItems.ID_ALL_CHANNELS; private boolean mIsDuringResetRowSelection; private final Handler mHandler = new ProgramGuideHandler(this); + private boolean mActive; private final Runnable mHideRunnable = new Runnable() { @Override @@ -217,7 +218,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { .getDimensionPixelOffset(R.dimen.program_guide_side_panel_alignment_y)); mSidePanelGridView.setWindowAlignmentOffsetPercent( VerticalGridView.WINDOW_ALIGN_OFFSET_PERCENT_DISABLED); - // TODO: Remove this check when we ship TV with epg search enabled. + if (Features.EPG_SEARCH.isEnabled(mActivity)) { mSearchOrb = (SearchOrbView) mContainer.findViewById( R.id.program_guide_side_panel_search_orb); @@ -250,8 +251,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { res.getInteger(R.integer.max_recycled_view_pool_epg_header_row_item)); mTimelineRow.setAdapter(mTimeListAdapter); - ProgramTableAdapter programTableAdapter = new ProgramTableAdapter(mActivity, - mProgramManager, this); + ProgramTableAdapter programTableAdapter = new ProgramTableAdapter(mActivity, this); programTableAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { @Override public void onChanged() { @@ -304,13 +304,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { R.animator.program_guide_side_panel_enter_full, 0, R.animator.program_guide_table_enter_full); - mShowAnimatorFull.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - ((ViewGroup) mSidePanel).setDescendantFocusability( - ViewGroup.FOCUS_AFTER_DESCENDANTS); - } - }); mShowAnimatorPartial = createAnimator( R.animator.program_guide_side_panel_enter_partial, @@ -383,34 +376,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { || mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true); } - private void updateGuidePosition() { - // Align EPG at vertical center, if EPG table height is less than the screen size. - Resources res = mActivity.getResources(); - int screenHeight = mContainer.getHeight(); - if (screenHeight <= 0) { - // mContainer is not initialized yet. - return; - } - int startPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_start); - int topPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_top); - int bottomPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_bottom); - int tableHeight = res.getDimensionPixelOffset(R.dimen.program_guide_table_header_row_height) - + mDetailHeight + mRowHeight * mGrid.getAdapter().getItemCount() + topPadding - + bottomPadding; - if (tableHeight > screenHeight) { - // EPG height is longer that the screen height. - mTable.setPaddingRelative(startPadding, topPadding, 0, 0); - LayoutParams layoutParams = mTable.getLayoutParams(); - layoutParams.height = LayoutParams.WRAP_CONTENT; - mTable.setLayoutParams(layoutParams); - } else { - mTable.setPaddingRelative(startPadding, topPadding, 0, bottomPadding); - LayoutParams layoutParams = mTable.getLayoutParams(); - layoutParams.height = tableHeight; - mTable.setLayoutParams(layoutParams); - } - } - @Override public void onRequestChildFocus(View oldFocus, View newFocus) { if (oldFocus != null && newFocus != null) { @@ -431,40 +396,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } } - private Animator createAnimator(int sidePanelAnimResId, int sidePanelGridAnimResId, - int tableAnimResId) { - List<Animator> animatorList = new ArrayList<>(); - - Animator sidePanelAnimator = AnimatorInflater.loadAnimator(mActivity, sidePanelAnimResId); - sidePanelAnimator.setTarget(mSidePanel); - animatorList.add(sidePanelAnimator); - - if (sidePanelGridAnimResId != 0) { - Animator sidePanelGridAnimator = AnimatorInflater.loadAnimator(mActivity, - sidePanelGridAnimResId); - sidePanelGridAnimator.setTarget(mSidePanelGridView); - sidePanelGridAnimator.addListener( - new HardwareLayerAnimatorListenerAdapter(mSidePanelGridView)); - animatorList.add(sidePanelGridAnimator); - } - Animator tableAnimator = AnimatorInflater.loadAnimator(mActivity, tableAnimResId); - tableAnimator.setTarget(mTable); - tableAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable)); - animatorList.add(tableAnimator); - - AnimatorSet set = new AnimatorSet(); - set.playTogether(animatorList); - return set; - } - - /** - * Returns {@code true} if the program guide should process the input events. - */ - public boolean isActive() { - return mContainer.getVisibility() == View.VISIBLE && !mHideAnimatorFull.isStarted() - && !mHideAnimatorPartial.isStarted(); - } - /** * Show the program guide. This reveals the side panel, and the program guide table is shown * partially. @@ -494,14 +425,11 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { mTimeListAdapter.update(mStartUtcTime); mTimelineRow.resetScroll(); + mContainer.setVisibility(View.VISIBLE); + mActive = true; if (!mShowGuidePartial) { - // Avoid changing focus from the genre side panel to the grid during animation. - // The descendant focus is changed to FOCUS_AFTER_DESCENDANTS after the animation. - ((ViewGroup) mSidePanel).setDescendantFocusability( - ViewGroup.FOCUS_BLOCK_DESCENDANTS); + mTable.requestFocus(); } - - mContainer.setVisibility(View.VISIBLE); positionCurrentTimeIndicator(); mSidePanelGridView.setSelectedPosition(0); if (DEBUG) { @@ -536,13 +464,13 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } }); } + updateGuidePosition(); runnableAfterAnimatorReady.run(); if (mShowGuidePartial) { mShowAnimatorPartial.start(); } else { mShowAnimatorFull.start(); } - updateGuidePosition(); } }; mContainer.getViewTreeObserver().addOnGlobalLayoutListener(mOnLayoutListenerForShow); @@ -564,7 +492,8 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { cancelHide(); mProgramManager.programGuideVisibilityChanged(false); mProgramManager.removeListener(mProgramManagerListener); - if (isFull()) { + mActive = false; + if (!mShowGuidePartial) { mHideAnimatorFull.start(); } else { mHideAnimatorPartial.start(); @@ -587,50 +516,21 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } } + /** + * Schedules hiding the program guide. + */ public void scheduleHide() { cancelHide(); mHandler.postDelayed(mHideRunnable, mShowDurationMillis); } /** - * Returns the scroll offset of the time line row in pixels. - */ - public int getTimelineRowScrollOffset() { - return mTimelineRow.getScrollOffset(); - } - - /** - * Cancel hiding the program guide. + * Cancels hiding the program guide. */ public void cancelHide() { mHandler.removeCallbacks(mHideRunnable); } - // Returns if program table is full screen mode. - private boolean isFull() { - return mPartialToFullAnimator.isStarted() || mTable.getTranslationX() == 0; - } - - private void startFull() { - 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; - mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply(); - mPartialToFullAnimator.start(); - } - - private void startPartial() { - if (!isFull()) { - return; - } - mShowGuidePartial = true; - mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply(); - mFullToPartialAnimator.start(); - } - /** * Process the {@code KEYCODE_BACK} key event. */ @@ -639,16 +539,30 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } /** - * Gets {@link VerticalGridView} for "genre select" side panel. + * Returns {@code true} if the program guide should process the input events. */ - public VerticalGridView getSidePanel() { - return mSidePanelGridView; + public boolean isActive() { + return mActive; + } + + /** + * Returns {@code true} if the program guide is shown, i.e. showing animation is done and + * hiding animation is not started yet. + */ + public boolean isRunningAnimation() { + return mShowAnimatorPartial.isStarted() || mShowAnimatorFull.isStarted() + || mHideAnimatorPartial.isStarted() || mHideAnimatorFull.isStarted(); + } + + /** Returns if program table is in full screen mode. **/ + boolean isFull() { + return !mShowGuidePartial; } /** * Requests change genre to {@code genreId}. */ - public void requestGenreChange(int genreId) { + void requestGenreChange(int genreId) { if (mLastRequestedGenreId == genreId) { // When Recycler.onLayout() removes its children to recycle, // View tries to find next focus candidate immediately @@ -679,6 +593,104 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { mProgramTableFadeOutAnimator.start(); } + /** + * Returns the scroll offset of the time line row in pixels. + */ + int getTimelineRowScrollOffset() { + return mTimelineRow.getScrollOffset(); + } + + /** Returns the program grid view that hold all component views. */ + ProgramGrid getProgramGrid() { + return mGrid; + } + + /** + * Gets {@link VerticalGridView} for "genre select" side panel. + */ + VerticalGridView getSidePanel() { + return mSidePanelGridView; + } + + /** Returns the program manager the program guide is using to provide program information. */ + ProgramManager getProgramManager() { + return mProgramManager; + } + + private void updateGuidePosition() { + // Align EPG at vertical center, if EPG table height is less than the screen size. + Resources res = mActivity.getResources(); + int screenHeight = mContainer.getHeight(); + if (screenHeight <= 0) { + // mContainer is not initialized yet. + return; + } + int startPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_start); + int topPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_top); + int bottomPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_margin_bottom); + int tableHeight = res.getDimensionPixelOffset(R.dimen.program_guide_table_header_row_height) + + mDetailHeight + mRowHeight * mGrid.getAdapter().getItemCount() + topPadding + + bottomPadding; + if (tableHeight > screenHeight) { + // EPG height is longer that the screen height. + mTable.setPaddingRelative(startPadding, topPadding, 0, 0); + LayoutParams layoutParams = mTable.getLayoutParams(); + layoutParams.height = LayoutParams.WRAP_CONTENT; + mTable.setLayoutParams(layoutParams); + } else { + mTable.setPaddingRelative(startPadding, topPadding, 0, bottomPadding); + LayoutParams layoutParams = mTable.getLayoutParams(); + layoutParams.height = tableHeight; + mTable.setLayoutParams(layoutParams); + } + } + + private Animator createAnimator(int sidePanelAnimResId, int sidePanelGridAnimResId, + int tableAnimResId) { + List<Animator> animatorList = new ArrayList<>(); + + Animator sidePanelAnimator = AnimatorInflater.loadAnimator(mActivity, sidePanelAnimResId); + sidePanelAnimator.setTarget(mSidePanel); + animatorList.add(sidePanelAnimator); + + if (sidePanelGridAnimResId != 0) { + Animator sidePanelGridAnimator = AnimatorInflater.loadAnimator(mActivity, + sidePanelGridAnimResId); + sidePanelGridAnimator.setTarget(mSidePanelGridView); + sidePanelGridAnimator.addListener( + new HardwareLayerAnimatorListenerAdapter(mSidePanelGridView)); + animatorList.add(sidePanelGridAnimator); + } + Animator tableAnimator = AnimatorInflater.loadAnimator(mActivity, tableAnimResId); + tableAnimator.setTarget(mTable); + tableAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable)); + animatorList.add(tableAnimator); + + AnimatorSet set = new AnimatorSet(); + set.playTogether(animatorList); + return set; + } + + private void startFull() { + if (!mShowGuidePartial || 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; + mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply(); + mPartialToFullAnimator.start(); + } + + private void startPartial() { + if (mShowGuidePartial) { + return; + } + mShowGuidePartial = true; + mSharedPreference.edit().putBoolean(KEY_SHOW_GUIDE_PARTIAL, mShowGuidePartial).apply(); + mFullToPartialAnimator.start(); + } + private void startCurrentTimeIndicator(long initialDelay) { mHandler.postDelayed(mUpdateTimeIndicator, initialDelay); } @@ -775,10 +787,12 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { mDetailInAnimator.cancel(); } - int direction = 0; - if (outRow != null && inRow != null) { - // -1 means the selection goes downwards and 1 goes upwards - direction = outRow.getTop() < inRow.getTop() ? -1 : 1; + int operationDirection = mGrid.getLastUpDownDirection(); + int animationPadding = 0; + if (operationDirection == View.FOCUS_UP) { + animationPadding = mDetailPadding; + } else if (operationDirection == View.FOCUS_DOWN) { + animationPadding = -mDetailPadding; } View outDetail = outRow != null ? outRow.findViewById(R.id.detail) : null; @@ -788,7 +802,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { Animator fadeOutAnimator = ObjectAnimator.ofPropertyValuesHolder(outDetailContent, PropertyValuesHolder.ofFloat(View.ALPHA, outDetail.getAlpha(), 0f), PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, - outDetailContent.getTranslationY(), direction * mDetailPadding)); + outDetailContent.getTranslationY(), animationPadding)); fadeOutAnimator.setStartDelay(0); fadeOutAnimator.setDuration(mAnimationDuration); fadeOutAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(outDetailContent)); @@ -842,8 +856,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { }); Animator fadeInAnimator = ObjectAnimator.ofPropertyValuesHolder(inDetailContent, PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f), - PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, - direction * -mDetailPadding, 0f)); + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -animationPadding, 0f)); fadeInAnimator.setDuration(mAnimationDuration); fadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(inDetailContent)); @@ -910,7 +923,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener { } private static class ProgramGuideHandler extends WeakHandler<ProgramGuide> { - public ProgramGuideHandler(ProgramGuide ref) { + ProgramGuideHandler(ProgramGuide ref) { super(ref); } diff --git a/src/com/android/tv/guide/ProgramItemView.java b/src/com/android/tv/guide/ProgramItemView.java index 4c7a4404..b23d578c 100644 --- a/src/com/android/tv/guide/ProgramItemView.java +++ b/src/com/android/tv/guide/ProgramItemView.java @@ -44,8 +44,8 @@ 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.DvrUiHelper; -import com.android.tv.dvr.ScheduledRecording; +import com.android.tv.dvr.data.ScheduledRecording; +import com.android.tv.dvr.ui.DvrUiHelper; import com.android.tv.guide.ProgramManager.TableEntry; import com.android.tv.util.ToastUtils; import com.android.tv.util.Utils; @@ -73,6 +73,7 @@ public class ProgramItemView extends TextView { private static TextAppearanceSpan sEpisodeTitleStyle; private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle; + private ProgramGuide mProgramGuide; private DvrManager mDvrManager; private TableEntry mTableEntry; private int mMaxWidthForRipple; @@ -106,18 +107,19 @@ 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())) { + } else if (entry.program != null && CommonFeatures.DVR.isEnabled(view.getContext())) { DvrManager dvrManager = singletons.getDvrManager(); if (entry.entryStartUtcMillis > System.currentTimeMillis() && dvrManager.isProgramRecordable(entry.program)) { if (entry.scheduledRecording == null) { - 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); - } + DvrUiHelper.checkStorageStatusAndShowErrorMessage(tvActivity, + channel.getInputId(), new Runnable() { + @Override + public void run() { + DvrUiHelper.requestRecordingFutureProgram(tvActivity, + entry.program, false); + } + }); } else { dvrManager.removeScheduledRecording(entry.scheduledRecording); String msg = view.getResources().getString( @@ -158,6 +160,11 @@ public class ProgramItemView extends TextView { } if (entry.isCurrentProgram()) { Drawable background = getBackground(); + if (!mProgramGuide.isActive() || mProgramGuide.isRunningAnimation()) { + // If program guide is not active or is during showing/hiding, + // the animation is unnecessary, skip it. + background.jumpToCurrentState(); + } int progress = getProgress(entry.entryStartUtcMillis, entry.entryEndUtcMillis); setProgress(background, R.id.reverse_progress, MAX_PROGRESS - progress); } @@ -247,8 +254,9 @@ public class ProgramItemView extends TextView { } @SuppressLint("SwitchIntDef") - public void setValues(TableEntry entry, int selectedGenreId, long fromUtcMillis, - long toUtcMillis, String gapTitle) { + public void setValues(ProgramGuide programGuide, TableEntry entry, int selectedGenreId, + long fromUtcMillis, long toUtcMillis, String gapTitle) { + mProgramGuide = programGuide; mTableEntry = entry; ViewGroup.LayoutParams layoutParams = getLayoutParams(); @@ -376,6 +384,7 @@ public class ProgramItemView extends TextView { } setTag(null); + mProgramGuide = null; mTableEntry = null; } diff --git a/src/com/android/tv/guide/ProgramListAdapter.java b/src/com/android/tv/guide/ProgramListAdapter.java index 03aea5ad..c1fcdd40 100644 --- a/src/com/android/tv/guide/ProgramListAdapter.java +++ b/src/com/android/tv/guide/ProgramListAdapter.java @@ -32,11 +32,12 @@ import com.android.tv.guide.ProgramManager.TableEntry; * Adapts a program list for a specific channel from {@link ProgramManager} to a row of the program * guide table. */ -public class ProgramListAdapter extends RecyclerView.Adapter<ProgramListAdapter.ProgramViewHolder> +class ProgramListAdapter extends RecyclerView.Adapter<ProgramListAdapter.ProgramItemViewHolder> implements TableEntriesUpdatedListener { private static final String TAG = "ProgramListAdapter"; private static final boolean DEBUG = false; + private final ProgramGuide mProgramGuide; private final ProgramManager mProgramManager; private final int mChannelIndex; private final String mNoInfoProgramTitle; @@ -44,9 +45,10 @@ public class ProgramListAdapter extends RecyclerView.Adapter<ProgramListAdapter. private long mChannelId; - public ProgramListAdapter(Resources res, ProgramManager programManager, int channelIndex) { + ProgramListAdapter(Resources res, ProgramGuide programGuide, int channelIndex) { setHasStableIds(true); - mProgramManager = programManager; + mProgramGuide = programGuide; + mProgramManager = programGuide.getProgramManager(); mChannelIndex = channelIndex; mNoInfoProgramTitle = res.getString(R.string.program_title_for_no_information); mBlockedProgramTitle = res.getString(R.string.program_title_for_blocked_channel); @@ -65,10 +67,6 @@ public class ProgramListAdapter extends RecyclerView.Adapter<ProgramListAdapter. } } - public ProgramManager getProgramManager() { - return mProgramManager; - } - @Override public int getItemCount() { return mProgramManager.getTableEntryCount(mChannelId); @@ -85,38 +83,40 @@ public class ProgramListAdapter extends RecyclerView.Adapter<ProgramListAdapter. } @Override - public void onBindViewHolder(ProgramViewHolder holder, int position) { + public void onBindViewHolder(ProgramItemViewHolder holder, int position) { TableEntry tableEntry = mProgramManager.getTableEntry(mChannelId, position); String gapTitle = tableEntry.isBlocked() ? mBlockedProgramTitle : mNoInfoProgramTitle; - holder.onBind(tableEntry, this.getProgramManager(), gapTitle); + holder.onBind(tableEntry, mProgramGuide, gapTitle); } @Override - public void onViewRecycled(ProgramViewHolder holder) { + public void onViewRecycled(ProgramItemViewHolder holder) { holder.onUnbind(); } @Override - public ProgramViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + public ProgramItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); - return new ProgramViewHolder(itemView); + return new ProgramItemViewHolder(itemView); } - public static class ProgramViewHolder extends RecyclerView.ViewHolder { + static class ProgramItemViewHolder extends RecyclerView.ViewHolder { // Should be called from main thread. - public ProgramViewHolder(View itemView) { + ProgramItemViewHolder(View itemView) { super(itemView); } - public void onBind(TableEntry entry, ProgramManager programManager, String gapTitle) { + void onBind(TableEntry entry, ProgramGuide programGuide, String gapTitle) { if (DEBUG) { Log.d(TAG, "onBind. View = " + itemView + ", Entry = " + entry); } - ((ProgramItemView) itemView).setValues(entry, programManager.getSelectedGenreId(), - programManager.getFromUtcMillis(), programManager.getToUtcMillis(), gapTitle); + ProgramManager programManager = programGuide.getProgramManager(); + ((ProgramItemView) itemView).setValues(programGuide, entry, + programManager.getSelectedGenreId(), programManager.getFromUtcMillis(), + programManager.getToUtcMillis(), gapTitle); } - public void onUnbind() { + void onUnbind() { ((ProgramItemView) itemView).clearValues(); } } diff --git a/src/com/android/tv/guide/ProgramManager.java b/src/com/android/tv/guide/ProgramManager.java index e3d919df..4ec3f77e 100644 --- a/src/com/android/tv/guide/ProgramManager.java +++ b/src/com/android/tv/guide/ProgramManager.java @@ -29,7 +29,7 @@ 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.dvr.data.ScheduledRecording; import com.android.tv.util.TvInputManagerHelper; import com.android.tv.util.Utils; @@ -68,107 +68,6 @@ public class ProgramManager { private long mFromUtcMillis; private long mToUtcMillis; - /** - * Entry for program guide table. An "entry" can be either an actual program or a gap between - * programs. This is needed for {@link ProgramListAdapter} because - * {@link android.support.v17.leanback.widget.HorizontalGridView} ignores margins between items. - */ - public static class TableEntry { - /** Channel ID which this entry is included. */ - public final long channelId; - - /** Program corresponding to the entry. {@code null} means that this entry is a gap. */ - public final Program program; - - public final ScheduledRecording scheduledRecording; - - /** Start time of entry in UTC milliseconds. */ - public final long entryStartUtcMillis; - - /** End time of entry in UTC milliseconds */ - public final long entryEndUtcMillis; - - private final boolean mIsBlocked; - - private TableEntry(long channelId, long startUtcMillis, long endUtcMillis) { - this(channelId, null, startUtcMillis, endUtcMillis, false); - } - - private TableEntry(long channelId, long startUtcMillis, long endUtcMillis, - boolean blocked) { - this(channelId, null, null, startUtcMillis, endUtcMillis, blocked); - } - - private TableEntry(long channelId, Program program, long entryStartUtcMillis, - long entryEndUtcMillis, boolean isBlocked) { - this(channelId, program, null, entryStartUtcMillis, entryEndUtcMillis, isBlocked); - } - - private TableEntry(long channelId, Program program, ScheduledRecording scheduledRecording, - long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked) { - this.channelId = channelId; - this.program = program; - this.scheduledRecording = scheduledRecording; - this.entryStartUtcMillis = entryStartUtcMillis; - this.entryEndUtcMillis = entryEndUtcMillis; - mIsBlocked = isBlocked; - } - - /** - * A stable id useful for {@link android.support.v7.widget.RecyclerView.Adapter}. - */ - public long getId() { - // using a negative entryEndUtcMillis keeps it from conflicting with program Id - return program != null ? program.getId() : -entryEndUtcMillis; - } - - /** - * Returns true if this is a gap. - */ - public boolean isGap() { - return !Program.isValid(program); - } - - /** - * Returns true if this channel is blocked. - */ - public boolean isBlocked() { - return mIsBlocked; - } - - /** - * Returns true if this program is on the air. - */ - public boolean isCurrentProgram() { - long current = System.currentTimeMillis(); - return entryStartUtcMillis <= current && entryEndUtcMillis > current; - } - - /** - * Returns if this program has the genre. - */ - public boolean hasGenre(int genreId) { - return !isGap() && program.hasGenre(genreId); - } - - /** - * Returns the width of table entry, in pixels. - */ - public int getWidth() { - return GuideUtils.convertMillisToPixel(entryStartUtcMillis, entryEndUtcMillis); - } - - @Override - public String toString() { - return "TableEntry{" - + "hashCode=" + hashCode() - + ", channelId=" + channelId - + ", program=" + program - + ", startTime=" + Utils.toTimeString(entryStartUtcMillis) - + ", endTimeTime=" + Utils.toTimeString(entryEndUtcMillis) + "}"; - } - } - private List<Channel> mChannels = new ArrayList<>(); private final Map<Long, List<TableEntry>> mChannelIdEntriesMap = new HashMap<>(); private final List<List<Channel>> mGenreChannelList = new ArrayList<>(); @@ -293,7 +192,7 @@ public class ProgramManager { mDvrScheduleManager = dvrScheduleManager; } - public void programGuideVisibilityChanged(boolean visible) { + void programGuideVisibilityChanged(boolean visible) { mProgramDataManager.setPauseProgramUpdate(visible); if (visible) { mChannelDataManager.addListener(mChannelDataManagerListener); @@ -325,87 +224,51 @@ public class ProgramManager { /** * Adds a {@link Listener}. */ - public void addListener(Listener listener) { + void addListener(Listener listener) { mListeners.add(listener); } /** * Registers a listener to be invoked when table entries are updated. */ - public void addTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { + void addTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { mTableEntriesUpdatedListeners.add(listener); } /** * Registers a listener to be invoked when a table entry is changed. */ - public void addTableEntryChangedListener(TableEntryChangedListener listener) { + void addTableEntryChangedListener(TableEntryChangedListener listener) { mTableEntryChangedListeners.add(listener); } /** * Removes a {@link Listener}. */ - public void removeListener(Listener listener) { + void removeListener(Listener listener) { mListeners.remove(listener); } /** * Removes a previously installed table entries update listener. */ - public void removeTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { + void removeTableEntriesUpdatedListener(TableEntriesUpdatedListener listener) { mTableEntriesUpdatedListeners.remove(listener); } /** * Removes a previously installed table entry changed listener. */ - public void removeTableEntryChangedListener(TableEntryChangedListener listener) { + void removeTableEntryChangedListener(TableEntryChangedListener listener) { mTableEntryChangedListeners.remove(listener); } /** - * Build genre filters based on the current programs. - * This categories channels by its current program's canonical genres - * and subsequent @{link resetChannelListWithGenre(int)} calls will reset channel list - * with built channel list. - * This is expected to be called whenever program guide is shown. - */ - public void buildGenreFilters() { - if (DEBUG) Log.d(TAG, "buildGenreFilters"); - - mGenreChannelList.clear(); - for (int i = 0; i < GenreItems.getGenreCount(); i++) { - mGenreChannelList.add(new ArrayList<>()); - } - for (Channel channel : mChannels) { - // TODO: Use programs in visible area instead of using current programs only. - Program currentProgram = mProgramDataManager.getCurrentProgram(channel.getId()); - if (currentProgram != null && currentProgram.getCanonicalGenres() != null) { - for (String genre : currentProgram.getCanonicalGenres()) { - mGenreChannelList.get(GenreItems.getId(genre)).add(channel); - } - } - } - mGenreChannelList.set(GenreItems.ID_ALL_CHANNELS, mChannels); - mFilteredGenreIds.clear(); - mFilteredGenreIds.add(0); - for (int i = 1; i < GenreItems.getGenreCount(); i++) { - if (mGenreChannelList.get(i).size() > 0) { - mFilteredGenreIds.add(i); - } - } - mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; - mFilteredChannels = mChannels; - notifyGenresUpdated(); - } - - /** * Resets channel list with given genre. * Caller should call {@link #buildGenreFilters()} prior to call this API to make * This notifies channel updates to listeners. */ - public void resetChannelListWithGenre(int genreId) { + void resetChannelListWithGenre(int genreId) { if (genreId == mSelectedGenreId) { return; } @@ -422,13 +285,154 @@ public class ProgramManager { } /** + * Update the initial time range to manage. It updates program entries and genre as well. + */ + void updateInitialTimeRange(long startUtcMillis, long endUtcMillis) { + mStartUtcMillis = startUtcMillis; + if (endUtcMillis > mEndUtcMillis) { + mEndUtcMillis = endUtcMillis; + } + + mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis); + updateChannels(true); + setTimeRange(startUtcMillis, endUtcMillis); + } + + + /** + * Shifts the time range by the given time. Also makes ProgramGuide scroll the views. + */ + void shiftTime(long timeMillisToScroll) { + long fromUtcMillis = mFromUtcMillis + timeMillisToScroll; + long toUtcMillis = mToUtcMillis + timeMillisToScroll; + if (fromUtcMillis < mStartUtcMillis) { + fromUtcMillis = mStartUtcMillis; + toUtcMillis += mStartUtcMillis - fromUtcMillis; + } + if (toUtcMillis > mEndUtcMillis) { + fromUtcMillis -= toUtcMillis - mEndUtcMillis; + toUtcMillis = mEndUtcMillis; + } + setTimeRange(fromUtcMillis, toUtcMillis); + } + + /** + * Returned the scrolled(shifted) time in milliseconds. + */ + long getShiftedTime() { + return mFromUtcMillis - mStartUtcMillis; + } + + /** + * Returns the start time set by {@link #updateInitialTimeRange}. + */ + long getStartTime() { + return mStartUtcMillis; + } + + /** + * Returns the program index of the program with {@code entryId} or -1 if not found. + */ + int getProgramIdIndex(long channelId, long entryId) { + List<TableEntry> entries = mChannelIdEntriesMap.get(channelId); + if (entries != null) { + for (int i = 0; i < entries.size(); i++) { + if (entries.get(i).getId() == entryId) { + return i; + } + } + } + return -1; + } + + /** + * Returns the program index of the program at {@code time} or -1 if not found. + */ + int getProgramIndexAtTime(long channelId, long time) { + List<TableEntry> entries = mChannelIdEntriesMap.get(channelId); + for (int i = 0; i < entries.size(); ++i) { + TableEntry entry = entries.get(i); + if (entry.entryStartUtcMillis <= time + && time < entry.entryEndUtcMillis) { + return i; + } + } + return -1; + } + + /** + * Returns the start time of currently managed time range, in UTC millisecond. + */ + long getFromUtcMillis() { + return mFromUtcMillis; + } + + /** + * Returns the end time of currently managed time range, in UTC millisecond. + */ + long getToUtcMillis() { + return mToUtcMillis; + } + + /** + * Returns the number of the currently managed channels. + */ + int getChannelCount() { + return mFilteredChannels.size(); + } + + /** + * Returns a {@link Channel} at a given {@code channelIndex} of the currently managed channels. + * Returns {@code null} if such a channel is not found. + */ + Channel getChannel(int channelIndex) { + if (channelIndex < 0 || channelIndex >= getChannelCount()) { + return null; + } + return mFilteredChannels.get(channelIndex); + } + + /** + * Returns the index of provided {@link Channel} within the currently managed channels. + * Returns -1 if such a channel is not found. + */ + int getChannelIndex(Channel channel) { + return mFilteredChannels.indexOf(channel); + } + + /** + * Returns the index of channel with {@code channelId} within the currently managed channels. + * Returns -1 if such a channel is not found. + */ + int getChannelIndex(long channelId) { + return getChannelIndex(mChannelDataManager.getChannel(channelId)); + } + + /** + * Returns the number of "entries", which lies within the currently managed time range, for a + * given {@code channelId}. + */ + int getTableEntryCount(long channelId) { + return mChannelIdEntriesMap.get(channelId).size(); + } + + /** + * Returns an entry as {@link Program} for a given {@code channelId} and {@code index} of + * entries within the currently managed time range. Returned {@link Program} can be a dummy one + * (e.g., whose channelId is INVALID_ID), when it corresponds to a gap between programs. + */ + TableEntry getTableEntry(long channelId, int index) { + return mChannelIdEntriesMap.get(channelId).get(index); + } + + /** * Returns list genre ID's which has a channel. */ - public List<Integer> getFilteredGenreIds() { + List<Integer> getFilteredGenreIds() { return mFilteredGenreIds; } - public int getSelectedGenreId() { + int getSelectedGenreId() { return mSelectedGenreId; } @@ -439,11 +443,24 @@ public class ProgramManager { mChannels = mChannelDataManager.getBrowsableChannelList(); mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; mFilteredChannels = mChannels; + updateTableEntriesWithoutNotification(clearPreviousTableEntries); + // Channel update notification should be called after updating table entries, so that + // the listener can get the entries. notifyChannelsUpdated(); - updateTableEntries(clearPreviousTableEntries); + notifyTableEntriesUpdated(); + buildGenreFilters(); } private void updateTableEntries(boolean clear) { + updateTableEntriesWithoutNotification(clear); + notifyTableEntriesUpdated(); + buildGenreFilters(); + } + + /** + * Updates the table entries without notifying the change. + */ + private void updateTableEntriesWithoutNotification(boolean clear) { if (clear) { mChannelIdEntriesMap.clear(); } @@ -491,46 +508,41 @@ public class ProgramManager { } } } - - notifyTableEntriesUpdated(); - buildGenreFilters(); - } - - private void notifyGenresUpdated() { - for (Listener listener : mListeners) { - listener.onGenresUpdated(); - } } - private void notifyChannelsUpdated() { - for (Listener listener : mListeners) { - listener.onChannelsUpdated(); - } - } + /** + * Build genre filters based on the current programs. + * This categories channels by its current program's canonical genres + * and subsequent @{link resetChannelListWithGenre(int)} calls will reset channel list + * with built channel list. + * This is expected to be called whenever program guide is shown. + */ + private void buildGenreFilters() { + if (DEBUG) Log.d(TAG, "buildGenreFilters"); - private void notifyTimeRangeUpdated() { - for (Listener listener : mListeners) { - listener.onTimeRangeUpdated(); + mGenreChannelList.clear(); + for (int i = 0; i < GenreItems.getGenreCount(); i++) { + mGenreChannelList.add(new ArrayList<>()); } - } - - private void notifyTableEntriesUpdated() { - for (TableEntriesUpdatedListener listener : mTableEntriesUpdatedListeners) { - listener.onTableEntriesUpdated(); + for (Channel channel : mChannels) { + Program currentProgram = mProgramDataManager.getCurrentProgram(channel.getId()); + if (currentProgram != null && currentProgram.getCanonicalGenres() != null) { + for (String genre : currentProgram.getCanonicalGenres()) { + mGenreChannelList.get(GenreItems.getId(genre)).add(channel); + } + } } - } - - private void notifyTableEntryUpdated(TableEntry entry) { - for (TableEntryChangedListener listener : mTableEntryChangedListeners) { - listener.onTableEntryChanged(entry); + mGenreChannelList.set(GenreItems.ID_ALL_CHANNELS, mChannels); + mFilteredGenreIds.clear(); + mFilteredGenreIds.add(0); + for (int i = 1; i < GenreItems.getGenreCount(); i++) { + if (mGenreChannelList.get(i).size() > 0) { + mFilteredGenreIds.add(i); + } } - } - - private void updateEntry(TableEntry old, TableEntry newEntry) { - List<TableEntry> entries = mChannelIdEntriesMap.get(old.channelId); - int index = entries.indexOf(old); - entries.set(index, newEntry); - notifyTableEntryUpdated(newEntry); + mSelectedGenreId = GenreItems.ID_ALL_CHANNELS; + mFilteredChannels = mChannels; + notifyGenresUpdated(); } @Nullable @@ -551,32 +563,11 @@ public class ProgramManager { return null; } - /** - * Returns the start time of currently managed time range, in UTC millisecond. - */ - public long getFromUtcMillis() { - return mFromUtcMillis; - } - - /** - * Returns the end time of currently managed time range, in UTC millisecond. - */ - public long getToUtcMillis() { - return mToUtcMillis; - } - - /** - * Update the initial time range to manage. It updates program entries and genre as well. - */ - public void updateInitialTimeRange(long startUtcMillis, long endUtcMillis) { - mStartUtcMillis = startUtcMillis; - if (endUtcMillis > mEndUtcMillis) { - mEndUtcMillis = endUtcMillis; - } - - mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis); - updateChannels(true); - setTimeRange(startUtcMillis, endUtcMillis); + private void updateEntry(TableEntry old, TableEntry newEntry) { + List<TableEntry> entries = mChannelIdEntriesMap.get(old.channelId); + int index = entries.indexOf(old); + entries.set(index, newEntry); + notifyTableEntryUpdated(newEntry); } private void setTimeRange(long fromUtcMillis, long toUtcMillis) { @@ -592,57 +583,6 @@ public class ProgramManager { } } - /** - * Returns the number of the currently managed channels. - */ - public int getChannelCount() { - return mFilteredChannels.size(); - } - - /** - * Returns a {@link Channel} at a given {@code channelIndex} of the currently managed channels. - * Returns {@code null} if such a channel is not found. - */ - public Channel getChannel(int channelIndex) { - if (channelIndex < 0 || channelIndex >= getChannelCount()) { - return null; - } - return mFilteredChannels.get(channelIndex); - } - - /** - * Returns the index of provided {@link Channel} within the currently managed channels. - * Returns -1 if such a channel is not found. - */ - public int getChannelIndex(Channel channel) { - return mFilteredChannels.indexOf(channel); - } - - /** - * Returns the index of channel with {@code channelId} within the currently managed channels. - * Returns -1 if such a channel is not found. - */ - public int getChannelIndex(long channelId) { - return getChannelIndex(mChannelDataManager.getChannel(channelId)); - } - - /** - * Returns the number of "entries", which lies within the currently managed time range, for a - * given {@code channelId}. - */ - public int getTableEntryCount(long channelId) { - return mChannelIdEntriesMap.get(channelId).size(); - } - - /** - * Returns an entry as {@link Program} for a given {@code channelId} and {@code index} of - * entries within the currently managed time range. Returned {@link Program} can be a dummy one - * (e.g., whose channelId is INVALID_ID), when it corresponds to a gap between programs. - */ - public TableEntry getTableEntry(long channelId, int index) { - return mChannelIdEntriesMap.get(channelId).get(index); - } - private List<TableEntry> createProgramEntries(long channelId, boolean parentalControlsEnabled) { List<TableEntry> entries = new ArrayList<>(); boolean channelLocked = parentalControlsEnabled @@ -690,89 +630,159 @@ public class ProgramManager { return entries; } - public interface Listener { - void onGenresUpdated(); - void onChannelsUpdated(); - void onTimeRangeUpdated(); + private void notifyGenresUpdated() { + for (Listener listener : mListeners) { + listener.onGenresUpdated(); + } } - public interface TableEntriesUpdatedListener { - void onTableEntriesUpdated(); + private void notifyChannelsUpdated() { + for (Listener listener : mListeners) { + listener.onChannelsUpdated(); + } } - public interface TableEntryChangedListener { - void onTableEntryChanged(TableEntry entry); + private void notifyTimeRangeUpdated() { + for (Listener listener : mListeners) { + listener.onTimeRangeUpdated(); + } } - public static class ListenerAdapter implements Listener { - @Override - public void onGenresUpdated() { } - - @Override - public void onChannelsUpdated() { } + private void notifyTableEntriesUpdated() { + for (TableEntriesUpdatedListener listener : mTableEntriesUpdatedListeners) { + listener.onTableEntriesUpdated(); + } + } - @Override - public void onTimeRangeUpdated() { } + private void notifyTableEntryUpdated(TableEntry entry) { + for (TableEntryChangedListener listener : mTableEntryChangedListeners) { + listener.onTableEntryChanged(entry); + } } /** - * Shifts the time range by the given time. Also makes ProgramGuide scroll the views. + * Entry for program guide table. An "entry" can be either an actual program or a gap between + * programs. This is needed for {@link ProgramListAdapter} because + * {@link android.support.v17.leanback.widget.HorizontalGridView} ignores margins between items. */ - public void shiftTime(long timeMillisToScroll) { - long fromUtcMillis = mFromUtcMillis + timeMillisToScroll; - long toUtcMillis = mToUtcMillis + timeMillisToScroll; - if (fromUtcMillis < mStartUtcMillis) { - fromUtcMillis = mStartUtcMillis; - toUtcMillis += mStartUtcMillis - fromUtcMillis; + static class TableEntry { + /** Channel ID which this entry is included. */ + final long channelId; + + /** Program corresponding to the entry. {@code null} means that this entry is a gap. */ + final Program program; + + final ScheduledRecording scheduledRecording; + + /** Start time of entry in UTC milliseconds. */ + final long entryStartUtcMillis; + + /** End time of entry in UTC milliseconds */ + final long entryEndUtcMillis; + + private final boolean mIsBlocked; + + private TableEntry(long channelId, long startUtcMillis, long endUtcMillis) { + this(channelId, null, startUtcMillis, endUtcMillis, false); } - if (toUtcMillis > mEndUtcMillis) { - fromUtcMillis -= toUtcMillis - mEndUtcMillis; - toUtcMillis = mEndUtcMillis; + + private TableEntry(long channelId, long startUtcMillis, long endUtcMillis, + boolean blocked) { + this(channelId, null, null, startUtcMillis, endUtcMillis, blocked); + } + + private TableEntry(long channelId, Program program, long entryStartUtcMillis, + long entryEndUtcMillis, boolean isBlocked) { + this(channelId, program, null, entryStartUtcMillis, entryEndUtcMillis, isBlocked); + } + + private TableEntry(long channelId, Program program, ScheduledRecording scheduledRecording, + long entryStartUtcMillis, long entryEndUtcMillis, boolean isBlocked) { + this.channelId = channelId; + this.program = program; + this.scheduledRecording = scheduledRecording; + this.entryStartUtcMillis = entryStartUtcMillis; + this.entryEndUtcMillis = entryEndUtcMillis; + mIsBlocked = isBlocked; + } + + /** + * A stable id useful for {@link android.support.v7.widget.RecyclerView.Adapter}. + */ + long getId() { + // using a negative entryEndUtcMillis keeps it from conflicting with program Id + return program != null ? program.getId() : -entryEndUtcMillis; + } + + /** + * Returns true if this is a gap. + */ + boolean isGap() { + return !Program.isValid(program); + } + + /** + * Returns true if this channel is blocked. + */ + boolean isBlocked() { + return mIsBlocked; + } + + /** + * Returns true if this program is on the air. + */ + boolean isCurrentProgram() { + long current = System.currentTimeMillis(); + return entryStartUtcMillis <= current && entryEndUtcMillis > current; + } + + /** + * Returns if this program has the genre. + */ + boolean hasGenre(int genreId) { + return !isGap() && program.hasGenre(genreId); + } + + /** + * Returns the width of table entry, in pixels. + */ + int getWidth() { + return GuideUtils.convertMillisToPixel(entryStartUtcMillis, entryEndUtcMillis); + } + + @Override + public String toString() { + return "TableEntry{" + + "hashCode=" + hashCode() + + ", channelId=" + channelId + + ", program=" + program + + ", startTime=" + Utils.toTimeString(entryStartUtcMillis) + + ", endTimeTime=" + Utils.toTimeString(entryEndUtcMillis) + "}"; } - setTimeRange(fromUtcMillis, toUtcMillis); } - /** - * Returned the scrolled(shifted) time in milliseconds. - */ - public long getShiftedTime() { - return mFromUtcMillis - mStartUtcMillis; + interface Listener { + void onGenresUpdated(); + void onChannelsUpdated(); + void onTimeRangeUpdated(); } - /** - * Returns the start time set by {@link #updateInitialTimeRange}. - */ - public long getStartTime() { - return mStartUtcMillis; + interface TableEntriesUpdatedListener { + void onTableEntriesUpdated(); } - /** - * Returns the program index of the program with {@code entryId} or -1 if not found. - */ - public int getProgramIdIndex(long channelId, long entryId) { - List<TableEntry> entries = mChannelIdEntriesMap.get(channelId); - if (entries != null) { - for (int i = 0; i < entries.size(); i++) { - if (entries.get(i).getId() == entryId) { - return i; - } - } - } - return -1; + interface TableEntryChangedListener { + void onTableEntryChanged(TableEntry entry); } - /** - * Returns the program index of the program at {@code time} or -1 if not found. - */ - public int getProgramIndexAtTime(long channelId, long time) { - List<TableEntry> entries = mChannelIdEntriesMap.get(channelId); - for (int i = 0; i < entries.size(); ++i) { - TableEntry entry = entries.get(i); - if (entry.entryStartUtcMillis <= time - && time < entry.entryEndUtcMillis) { - return i; - } - } - return -1; + static class ListenerAdapter implements Listener { + @Override + public void onGenresUpdated() { } + + @Override + public void onChannelsUpdated() { } + + @Override + public void onTimeRangeUpdated() { } } } diff --git a/src/com/android/tv/guide/ProgramRow.java b/src/com/android/tv/guide/ProgramRow.java index 2c98ab2d..fefc724c 100644 --- a/src/com/android/tv/guide/ProgramRow.java +++ b/src/com/android/tv/guide/ProgramRow.java @@ -21,9 +21,11 @@ import android.graphics.Rect; import android.support.v7.widget.LinearLayoutManager; import android.util.AttributeSet; import android.util.Log; +import android.util.Range; import android.view.View; import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import com.android.tv.MainActivity; import com.android.tv.data.Channel; import com.android.tv.guide.ProgramManager.TableEntry; import com.android.tv.util.Utils; @@ -37,6 +39,7 @@ public class ProgramRow extends TimelineGridView { private static final long ONE_HOUR_MILLIS = TimeUnit.HOURS.toMillis(1); private static final long HALF_HOUR_MILLIS = ONE_HOUR_MILLIS / 2; + private ProgramGuide mProgramGuide; private ProgramManager mProgramManager; private boolean mKeepFocusToCurrentProgram; @@ -44,8 +47,8 @@ public class ProgramRow extends TimelineGridView { interface ChildFocusListener { /** - * Is called after focus is moved. It used {@link ChildFocusListener#isChild} to decide if - * old and new focuses are listener's children. + * Is called after focus is moved. Caller should check if old and new focuses are + * listener's children. * See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}. */ void onChildFocus(View oldFocus, View newFocus); @@ -213,7 +216,6 @@ public class ProgramRow extends TimelineGridView { // so give focus back in onChildAttachedToWindow(). mKeepFocusToCurrentProgram = true; } - // TODO: Try to keep focus for non-current program. } super.onChildDetachedFromWindow(child); } @@ -237,16 +239,18 @@ public class ProgramRow extends TimelineGridView { @Override public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { - // Give focus to the current program by default. - // Note that this logic is used only if requestFocus() is called to the ProgramRow, - // so focus finding logic will not be blocked by this. - View currentProgram = getCurrentProgramView(); - if (currentProgram != null) { - return currentProgram.requestFocus(); + ProgramGrid programGrid = mProgramGuide.getProgramGrid(); + + // Give focus according to the previous focused range + Range<Integer> focusRange = programGrid.getFocusRange(); + View nextFocus = GuideUtils.findNextFocusedProgram(this, focusRange.getLower(), + focusRange.getUpper(), programGrid.isKeepCurrentProgramFocused()); + + if (nextFocus != null) { + return nextFocus.requestFocus(); } if (DEBUG) Log.d(TAG, "onRequestFocusInDescendants"); - boolean result = super.onRequestFocusInDescendants(direction, previouslyFocusedRect); if (!result) { // The default focus search logic of LeanbackLibrary is sometimes failed. @@ -276,10 +280,11 @@ public class ProgramRow extends TimelineGridView { } /** - * Sets the instance of {@link ProgramManager} + * Sets the instance of {@link ProgramGuide} */ - public void setProgramManager(ProgramManager programManager) { - mProgramManager = programManager; + public void setProgramGuide(ProgramGuide programGuide) { + mProgramGuide = programGuide; + mProgramManager = programGuide.getProgramManager(); } /** @@ -300,7 +305,7 @@ public class ProgramRow extends TimelineGridView { .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. + // Therefore we have to update children's visible areas by ourselves in this 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. diff --git a/src/com/android/tv/guide/ProgramTableAdapter.java b/src/com/android/tv/guide/ProgramTableAdapter.java index e4a67972..99f853b1 100644 --- a/src/com/android/tv/guide/ProgramTableAdapter.java +++ b/src/com/android/tv/guide/ProgramTableAdapter.java @@ -45,19 +45,21 @@ import android.view.ViewGroup; import android.view.ViewParent; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener; 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.TvCommonUtils; 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.dvr.data.ScheduledRecording; import com.android.tv.guide.ProgramManager.TableEntriesUpdatedListener; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter; @@ -73,7 +75,7 @@ import java.util.List; /** * Adapts the {@link ProgramListAdapter} list to the body of the program guide table. */ -public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.ProgramRowHolder> +class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapter.ProgramRowViewHolder> implements ProgramManager.TableEntryChangedListener { private static final String TAG = "ProgramTableAdapter"; private static final boolean DEBUG = false; @@ -112,8 +114,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte private final int mDvrPaddingStartWithTrack; private final int mDvrPaddingStartWithOutTrack; - public ProgramTableAdapter(Context context, ProgramManager programManager, - ProgramGuide programGuide) { + ProgramTableAdapter(Context context, ProgramGuide programGuide) { mContext = context; mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); @@ -125,8 +126,8 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mDvrManager = null; mDvrDataManager = null; } - mProgramManager = programManager; mProgramGuide = programGuide; + mProgramManager = programGuide.getProgramManager(); Resources res = context.getResources(); mChannelLogoWidth = res.getDimensionPixelSize( @@ -193,7 +194,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mProgramListAdapters.clear(); for (int i = 0; i < mProgramManager.getChannelCount(); i++) { ProgramListAdapter listAdapter = new ProgramListAdapter(mContext.getResources(), - mProgramManager, i); + mProgramGuide, i); mProgramManager.addTableEntriesUpdatedListener(listAdapter); mProgramListAdapters.add(listAdapter); } @@ -211,12 +212,12 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } @Override - public void onBindViewHolder(ProgramRowHolder holder, int position) { + public void onBindViewHolder(ProgramRowViewHolder holder, int position) { holder.onBind(position); } @Override - public void onBindViewHolder(ProgramRowHolder holder, int position, List<Object> payloads) { + public void onBindViewHolder(ProgramRowViewHolder holder, int position, List<Object> payloads) { if (!payloads.isEmpty()) { holder.updateDetailView(); } else { @@ -225,11 +226,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } @Override - public ProgramRowHolder onCreateViewHolder(ViewGroup parent, int viewType) { + public ProgramRowViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); ProgramRow programRow = (ProgramRow) itemView.findViewById(R.id.row); programRow.setRecycledViewPool(mRecycledViewPool); - return new ProgramRowHolder(itemView); + return new ProgramRowViewHolder(itemView); } @Override @@ -241,18 +242,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte notifyItemChanged(channelIndex, true); } - @Override - public void onViewAttachedToWindow(ProgramRowHolder holder) { - holder.onAttachedToWindow(); - } - - @Override - public void onViewDetachedFromWindow(ProgramRowHolder holder) { - holder.onDetachedFromWindow(); - } - - // TODO: make it static - public class ProgramRowHolder extends RecyclerView.ViewHolder + class ProgramRowViewHolder extends RecyclerView.ViewHolder implements ProgramRow.ChildFocusListener { private final ViewGroup mContainer; @@ -269,6 +259,12 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } } }; + private final Runnable mUpdateDetailViewRunnable = new Runnable() { + @Override + public void run() { + updateDetailView(); + } + }; private final RecyclerView.OnScrollListener mOnScrollListener = new RecyclerView.OnScrollListener() { @@ -282,8 +278,9 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte new ViewTreeObserver.OnGlobalFocusChangeListener() { @Override public void onGlobalFocusChanged(View oldFocus, View newFocus) { - onChildFocus(isChild(oldFocus) ? oldFocus : null, - isChild(newFocus) ? newFocus : null); + onChildFocus( + GuideUtils.isDescendant(mContainer, oldFocus) ? oldFocus : null, + GuideUtils.isDescendant(mContainer, newFocus) ? newFocus : null); } }; @@ -312,11 +309,38 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte private final ImageView mInputLogoView; private boolean mIsInputLogoVisible; + private AccessibilityStateChangeListener mAccessibilityStateChangeListener = + new AccessibilityManager.AccessibilityStateChangeListener() { + @Override + public void onAccessibilityStateChanged(boolean enable) { + enable &= !TvCommonUtils.isRunningInTest(); + mDetailView.setFocusable(enable); + mChannelHeaderView.setFocusable(enable); + } + }; - public ProgramRowHolder(View itemView) { + ProgramRowViewHolder(View itemView) { super(itemView); mContainer = (ViewGroup) itemView; + mContainer.addOnAttachStateChangeListener( + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + mContainer.getViewTreeObserver() + .addOnGlobalFocusChangeListener(mGlobalFocusChangeListener); + mAccessibilityManager.addAccessibilityStateChangeListener( + mAccessibilityStateChangeListener); + } + + @Override + public void onViewDetachedFromWindow(View v) { + mContainer.getViewTreeObserver() + .removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener); + mAccessibilityManager.removeAccessibilityStateChangeListener( + mAccessibilityStateChangeListener); + } + }); mProgramRow = (ProgramRow) mContainer.findViewById(R.id.row); mDetailView = (ViewGroup) mContainer.findViewById(R.id.detail); @@ -339,23 +363,18 @@ 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); - } - }); + + boolean accessibilityEnabled = mAccessibilityManager.isEnabled() + && !TvCommonUtils.isRunningInTest(); + mDetailView.setFocusable(accessibilityEnabled); + mChannelHeaderView.setFocusable(accessibilityEnabled); } public void onBind(int position) { onBindChannel(mProgramManager.getChannel(position)); mProgramRow.swapAdapter(mProgramListAdapters.get(position), true); - mProgramRow.setProgramManager(mProgramManager); + mProgramRow.setProgramGuide(mProgramGuide); mProgramRow.setChannel(mProgramManager.getChannel(position)); mProgramRow.setChildFocusListener(this); mProgramRow.resetScroll(mProgramGuide.getTimelineRowScrollOffset()); @@ -416,24 +435,11 @@ 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; - } - // When the accessibility service is enabled, focus might be put on channel's header or + } // 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(); @@ -443,7 +449,15 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mSelectedEntry = ((ProgramItemView) newFocus).getTableEntry(); } if (oldFocus == null) { - updateDetailView(); + // Focus moved from other row. + if (mProgramGuide.getProgramGrid().isInLayout()) { + // We need to post runnable to avoid updating detail view when + // the recycler view is in layout, which may cause detail view not + // laid out according to the updated contents. + mHandler.post(mUpdateDetailViewRunnable); + } else { + updateDetailView(); + } return; } @@ -508,16 +522,6 @@ 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. @@ -556,10 +560,8 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis(), false)); - boolean trackMetaDataVisible = false; - trackMetaDataVisible |= - updateTextView(mAspectRatioView, Utils.getAspectRatioString( - program.getVideoWidth(), program.getVideoHeight())); + boolean trackMetaDataVisible = updateTextView(mAspectRatioView, Utils + .getAspectRatioString(program.getVideoWidth(), program.getVideoHeight())); int videoDefinitionLevel = Utils.getVideoDefinitionLevelFromSize( program.getVideoWidth(), program.getVideoHeight()); @@ -658,7 +660,9 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte if (TextUtils.isEmpty(name)) { return mContext.getString(R.string.program_guide_content_locked); } else { - return mContext.getString(R.string.program_guide_content_locked_format, name); + return TvContentRating.UNRATED.equals(blockedRating) + ? mContext.getString(R.string.program_guide_content_locked_unrated) + : mContext.getString(R.string.program_guide_content_locked_format, name); } } @@ -666,7 +670,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte * Update tv input logo. It should be called when the visible child item in ProgramGrid * changed. */ - public void updateInputLogo(int lastPosition, boolean forceShow) { + void updateInputLogo(int lastPosition, boolean forceShow) { if (mChannel == null) { mInputLogoView.setVisibility(View.GONE); mIsInputLogoVisible = false; @@ -731,7 +735,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte mInputLogoView.setVisibility(View.VISIBLE); } - private void updateCriticScoreView(ProgramRowHolder holder, final long programId, + private void updateCriticScoreView(ProgramRowViewHolder 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); @@ -759,11 +763,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte } } - private static ImageLoaderCallback<ProgramRowHolder> createCriticScoreLogoCallback( - ProgramRowHolder holder, final long programId, ImageView logoView) { - return new ImageLoaderCallback<ProgramRowHolder>(holder) { + private static ImageLoaderCallback<ProgramRowViewHolder> createCriticScoreLogoCallback( + ProgramRowViewHolder holder, final long programId, ImageView logoView) { + return new ImageLoaderCallback<ProgramRowViewHolder>(holder) { @Override - public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logoImage) { + public void onBitmapLoaded(ProgramRowViewHolder holder, @Nullable Bitmap logoImage) { if (logoImage == null || holder.mSelectedEntry == null || holder.mSelectedEntry.program == null || holder.mSelectedEntry.program.getId() != programId) { @@ -776,11 +780,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte }; } - private static ImageLoaderCallback<ProgramRowHolder> createProgramPosterArtCallback( - ProgramRowHolder holder, final Program program) { - return new ImageLoaderCallback<ProgramRowHolder>(holder) { + private static ImageLoaderCallback<ProgramRowViewHolder> createProgramPosterArtCallback( + ProgramRowViewHolder holder, final Program program) { + return new ImageLoaderCallback<ProgramRowViewHolder>(holder) { @Override - public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap posterArt) { + public void onBitmapLoaded(ProgramRowViewHolder holder, @Nullable Bitmap posterArt) { if (posterArt == null || holder.mSelectedEntry == null || holder.mSelectedEntry.program == null) { return; @@ -794,11 +798,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte }; } - private static ImageLoaderCallback<ProgramRowHolder> createChannelLogoLoadedCallback( - ProgramRowHolder holder, final long channelId) { - return new ImageLoaderCallback<ProgramRowHolder>(holder) { + private static ImageLoaderCallback<ProgramRowViewHolder> createChannelLogoLoadedCallback( + ProgramRowViewHolder holder, final long channelId) { + return new ImageLoaderCallback<ProgramRowViewHolder>(holder) { @Override - public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logo) { + public void onBitmapLoaded(ProgramRowViewHolder holder, @Nullable Bitmap logo) { if (logo == null || holder.mChannel == null || holder.mChannel.getId() != channelId) { return; @@ -808,11 +812,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte }; } - private static ImageLoaderCallback<ProgramRowHolder> createTvInputLogoLoadedCallback( - final TvInputInfo info, ProgramRowHolder holder) { - return new ImageLoaderCallback<ProgramRowHolder>(holder) { + private static ImageLoaderCallback<ProgramRowViewHolder> createTvInputLogoLoadedCallback( + final TvInputInfo info, ProgramRowViewHolder holder) { + return new ImageLoaderCallback<ProgramRowViewHolder>(holder) { @Override - public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logo) { + public void onBitmapLoaded(ProgramRowViewHolder holder, @Nullable Bitmap logo) { if (logo != null && holder.mChannel != null && info.getId() .equals(holder.mChannel.getInputId())) { holder.updateInputLogoInternal(logo); diff --git a/src/com/android/tv/guide/TimeListAdapter.java b/src/com/android/tv/guide/TimeListAdapter.java index 868fed46..d9e96a40 100644 --- a/src/com/android/tv/guide/TimeListAdapter.java +++ b/src/com/android/tv/guide/TimeListAdapter.java @@ -16,6 +16,7 @@ package com.android.tv.guide; +import android.content.Context; import android.content.res.Resources; import android.support.v7.widget.RecyclerView; import android.text.format.DateFormat; @@ -25,26 +26,40 @@ import android.view.ViewGroup; import android.widget.TextView; import com.android.tv.R; +import com.android.tv.util.Utils; import java.util.Date; +import java.util.Locale; import java.util.concurrent.TimeUnit; /** * Adapts the time range from {@link ProgramManager} to the timeline header row of the program * guide table. */ -public class TimeListAdapter extends RecyclerView.Adapter<TimeListAdapter.TimeViewHolder> { +class TimeListAdapter extends RecyclerView.Adapter<TimeListAdapter.TimeViewHolder> { private static final long TIME_UNIT_MS = TimeUnit.MINUTES.toMillis(30); + + // Ex. 3:00 AM + private static final String TIME_PATTERN_SAME_DAY = "h:mm a"; + // Ex. Oct 21, 3:00 AM + private static final String TIME_PATTERN_DIFFERENT_DAY = "MMM d, h:mm a"; + private static int sRowHeaderOverlapping; // Nearest half hour at or before the start time. private long mStartUtcMs; + private final String mTimePatternSameDay; + private final String mTimePatternDifferentDay; - public TimeListAdapter(Resources res) { + TimeListAdapter(Resources res) { if (sRowHeaderOverlapping == 0) { sRowHeaderOverlapping = Math.abs(res.getDimensionPixelOffset( R.dimen.program_guide_table_header_row_overlap)); } + Locale locale = res.getConfiguration().locale; + mTimePatternSameDay = DateFormat.getBestDateTimePattern(locale, TIME_PATTERN_SAME_DAY); + mTimePatternDifferentDay = + DateFormat.getBestDateTimePattern(locale, TIME_PATTERN_DIFFERENT_DAY); } public void update(long startTimeMs) { @@ -68,10 +83,14 @@ public class TimeListAdapter extends RecyclerView.Adapter<TimeListAdapter.TimeVi long endTime = startTime + TIME_UNIT_MS; View itemView = holder.itemView; - - TextView textView = (TextView) itemView.findViewById(R.id.time); - String time = DateFormat.getTimeFormat(itemView.getContext()).format(new Date(startTime)); - textView.setText(time); + Date timeDate = new Date(startTime); + String timeString; + if (Utils.isInGivenDay(System.currentTimeMillis(), startTime)) { + timeString = DateFormat.format(mTimePatternSameDay, timeDate).toString(); + } else { + timeString = DateFormat.format(mTimePatternDifferentDay, timeDate).toString(); + } + ((TextView) itemView.findViewById(R.id.time)).setText(timeString); RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) itemView.getLayoutParams(); lp.width = GuideUtils.convertMillisToPixel(startTime, endTime); @@ -90,8 +109,8 @@ public class TimeListAdapter extends RecyclerView.Adapter<TimeListAdapter.TimeVi return new TimeViewHolder(itemView); } - public static class TimeViewHolder extends RecyclerView.ViewHolder { - public TimeViewHolder(View itemView) { + static class TimeViewHolder extends RecyclerView.ViewHolder { + TimeViewHolder(View itemView) { super(itemView); } } diff --git a/src/com/android/tv/license/License.java b/src/com/android/tv/license/License.java new file mode 100644 index 00000000..c1cbd09e --- /dev/null +++ b/src/com/android/tv/license/License.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2017 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.license; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Container class to store the name of a library and the filename of its associated license file. + */ +public final class License implements Comparable<License>, Parcelable { + // Name of the third-party library. + private final String mLibraryName; + // Byte offset in the file to the start of the license text. + private final long mLicenseOffset; + // Byte length of the license text. + private final int mLicenseLength; + // Path to the archive that has bundled licenses. + // Empty string if the license is bundled in the apk itself. + private final String mPath; + + /** + * Create an object representing a stored license. The text for all licenses is stored in a + * single file, so the offset and length describe this license's position within the file. + * + * @param path a path to an .apk-compatible archive that contains the license. An empty string + * in case the license is contained within the app itself. + */ + static License create(String libraryName, long licenseOffset, int licenseLength, String path) { + return new License(libraryName, licenseOffset, licenseLength, path); + } + + public static final Parcelable.Creator<License> CREATOR = + new Parcelable.Creator<License>() { + @Override + public License createFromParcel(Parcel in) { + return new License(in); + } + + @Override + public License[] newArray(int size) { + return new License[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mLibraryName); + dest.writeLong(mLicenseOffset); + dest.writeInt(mLicenseLength); + dest.writeString(mPath); + } + + @Override + public int compareTo(License o) { + return mLibraryName.compareToIgnoreCase(o.getLibraryName()); + } + + @Override + public String toString() { + return getLibraryName(); + } + + private License(String libraryName, long licenseOffset, int licenseLength, String path) { + this.mLibraryName = libraryName; + this.mLicenseOffset = licenseOffset; + this.mLicenseLength = licenseLength; + this.mPath = path; + } + + private License(Parcel in) { + mLibraryName = in.readString(); + mLicenseOffset = in.readLong(); + mLicenseLength = in.readInt(); + mPath = in.readString(); + } + + String getLibraryName() { + return mLibraryName; + } + + long getLicenseOffset() { + return mLicenseOffset; + } + + int getLicenseLength() { + return mLicenseLength; + } + + public String getPath() { + return mPath; + } +} diff --git a/src/com/android/tv/license/LicenseDialogFragment.java b/src/com/android/tv/license/LicenseDialogFragment.java new file mode 100644 index 00000000..b0e09776 --- /dev/null +++ b/src/com/android/tv/license/LicenseDialogFragment.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2017 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.license; + +import android.app.DialogFragment; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.text.method.ScrollingMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.widget.TextView; + +import com.android.tv.R; +import com.android.tv.dialog.SafeDismissDialogFragment; + +/** A DialogFragment that shows a License in a text view. */ +public class LicenseDialogFragment extends SafeDismissDialogFragment { + public static final String DIALOG_TAG = LicenseDialogFragment.class.getSimpleName(); + + private static final String LICENSE = "LICENSE"; + + private License mLicense; + private String mTrackerLabel; + + /** + * Create a new LicenseDialogFragment to show a particular license. + * + * @param license The License to show. + */ + public static LicenseDialogFragment newInstance(License license) { + LicenseDialogFragment f = new LicenseDialogFragment(); + Bundle args = new Bundle(); + args.putParcelable(LICENSE, license); + f.setArguments(args); + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mLicense = getArguments().getParcelable(LICENSE); + String title = mLicense.getLibraryName(); + mTrackerLabel = getArguments().getString(title + "_license"); + int style = + TextUtils.isEmpty(title) + ? DialogFragment.STYLE_NO_TITLE + : DialogFragment.STYLE_NORMAL; + setStyle(style, 0); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + TextView textView = new TextView(getActivity()); + String licenseText = Licenses.getLicenseText(getContext(), mLicense); + textView.setText(licenseText != null ? licenseText : ""); + textView.setMovementMethod(new ScrollingMovementMethod()); + int verticalOverscan = + getResources().getDimensionPixelSize(R.dimen.vertical_overscan_safe_margin); + int horizontalOverscan = + getResources().getDimensionPixelSize(R.dimen.horizontal_overscan_safe_margin); + textView.setPadding( + horizontalOverscan, verticalOverscan, horizontalOverscan, verticalOverscan); + return textView; + } + + @Override + public void onStart() { + super.onStart(); + // Ensure the dialog is fullscreen, even if the TextView doesn't have its content yet. + getDialog().getWindow().setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + getDialog().setTitle(mLicense.getLibraryName()); + } + + @Override + public String getTrackerLabel() { + return mTrackerLabel; + } +} diff --git a/src/com/android/tv/license/LicenseSideFragment.java b/src/com/android/tv/license/LicenseSideFragment.java new file mode 100644 index 00000000..fd92467c --- /dev/null +++ b/src/com/android/tv/license/LicenseSideFragment.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 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.license; + +import android.content.Context; + +import com.android.tv.R; +import com.android.tv.ui.sidepanel.ActionItem; +import com.android.tv.ui.sidepanel.SideFragment; + +import java.util.ArrayList; +import java.util.List; + +/** Opens a dialog showing open source licenses. */ +public final class LicenseSideFragment extends SideFragment { + + public static final String TRACKER_LABEL = "Open Source Licenses"; + + public class LicenseActionItem extends ActionItem { + private final License license; + + public LicenseActionItem(License license) { + super(license.getLibraryName()); + this.license = license; + } + + @Override + protected void onSelected() { + LicenseDialogFragment dialog = LicenseDialogFragment.newInstance(license); + getMainActivity() + .getOverlayManager() + .showDialogFragment(LicenseDialogFragment.DIALOG_TAG, dialog, true); + } + } + + private List<LicenseActionItem> licenses; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + licenses = toActionItems(Licenses.getLicenses(context)); + } + + private List<LicenseActionItem> toActionItems(ArrayList<License> licenses) { + List<LicenseActionItem> items = new ArrayList<>(licenses.size()); + for (License license : licenses) { + items.add(new LicenseActionItem(license)); + } + return items; + } + + @Override + protected String getTitle() { + return getResources().getString(R.string.settings_menu_licenses); + } + + @Override + public String getTrackerLabel() { + return TRACKER_LABEL; + } + + @Override + protected List<LicenseActionItem> getItemList() { + return licenses; + } +} diff --git a/src/com/android/tv/license/LicenseUtils.java b/src/com/android/tv/license/LicenseUtils.java index b972aad6..cf3fe751 100644 --- a/src/com/android/tv/license/LicenseUtils.java +++ b/src/com/android/tv/license/LicenseUtils.java @@ -26,21 +26,9 @@ import java.io.InputStream; * Utilities for showing open source licenses. */ public final class LicenseUtils { - public final static String LICENSE_FILE = "file:///android_asset/licenses.html"; public final static String RATING_SOURCE_FILE = "file:///android_asset/rating_sources.html"; - private final static File licenseFile = new File(LICENSE_FILE); - /** - * Checks if the license.html asset is include in the apk. - */ - public static boolean hasLicenses(AssetManager am) { - try (InputStream is = am.open("licenses.html")) { - return true; - } catch (IOException e) { - return false; - } - } /** * Checks if the rating_attribution.html asset is include in the apk. diff --git a/src/com/android/tv/license/Licenses.java b/src/com/android/tv/license/Licenses.java new file mode 100644 index 00000000..4b8a7ffc --- /dev/null +++ b/src/com/android/tv/license/Licenses.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2017 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.license; + +import android.content.Context; +import android.support.annotation.RawRes; + +import com.android.tv.R; +import com.android.tv.common.SoftPreconditions; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Collections; + +/** + * A helper for extracting licenses embedded using + * third_party_licenses.build:third_party_licenses(). + */ +public final class Licenses { + + public static final String TAG = "Licenses"; + public static boolean hasLicenses(Context context) { + return !getTextFromResource( + context.getApplicationContext(), R.raw.third_party_license_metadata, 0, -1) + .isEmpty(); + } + + /** Return the licenses bundled into this app. */ + public static ArrayList<License> getLicenses(Context context) { + return getLicenseListFromMetadata( + getTextFromResource( + context.getApplicationContext(), R.raw.third_party_license_metadata, 0, -1), + ""); + } + + /** + * Returns a list of {@link License}s parsed from a license metadata file. + * + * @param metadata a {@code String} containing the contents of a license metadata file. + * @param filePath a path to a package archive with licenses or empty string for the app package + */ + private static ArrayList<License> getLicenseListFromMetadata(String metadata, String filePath) { + String[] entries = metadata.split("\n"); + ArrayList<License> licenses = new ArrayList<License>(entries.length); + for (String entry : entries) { + int delimiter = entry.indexOf(' '); + String[] licenseLocation = entry.substring(0, delimiter).split(":"); + SoftPreconditions.checkState( + licenseLocation.length == 2 && delimiter > 0, + TAG, + "Invalid license meta-data line:\n" + entry); + long licenseOffset = Long.parseLong(licenseLocation[0]); + int licenseLength = Integer.parseInt(licenseLocation[1]); + licenses.add( + License.create( + entry.substring(delimiter + 1), + licenseOffset, + licenseLength, + filePath)); + } + Collections.sort(licenses); + return licenses; + } + + /** Return the text of a bundled license file. */ + public static String getLicenseText(Context context, License license) { + long offset = license.getLicenseOffset(); + int length = license.getLicenseLength(); + return getTextFromResource(context, R.raw.third_party_licenses, offset, length); + } + + private static String getTextFromResource( + Context context, @RawRes int resourcesIdentifier, long offset, int length) { + InputStream stream = + context.getApplicationContext().getResources().openRawResource(resourcesIdentifier); + return getTextFromInputStream(stream, offset, length); + } + + private static String getTextFromInputStream(InputStream stream, long offset, int length) { + byte[] buffer = new byte[1024]; + ByteArrayOutputStream textArray = new ByteArrayOutputStream(); + + try { + stream.skip(offset); + int bytesRemaining = length > 0 ? length : Integer.MAX_VALUE; + int bytes = 0; + + while (bytesRemaining > 0 + && (bytes = stream.read(buffer, 0, Math.min(bytesRemaining, buffer.length))) + != -1) { + textArray.write(buffer, 0, bytes); + bytesRemaining -= bytes; + } + stream.close(); + } catch (IOException e) { + throw new RuntimeException("Failed to read license or metadata text.", e); + } + try { + return textArray.toString("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException( + "Unsupported encoding UTF8. This should always be supported.", e); + } + } +} diff --git a/src/com/android/tv/menu/ActionCardView.java b/src/com/android/tv/menu/ActionCardView.java index 54892cac..2fd70bfb 100644 --- a/src/com/android/tv/menu/ActionCardView.java +++ b/src/com/android/tv/menu/ActionCardView.java @@ -19,8 +19,8 @@ package com.android.tv.menu; import android.content.Context; import android.util.AttributeSet; import android.util.Log; -import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.RelativeLayout; import android.widget.TextView; import com.android.tv.R; @@ -28,7 +28,7 @@ import com.android.tv.R; /** * A view to render an item of TV options. */ -public class ActionCardView extends FrameLayout implements ItemListRowView.CardView<MenuAction> { +public class ActionCardView extends RelativeLayout implements ItemListRowView.CardView<MenuAction> { private static final String TAG = MenuView.TAG; private static final boolean DEBUG = MenuView.DEBUG; @@ -66,7 +66,7 @@ public class ActionCardView extends FrameLayout implements ItemListRowView.CardV } mIconView.setImageDrawable(action.getDrawable(getContext())); mLabelView.setText(action.getActionName(getContext())); - mStateView.setText(action.getActionDescription(getContext())); + mStateView.setText(action.getActionDescription()); if (action.isEnabled()) { setEnabled(true); setFocusable(true); diff --git a/src/com/android/tv/menu/AppLinkCardView.java b/src/com/android/tv/menu/AppLinkCardView.java index bfb5e3f1..94ccd37f 100644 --- a/src/com/android/tv/menu/AppLinkCardView.java +++ b/src/com/android/tv/menu/AppLinkCardView.java @@ -24,6 +24,7 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.os.AsyncTask; import android.support.annotation.Nullable; import android.support.v7.graphics.Palette; import android.text.TextUtils; @@ -40,10 +41,12 @@ import com.android.tv.util.BitmapUtils; import com.android.tv.util.ImageLoader; import com.android.tv.util.TvInputManagerHelper; +import java.util.Objects; + /** * A view to render an app link card. */ -public class AppLinkCardView extends BaseCardView<Channel> { +public class AppLinkCardView extends BaseCardView<ChannelsRowItem> { private static final String TAG = MenuView.TAG; private static final boolean DEBUG = MenuView.DEBUG; @@ -53,9 +56,9 @@ public class AppLinkCardView extends BaseCardView<Channel> { private final int mIconHeight; private final int mIconPadding; private final int mIconColorFilter; + private final Drawable mDefaultDrawable; private ImageView mImageView; - private View mGradientView; private TextView mAppInfoView; private View mMetaViewHolder; private Channel mChannel; @@ -82,6 +85,7 @@ public class AppLinkCardView extends BaseCardView<Channel> { mPackageManager = context.getPackageManager(); mTvInputManagerHelper = ((MainActivity) context).getTvInputManagerHelper(); mIconColorFilter = getResources().getColor(R.color.app_link_card_icon_color_filter, null); + mDefaultDrawable = getResources().getDrawable(R.drawable.ic_recent_thumbnail_default, null); } /** @@ -92,65 +96,153 @@ public class AppLinkCardView extends BaseCardView<Channel> { } @Override - public void onBind(Channel channel, boolean selected) { + public void onBind(ChannelsRowItem item, boolean selected) { + Channel newChannel = item.getChannel(); + boolean channelChanged = !Objects.equals(mChannel, newChannel); + String previousPosterArtUri = mChannel == null ? null : mChannel.getAppLinkPosterArtUri(); + boolean posterArtChanged = previousPosterArtUri == null + || newChannel.getAppLinkPosterArtUri() == null + || !TextUtils.equals(previousPosterArtUri, newChannel.getAppLinkPosterArtUri()); + mChannel = newChannel; if (DEBUG) { - Log.d(TAG, "onBind(channelName=" + channel.getDisplayName() + ", selected=" + selected + Log.d(TAG, "onBind(channelName=" + mChannel.getDisplayName() + ", selected=" + selected + ")"); } - mChannel = channel; ApplicationInfo appInfo = mTvInputManagerHelper.getTvInputAppInfo(mChannel.getInputId()); - int linkType = mChannel.getAppLinkType(getContext()); - mIntent = mChannel.getAppLinkIntent(getContext()); + if (channelChanged) { + int linkType = mChannel.getAppLinkType(getContext()); + mIntent = mChannel.getAppLinkIntent(getContext()); - switch (linkType) { - case Channel.APP_LINK_TYPE_CHANNEL: - setText(mChannel.getAppLinkText()); - mAppInfoView.setVisibility(VISIBLE); - mGradientView.setVisibility(VISIBLE); - mAppInfoView.setCompoundDrawablePadding(mIconPadding); - mAppInfoView.setCompoundDrawables(null, null, null, null); - mAppInfoView.setText(mPackageManager.getApplicationLabel(appInfo)); - if (!TextUtils.isEmpty(mChannel.getAppLinkIconUri())) { - mChannel.loadBitmap(getContext(), Channel.LOAD_IMAGE_TYPE_APP_LINK_ICON, - mIconWidth, mIconHeight, createChannelLogoCallback(this, mChannel, - Channel.LOAD_IMAGE_TYPE_APP_LINK_ICON)); - } else if (appInfo.icon != 0) { - Drawable appIcon = mPackageManager.getApplicationIcon(appInfo); - BitmapUtils.setColorFilterToDrawable(mIconColorFilter, appIcon); - appIcon.setBounds(0, 0, mIconWidth, mIconHeight); - mAppInfoView.setCompoundDrawables(appIcon, null, null, null); - } - break; - case Channel.APP_LINK_TYPE_APP: - setText(getContext().getString( - R.string.channels_item_app_link_app_launcher, - mPackageManager.getApplicationLabel(appInfo))); - mAppInfoView.setVisibility(GONE); - mGradientView.setVisibility(GONE); - break; - default: - mAppInfoView.setVisibility(GONE); - mGradientView.setVisibility(GONE); - Log.d(TAG, "Should not be here."); - } + CharSequence appLabel; + switch (linkType) { + case Channel.APP_LINK_TYPE_CHANNEL: + setText(mChannel.getAppLinkText()); + mAppInfoView.setVisibility(VISIBLE); + mAppInfoView.setCompoundDrawablePadding(mIconPadding); + mAppInfoView.setCompoundDrawablesRelative(null, null, null, null); + appLabel = mTvInputManagerHelper + .getTvInputApplicationLabel(mChannel.getInputId()); + if (appLabel != null) { + mAppInfoView.setText(appLabel); + } else { + new AsyncTask<Void, Void, CharSequence>() { + private final String mLoadTvInputId = mChannel.getInputId(); - if (mChannel.getAppLinkColor() == 0) { - mMetaViewHolder.setBackgroundResource(R.color.channel_card_meta_background); - } else { - mMetaViewHolder.setBackgroundColor(mChannel.getAppLinkColor()); - } + @Override + protected CharSequence doInBackground(Void... params) { + if (appInfo != null) { + return mPackageManager.getApplicationLabel(appInfo); + } + return null; + } - if (!TextUtils.isEmpty(mChannel.getAppLinkPosterArtUri())) { - mImageView.setImageResource(R.drawable.ic_recent_thumbnail_default); - mChannel.loadBitmap(getContext(), Channel.LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART, - mCardImageWidth, mCardImageHeight, createChannelLogoCallback(this, mChannel, - Channel.LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART)); - } else { - setCardImageWithBanner(appInfo); + @Override + protected void onPostExecute(CharSequence appLabel) { + mTvInputManagerHelper.setTvInputApplicationLabel( + mLoadTvInputId, appLabel); + if (mLoadTvInputId != mChannel.getInputId() + || !isAttachedToWindow()) { + return; + } + mAppInfoView.setText(appLabel); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + if (!TextUtils.isEmpty(mChannel.getAppLinkIconUri())) { + mChannel.loadBitmap(getContext(), Channel.LOAD_IMAGE_TYPE_APP_LINK_ICON, + mIconWidth, mIconHeight, createChannelLogoCallback( + this, mChannel, Channel.LOAD_IMAGE_TYPE_APP_LINK_ICON)); + } else if (appInfo.icon != 0) { + Drawable appIcon = mTvInputManagerHelper + .getTvInputApplicationIcon(mChannel.getInputId()); + if (appIcon != null) { + BitmapUtils.setColorFilterToDrawable(mIconColorFilter, appIcon); + appIcon.setBounds(0, 0, mIconWidth, mIconHeight); + mAppInfoView.setCompoundDrawablesRelative(appIcon, null, null, null); + } else { + new AsyncTask<Void, Void, Drawable>() { + private final String mLoadTvInputId = mChannel.getInputId(); + + @Override + protected Drawable doInBackground(Void... params) { + return mPackageManager.getApplicationIcon(appInfo); + } + + @Override + protected void onPostExecute(Drawable appIcon) { + mTvInputManagerHelper.setTvInputApplicationIcon( + mLoadTvInputId, appIcon); + if (!mLoadTvInputId.equals(mChannel.getInputId()) + || !isAttachedToWindow()) { + return; + } + BitmapUtils.setColorFilterToDrawable(mIconColorFilter, appIcon); + appIcon.setBounds(0, 0, mIconWidth, mIconHeight); + mAppInfoView.setCompoundDrawablesRelative( + appIcon, null, null, null); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + break; + case Channel.APP_LINK_TYPE_APP: + appLabel = mTvInputManagerHelper + .getTvInputApplicationLabel(mChannel.getInputId()); + if (appLabel != null) { + setText(getContext() + .getString(R.string.channels_item_app_link_app_launcher, appLabel)); + } else { + new AsyncTask<Void, Void, CharSequence>() { + private final String mLoadTvInputId = mChannel.getInputId(); + + @Override + protected CharSequence doInBackground(Void... params) { + if (appInfo != null) { + return mPackageManager.getApplicationLabel(appInfo); + } + return null; + } + + @Override + protected void onPostExecute(CharSequence appLabel) { + mTvInputManagerHelper.setTvInputApplicationLabel( + mLoadTvInputId, appLabel); + if (!mLoadTvInputId.equals(mChannel.getInputId()) + || !isAttachedToWindow()) { + return; + } + setText(getContext() + .getString( + R.string.channels_item_app_link_app_launcher, + appLabel)); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + mAppInfoView.setVisibility(GONE); + break; + default: + mAppInfoView.setVisibility(GONE); + Log.d(TAG, "Should not be here."); + } + + if (mChannel.getAppLinkColor() == 0) { + mMetaViewHolder.setBackgroundResource(R.color.channel_card_meta_background); + } else { + mMetaViewHolder.setBackgroundColor(mChannel.getAppLinkColor()); + } } - // 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); + if (posterArtChanged) { + mImageView.setImageDrawable(mDefaultDrawable); + mImageView.setForeground(null); + if (!TextUtils.isEmpty(mChannel.getAppLinkPosterArtUri())) { + mChannel.loadBitmap(getContext(), Channel.LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART, + mCardImageWidth, mCardImageHeight, createChannelLogoCallback(this, mChannel, + Channel.LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART)); + } else { + setCardImageWithBanner(appInfo); + } + } + super.onBind(item, selected); } private static ImageLoader.ImageLoaderCallback<AppLinkCardView> createChannelLogoCallback( @@ -182,13 +274,14 @@ public class AppLinkCardView extends BaseCardView<Channel> { } } BitmapUtils.setColorFilterToDrawable(mIconColorFilter, drawable); - mAppInfoView.setCompoundDrawables(drawable, null, null, null); + mAppInfoView.setCompoundDrawablesRelative(drawable, null, null, null); } else if (type == Channel.LOAD_IMAGE_TYPE_APP_LINK_POSTER_ART) { if (bitmap == null) { setCardImageWithBanner( mTvInputManagerHelper.getTvInputAppInfo(mChannel.getInputId())); } else { mImageView.setImageBitmap(bitmap); + mImageView.setForeground(getContext().getDrawable(R.drawable.card_image_gradient)); if (mChannel.getAppLinkColor() == 0) { extractAndSetMetaViewBackgroundColor(bitmap); } @@ -200,7 +293,6 @@ public class AppLinkCardView extends BaseCardView<Channel> { protected void onFinishInflate() { super.onFinishInflate(); mImageView = (ImageView) findViewById(R.id.image); - mGradientView = findViewById(R.id.image_gradient); mAppInfoView = (TextView) findViewById(R.id.app_info); mMetaViewHolder = findViewById(R.id.app_link_text_holder); } @@ -209,37 +301,85 @@ public class AppLinkCardView extends BaseCardView<Channel> { // 1) Provided poster art image, 2) Activity banner, 3) Activity icon, 4) Application banner, // 5) Application icon, and 6) default image. private void setCardImageWithBanner(ApplicationInfo appInfo) { - Drawable banner = null; - if (mIntent != null) { - try { - banner = mPackageManager.getActivityBanner(mIntent); - if (banner == null) { - banner = mPackageManager.getActivityIcon(mIntent); + new AsyncTask<Void, Void, Drawable>() { + private String mLoadTvInputId = mChannel.getInputId(); + @Override + protected Drawable doInBackground(Void... params) { + Drawable banner = null; + if (mIntent != null) { + try { + banner = mPackageManager.getActivityBanner(mIntent); + if (banner == null) { + banner = mPackageManager.getActivityIcon(mIntent); + } + } catch (PackageManager.NameNotFoundException e) { + // do nothing. + } } - } catch (PackageManager.NameNotFoundException e) { - // do nothing. + return banner; } - } - if (banner == null && appInfo != null) { - if (appInfo.banner != 0) { - banner = mPackageManager.getApplicationBanner(appInfo); - } - if (banner == null && appInfo.icon != 0) { - banner = mPackageManager.getApplicationIcon(appInfo); + @Override + protected void onPostExecute(Drawable banner) { + if (mLoadTvInputId != mChannel.getInputId() || !isAttachedToWindow()) { + return; + } + if (banner != null) { + setCardImageWithBannerInternal(banner); + } else { + setCardImageWithApplicationInfoBanner(appInfo); + } } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void setCardImageWithApplicationInfoBanner(ApplicationInfo appInfo) { + Drawable appBanner = + mTvInputManagerHelper.getTvInputApplicationBanner(mChannel.getInputId()); + if (appBanner != null) { + setCardImageWithBannerInternal(appBanner); + } else { + new AsyncTask<Void, Void, Drawable>() { + private final String mLoadTvInputId = mChannel.getInputId(); + @Override + protected Drawable doInBackground(Void... params) { + Drawable banner = null; + if (appInfo != null) { + if (appInfo.banner != 0) { + banner = mPackageManager.getApplicationBanner(appInfo); + } + if (banner == null && appInfo.icon != 0) { + banner = mPackageManager.getApplicationIcon(appInfo); + } + } + return banner; + } + + @Override + protected void onPostExecute(Drawable banner) { + mTvInputManagerHelper.setTvInputApplicationBanner(mLoadTvInputId, banner); + if (!TextUtils.equals(mLoadTvInputId, mChannel.getInputId()) + || !isAttachedToWindow()) { + return; + } + setCardImageWithBannerInternal(banner); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } + } + private void setCardImageWithBannerInternal(Drawable banner) { if (banner == null) { - mImageView.setImageResource(R.drawable.ic_recent_thumbnail_default); + mImageView.setImageDrawable(mDefaultDrawable); mImageView.setBackgroundResource(R.color.channel_card); } else { - Bitmap bitmap = - Bitmap.createBitmap(mCardImageWidth, mCardImageHeight, Bitmap.Config.ARGB_8888); + Bitmap bitmap = Bitmap.createBitmap( + mCardImageWidth, mCardImageHeight, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); banner.setBounds(0, 0, mCardImageWidth, mCardImageHeight); banner.draw(canvas); mImageView.setImageDrawable(banner); + mImageView.setForeground(getContext().getDrawable(R.drawable.card_image_gradient)); if (mChannel.getAppLinkColor() == 0) { extractAndSetMetaViewBackgroundColor(bitmap); } diff --git a/src/com/android/tv/menu/BaseCardView.java b/src/com/android/tv/menu/BaseCardView.java index c6a34a5d..4c5e6c78 100644 --- a/src/com/android/tv/menu/BaseCardView.java +++ b/src/com/android/tv/menu/BaseCardView.java @@ -22,6 +22,7 @@ import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Outline; import android.support.annotation.Nullable; +import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; @@ -35,9 +36,6 @@ import com.android.tv.R; * A base class to render a card. */ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRowView.CardView<T> { - private static final String TAG = "BaseCardView"; - private static final boolean DEBUG = false; - private static final float SCALE_FACTOR_0F = 0f; private static final float SCALE_FACTOR_1F = 1f; @@ -57,6 +55,11 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo private TextView mTextViewFocused; private final int mCardImageWidth; private final float mCardHeight; + private boolean mSelected; + + private int mTextResId; + private String mTextString; + private boolean mTextChanged; public BaseCardView(Context context) { this(context, null); @@ -103,23 +106,9 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo /** * 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) { - 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); } @@ -128,6 +117,7 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo @Override public void onSelected() { + mSelected = true; if (isAttachedToWindow() && getVisibility() == View.VISIBLE) { startFocusAnimation(SCALE_FACTOR_1F); } else { @@ -138,6 +128,7 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo @Override public void onDeselected() { + mSelected = false; if (isAttachedToWindow() && getVisibility() == View.VISIBLE) { startFocusAnimation(SCALE_FACTOR_0F); } else { @@ -150,11 +141,17 @@ 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); + if (mTextResId != resId) { + mTextResId = resId; + mTextString = null; + mTextChanged = true; + if (mTextViewFocused != null) { + mTextViewFocused.setText(resId); + } + if (mTextView != null) { + mTextView.setText(resId); + } + onTextViewUpdated(); } } @@ -162,12 +159,33 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo * Sets text of this card view. */ public void setText(String text) { - if (mTextViewFocused != null) { - mTextViewFocused.setText(text); + if (!TextUtils.equals(text, mTextString)) { + mTextString = text; + mTextResId = 0; + mTextChanged = true; + if (mTextViewFocused != null) { + mTextViewFocused.setText(text); + } + if (mTextView != null) { + mTextView.setText(text); + } + onTextViewUpdated(); } - if (mTextView != null) { - mTextView.setText(text); + } + + private void onTextViewUpdated() { + if (mTextView != null && mTextViewFocused != null) { + mTextViewFocused.measure( + MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mExtendViewOnFocus = mTextViewFocused.getLineCount() > 1; + if (mExtendViewOnFocus) { + setTextViewFocusedAlpha(mSelected ? 1f : 0f); + } else { + setTextViewFocusedAlpha(1f); + } } + setFocusAnimatedValue(mSelected ? SCALE_FACTOR_1F : SCALE_FACTOR_0F); } /** @@ -209,12 +227,18 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo setScaleX(scale); setScaleY(scale); setTranslationZ(mFocusTranslationZ * animatedValue); - if (mExtendViewOnFocus) { + if (mTextView != null && mTextViewFocused != null) { ViewGroup.LayoutParams params = mTextView.getLayoutParams(); - params.height = Math.round(mTextViewHeight - + (mExtendedTextViewHeight - mTextViewHeight) * animatedValue); - setTextViewLayoutParams(params); - setTextViewFocusedAlpha(animatedValue); + int height = mExtendViewOnFocus ? Math.round(mTextViewHeight + + (mExtendedTextViewHeight - mTextViewHeight) * animatedValue) + : (int) mTextViewHeight; + if (height != params.height) { + params.height = height; + setTextViewLayoutParams(params); + } + if (mExtendViewOnFocus) { + setTextViewFocusedAlpha(animatedValue); + } } } diff --git a/src/com/android/tv/menu/ChannelCardView.java b/src/com/android/tv/menu/ChannelCardView.java index 1c8015a6..2ecb6af7 100644 --- a/src/com/android/tv/menu/ChannelCardView.java +++ b/src/com/android/tv/menu/ChannelCardView.java @@ -34,10 +34,12 @@ import com.android.tv.data.Program; import com.android.tv.parental.ParentalControlSettings; import com.android.tv.util.ImageLoader; +import java.util.Objects; + /** * A view to render channel card. */ -public class ChannelCardView extends BaseCardView<Channel> { +public class ChannelCardView extends BaseCardView<ChannelsRowItem> { private static final String TAG = MenuView.TAG; private static final boolean DEBUG = MenuView.DEBUG; @@ -45,11 +47,11 @@ public class ChannelCardView extends BaseCardView<Channel> { private final int mCardImageHeight; private ImageView mImageView; - private View mGradientView; private TextView mChannelNumberNameView; private ProgressBar mProgressBar; private Channel mChannel; private Program mProgram; + private String mPosterArtUri; private final MainActivity mMainActivity; public ChannelCardView(Context context) { @@ -71,39 +73,72 @@ public class ChannelCardView extends BaseCardView<Channel> { protected void onFinishInflate() { super.onFinishInflate(); mImageView = (ImageView) findViewById(R.id.image); - mGradientView = findViewById(R.id.image_gradient); + mImageView.setBackgroundResource(R.color.channel_card); mChannelNumberNameView = (TextView) findViewById(R.id.channel_number_and_name); mProgressBar = (ProgressBar) findViewById(R.id.progress); } @Override - public void onBind(Channel channel, boolean selected) { + public void onBind(ChannelsRowItem item, boolean selected) { if (DEBUG) { - Log.d(TAG, "onBind(channelName=" + channel.getDisplayName() + ", selected=" + selected - + ")"); + Log.d(TAG, "onBind(channelName=" + item.getChannel().getDisplayName() + ", selected=" + + selected + ")"); } - mChannel = channel; - mProgram = null; - 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); + updateChannel(item); + updateProgram(); + super.onBind(item, selected); + } - setTextViewEnabled(true); - if (mMainActivity.getParentalControlSettings().isParentalControlsEnabled() - && mChannel.isLocked()) { + private void updateChannel(ChannelsRowItem item) { + if (!item.getChannel().equals(mChannel)) { + mChannel = item.getChannel(); + mChannelNumberNameView.setText(mChannel.getDisplayText()); + mChannelNumberNameView.setVisibility(VISIBLE); + } + } + + private void updateProgram() { + ParentalControlSettings parental = mMainActivity.getParentalControlSettings(); + if (parental.isParentalControlsEnabled() && mChannel.isLocked()) { setText(R.string.program_title_for_blocked_channel); - return; + mProgram = null; + } else { + Program currentProgram = + mMainActivity.getProgramDataManager().getCurrentProgram(mChannel.getId()); + if (!Objects.equals(currentProgram, mProgram)) { + mProgram = currentProgram; + if (mProgram == null || TextUtils.isEmpty(mProgram.getTitle())) { + setTextViewEnabled(false); + setText(R.string.program_title_for_no_information); + } else { + setTextViewEnabled(true); + setText(mProgram.getTitle()); + } + } + } + if (mProgram == null) { + mProgressBar.setVisibility(GONE); + setPosterArt(null); } else { - setText(""); + // Update progress. + mProgressBar.setVisibility(View.VISIBLE); + long startTime = mProgram.getStartTimeUtcMillis(); + long endTime = mProgram.getEndTimeUtcMillis(); + long currTime = System.currentTimeMillis(); + if (currTime <= startTime) { + mProgressBar.setProgress(0); + } else if (currTime >= endTime) { + mProgressBar.setProgress(100); + } else { + mProgressBar.setProgress( + (int) (100 * (currTime - startTime) / (endTime - startTime))); + } + // Update image. + if (!parental.isParentalControlsEnabled() + || !parental.isRatingBlocked(mProgram.getContentRatings())) { + setPosterArt(mProgram.getPosterArtUri()); + } } - - updateProgramInformation(); - // 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); } private static ImageLoader.ImageLoaderCallback<ChannelCardView> createProgramPosterArtCallback( @@ -121,49 +156,20 @@ public class ChannelCardView extends BaseCardView<Channel> { }; } - private void updatePosterArt(Bitmap posterArt) { - mImageView.setImageBitmap(posterArt); - mGradientView.setVisibility(View.VISIBLE); - } - - private void updateProgramInformation() { - if (mChannel == null) { - return; - } - mProgram = mMainActivity.getProgramDataManager().getCurrentProgram(mChannel.getId()); - if (mProgram == null || TextUtils.isEmpty(mProgram.getTitle())) { - setTextViewEnabled(false); - setText(R.string.program_title_for_no_information); - } else { - setText(mProgram.getTitle()); - } - - if (mProgram == null) { - return; - } - - long startTime = mProgram.getStartTimeUtcMillis(); - long endTime = mProgram.getEndTimeUtcMillis(); - long currTime = System.currentTimeMillis(); - mProgressBar.setVisibility(View.VISIBLE); - if (currTime <= startTime) { - mProgressBar.setProgress(0); - } else if (currTime >= endTime) { - mProgressBar.setProgress(100); - } else { - mProgressBar.setProgress((int) (100 * (currTime - startTime) / (endTime - startTime))); + private void setPosterArt(String posterArtUri) { + if (!TextUtils.equals(mPosterArtUri, posterArtUri)) { + mPosterArtUri = posterArtUri; + if (posterArtUri == null + || !mProgram.loadPosterArt(getContext(), mCardImageWidth, mCardImageHeight, + createProgramPosterArtCallback(this, mProgram))) { + mImageView.setImageResource(R.drawable.ic_recent_thumbnail_default); + mImageView.setForeground(null); + } } + } - if (!(getContext() instanceof MainActivity)) { - Log.e(TAG, "Fails to check program's content rating."); - return; - } - ParentalControlSettings parental = mMainActivity.getParentalControlSettings(); - if ((!parental.isParentalControlsEnabled() - || !parental.isRatingBlocked(mProgram.getContentRatings())) - && !TextUtils.isEmpty(mProgram.getPosterArtUri())) { - mProgram.loadPosterArt(getContext(), mCardImageWidth, mCardImageHeight, - createProgramPosterArtCallback(this, mProgram)); - } + private void updatePosterArt(Bitmap posterArt) { + mImageView.setImageBitmap(posterArt); + mImageView.setForeground(getContext().getDrawable(R.drawable.card_image_gradient)); } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/menu/ChannelsPosterPrefetcher.java b/src/com/android/tv/menu/ChannelsPosterPrefetcher.java index f932d75d..bc5d6cfb 100644 --- a/src/com/android/tv/menu/ChannelsPosterPrefetcher.java +++ b/src/com/android/tv/menu/ChannelsPosterPrefetcher.java @@ -97,12 +97,13 @@ public class ChannelsPosterPrefetcher { // This executes on the main thread, but since the item list is expected to be about 5 items // and ImageLoader spawns an async task so this is fast enough. 1 ms in local testing. - List<Channel> channelList = mChannelsAdapter.getItemList(); - if (channelList != null) { - for (Channel channel : channelList) { + List<ChannelsRowItem> items = mChannelsAdapter.getItemList(); + if (items != null) { + for (ChannelsRowItem item : items) { if (isCanceled) { return; } + Channel channel = item.getChannel(); if (!Channel.isValid(channel)) { continue; } @@ -116,7 +117,7 @@ public class ChannelsPosterPrefetcher { } if (DEBUG) { Log.d(TAG, "doPrefetchImages() finished. ImageLoader may still have async tasks for " - + "channels " + channelList); + + "channels " + items); } } diff --git a/src/com/android/tv/menu/ChannelsRow.java b/src/com/android/tv/menu/ChannelsRow.java index dedf0993..490d73de 100644 --- a/src/com/android/tv/menu/ChannelsRow.java +++ b/src/com/android/tv/menu/ChannelsRow.java @@ -26,8 +26,14 @@ import com.android.tv.recommendation.Recommender; public class ChannelsRow extends ItemListRow { public static final String ID = ChannelsRow.class.getName(); - private static final int MIN_COUNT_FOR_RECENT_CHANNELS = 5; - private static final int MAX_COUNT_FOR_RECENT_CHANNELS = 10; + /** + * Minimum count for recent channels. + */ + public static final int MIN_COUNT_FOR_RECENT_CHANNELS = 5; + /** + * Maximum count for recent channels. + */ + public static final int MAX_COUNT_FOR_RECENT_CHANNELS = 10; private Recommender mTvRecommendation; private ChannelsRowAdapter mChannelsAdapter; diff --git a/src/com/android/tv/menu/ChannelsRowAdapter.java b/src/com/android/tv/menu/ChannelsRowAdapter.java index c8e1bd05..7ff44ea6 100644 --- a/src/com/android/tv/menu/ChannelsRowAdapter.java +++ b/src/com/android/tv/menu/ChannelsRowAdapter.java @@ -31,17 +31,15 @@ import com.android.tv.dvr.DvrDataManager; import com.android.tv.recommendation.Recommender; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; -import com.android.tv.util.Utils; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; /** * An adapter of the Channels row. */ -public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> { - private static final String TAG = "ChannelsRowAdapter"; - +public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<ChannelsRowItem> { // There are four special cards: guide, setup, dvr, applink. private static final int SIZE_OF_VIEW_TYPE = 5; @@ -51,7 +49,6 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> private final DvrDataManager mDvrDataManager; private final int mMaxCount; private final int mMinCount; - private final int[] mViewType = new int[SIZE_OF_VIEW_TYPE]; private final View.OnClickListener mGuideOnClickListener = new View.OnClickListener() { @Override @@ -113,14 +110,12 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> mRecommender = recommender; mMinCount = minCount; mMaxCount = maxCount; + setHasStableIds(true); } @Override public int getItemViewType(int position) { - if (position >= SIZE_OF_VIEW_TYPE) { - return R.layout.menu_card_channel; - } - return mViewType[position]; + return getItemList().get(position).getLayoutId(); } @Override @@ -129,9 +124,12 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> } @Override - public void onBindViewHolder(MyViewHolder viewHolder, int position) { - super.onBindViewHolder(viewHolder, position); + public long getItemId(int position) { + return getItemList().get(position).getItemId(); + } + @Override + public void onBindViewHolder(MyViewHolder viewHolder, int position) { int viewType = getItemViewType(position); if (viewType == R.layout.menu_card_guide) { viewHolder.itemView.setOnClickListener(mGuideOnClickListener); @@ -144,80 +142,158 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> SimpleCardView view = (SimpleCardView) viewHolder.itemView; view.setText(R.string.channels_item_dvr); } else { - viewHolder.itemView.setTag(getItemList().get(position)); + viewHolder.itemView.setTag(getItemList().get(position).getChannel()); viewHolder.itemView.setOnClickListener(mChannelOnClickListener); } + super.onBindViewHolder(viewHolder, position); } @Override public void update() { - List<Channel> channelList = new ArrayList<>(); - Channel dummyChannel = new Channel.Builder().build(); - // For guide item - channelList.add(dummyChannel); - // For setup item - TvInputManagerHelper inputManager = TvApplication.getSingletons(mContext) - .getTvInputManagerHelper(); - boolean showSetupCard = SetupUtils.getInstance(mContext).hasNewInput(inputManager); - 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; + if (getItemCount() == 0) { + createItems(); + } else { + updateItems(); + } + } + + private void createItems() { + List<ChannelsRowItem> items = new ArrayList<>(); + items.add(ChannelsRowItem.GUIDE_ITEM); + if (needToShowSetupItem()) { + items.add(ChannelsRowItem.SETUP_ITEM); + } + if (needToShowDvrItem()) { + items.add(ChannelsRowItem.DVR_ITEM); + } + if (needToShowAppLinkItem()) { + ChannelsRowItem.APP_LINK_ITEM.setChannel( + new Channel.Builder(getMainActivity().getCurrentChannel()).build()); + items.add(ChannelsRowItem.APP_LINK_ITEM); + } + for (Channel channel : getRecentChannels()) { + items.add(new ChannelsRowItem(channel, R.layout.menu_card_channel)); + } + setItemList(items); + } + + private void updateItems() { + List<ChannelsRowItem> items = getItemList(); + // The current index of the item list to iterate. It starts from 1 because the first item + // (GUIDE) is always visible and not updated. + int currentIndex = 1; + if (updateItem(needToShowSetupItem(), ChannelsRowItem.SETUP_ITEM, currentIndex)) { + ++currentIndex; + } + if (updateItem(needToShowDvrItem(), ChannelsRowItem.DVR_ITEM, currentIndex)) { + ++currentIndex; + } + if (updateItem(needToShowAppLinkItem(), ChannelsRowItem.APP_LINK_ITEM, currentIndex)) { + if (!getMainActivity().getCurrentChannel() + .hasSameReadOnlyInfo(ChannelsRowItem.APP_LINK_ITEM.getChannel())) { + ChannelsRowItem.APP_LINK_ITEM.setChannel( + new Channel.Builder(getMainActivity().getCurrentChannel()).build()); + notifyItemChanged(currentIndex); + } + ++currentIndex; + } + int numOldChannels = items.size() - currentIndex; + if (numOldChannels > 0) { + while (items.size() > currentIndex) { + items.remove(items.size() - 1); + } + notifyItemRangeRemoved(currentIndex, numOldChannels); + } + for (Channel channel : getRecentChannels()) { + items.add(new ChannelsRowItem(channel, R.layout.menu_card_channel)); + } + int numNewChannels = items.size() - currentIndex; + if (numNewChannels > 0) { + notifyItemRangeInserted(currentIndex, numNewChannels); + } + } + + /** + * Returns {@code true} if the item should be shown. + */ + private boolean updateItem(boolean needToShow, ChannelsRowItem item, int index) { + List<ChannelsRowItem> items = getItemList(); + boolean isItemInList = index < items.size() && item.equals(items.get(index)); + if (needToShow && !isItemInList) { + items.add(index, item); + notifyItemInserted(index); + } else if (!needToShow && isItemInList) { + items.remove(index); + notifyItemRemoved(index); + } + return needToShow; + } + + private boolean needToShowSetupItem() { + TvInputManagerHelper inputManager = + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); + return SetupUtils.getInstance(mContext).hasNewInput(inputManager); + } + + private boolean needToShowDvrItem() { + TvInputManagerHelper inputManager = + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); if (mDvrDataManager != null) { for (TvInputInfo info : inputManager.getTvInputInfos(true, true)) { if (info.canRecord()) { - showDvrCard = true; - break; + return true; } } } + return false; + } - mViewType[0] = R.layout.menu_card_guide; - int index = 1; - if (showSetupCard) { - channelList.add(dummyChannel); - mViewType[index++] = R.layout.menu_card_setup; - } - if (showDvrCard) { - channelList.add(dummyChannel); - mViewType[index++] = R.layout.menu_card_dvr; - } - if (showAppLinkCard) { - channelList.add(currentChannel); - mViewType[index++] = R.layout.menu_card_app_link; - } - for ( ; index < mViewType.length; ++index) { - mViewType[index] = R.layout.menu_card_channel; - } - channelList.addAll(getRecentChannels()); - setItemList(channelList); + private boolean needToShowAppLinkItem() { + TvInputManagerHelper inputManager = + TvApplication.getSingletons(mContext).getTvInputManagerHelper(); + Channel currentChannel = getMainActivity().getCurrentChannel(); + return currentChannel != null + && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE + // Sometimes applicationInfo can be null. b/28932537 + && inputManager.getTvInputAppInfo(currentChannel.getInputId()) != null; } private List<Channel> getRecentChannels() { List<Channel> channelList = new ArrayList<>(); + long currentChannelId = getMainActivity().getCurrentChannelId(); + ArrayDeque<Long> recentChannels = getMainActivity().getRecentChannels(); + // Add the last watched channel as the first one. + for (long channelId : recentChannels) { + if (addChannelToList( + channelList, mRecommender.getChannel(channelId), currentChannelId)) { + break; + } + } + // Add the recommended channels. for (Channel channel : mRecommender.recommendChannels(mMaxCount)) { - if (channel.isBrowsable()) { - channelList.add(channel); + if (channelList.size() >= mMaxCount) { + break; } + addChannelToList(channelList, channel, currentChannelId); } - int count = channelList.size(); // If the number of recommended channels is not enough, add more from the recent channel // list. - if (count < mMinCount) { - for (long channelId : getMainActivity().getRecentChannels()) { - Channel channel = mRecommender.getChannel(channelId); - if (channel == null || channelList.contains(channel) - || !channel.isBrowsable()) { - continue; - } - channelList.add(channel); - if (++count >= mMinCount) { - break; - } + for (long channelId : recentChannels) { + if (channelList.size() >= mMinCount) { + break; } + addChannelToList(channelList, mRecommender.getChannel(channelId), currentChannelId); } return channelList; } + + private static boolean addChannelToList( + List<Channel> channelList, Channel channel, long currentChannelId) { + if (channel == null || channel.getId() == currentChannelId + || channelList.contains(channel) || !channel.isBrowsable()) { + return false; + } + channelList.add(channel); + return true; + } } diff --git a/src/com/android/tv/menu/ChannelsRowItem.java b/src/com/android/tv/menu/ChannelsRowItem.java new file mode 100644 index 00000000..c35189ec --- /dev/null +++ b/src/com/android/tv/menu/ChannelsRowItem.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2017 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.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.android.tv.R; +import com.android.tv.data.Channel; + +/** + * A class for the items in channels row. + */ +public class ChannelsRowItem { + /** The item ID for guide item */ + public static final int GUIDE_ITEM_ID = -1; + /** The item ID for setup item */ + public static final int SETUP_ITEM_ID = -2; + /** The item ID for DVR item */ + public static final int DVR_ITEM_ID = -3; + /** The item ID for app link item */ + public static final int APP_LINK_ITEM_ID = -4; + + /** The item which represents the guide. */ + public static final ChannelsRowItem GUIDE_ITEM = + new ChannelsRowItem(GUIDE_ITEM_ID, R.layout.menu_card_guide); + /** The item which represents the setup. */ + public static final ChannelsRowItem SETUP_ITEM = + new ChannelsRowItem(SETUP_ITEM_ID, R.layout.menu_card_setup); + /** The item which represents the DVR. */ + public static final ChannelsRowItem DVR_ITEM = + new ChannelsRowItem(DVR_ITEM_ID, R.layout.menu_card_dvr); + /** The item which represents the app link. */ + public static final ChannelsRowItem APP_LINK_ITEM = + new ChannelsRowItem(APP_LINK_ITEM_ID, R.layout.menu_card_app_link); + + private final long mItemId; + @NonNull private Channel mChannel; + private final int mLayoutId; + + public ChannelsRowItem(@NonNull Channel channel, int layoutId) { + this(channel.getId(), layoutId); + mChannel = channel; + } + + private ChannelsRowItem(long itemId, int layoutId) { + mItemId = itemId; + mLayoutId = layoutId; + } + + /** + * Returns the channel for this item. + */ + @NonNull + public Channel getChannel() { + return mChannel; + } + + /** + * Sets the channel. + */ + public void setChannel(@NonNull Channel channel) { + mChannel = channel; + } + + /** + * Returns the layout resource ID to represent this item. + */ + public int getLayoutId() { + return mLayoutId; + } + + /** + * Returns the unique ID for this item. + */ + public long getItemId() { + return mItemId; + } + + @Override + public String toString() { + return "ChannelsRowItem{" + + "itemId=" + mItemId + + ", layoutId=" + mLayoutId + + ", channel=" + mChannel + "}"; + } +} diff --git a/src/com/android/tv/menu/ItemListRowView.java b/src/com/android/tv/menu/ItemListRowView.java index 4919c595..cbeee936 100644 --- a/src/com/android/tv/menu/ItemListRowView.java +++ b/src/com/android/tv/menu/ItemListRowView.java @@ -28,6 +28,7 @@ import android.view.ViewGroup; import com.android.tv.MainActivity; import com.android.tv.R; +import com.android.tv.util.ViewCache; import java.util.Collections; import java.util.List; @@ -69,6 +70,8 @@ public class ItemListRowView extends MenuRowView implements OnChildSelectedListe protected void onFinishInflate() { super.onFinishInflate(); mListView = (HorizontalGridView) getContentsView(); + // Disable the position change animation of the cards. + mListView.setItemAnimator(null); } @Override @@ -194,9 +197,24 @@ public class ItemListRowView extends MenuRowView implements OnChildSelectedListe return mItemList.size(); } + /** + * Returns the position of the item. + */ + protected int getItemPosition(T item) { + return mItemList.indexOf(item); + } + + /** + * Returns {@code true} if the item list contains the item, otherwise {@code false}. + */ + protected boolean containsItem(T item) { + return mItemList.contains(item); + } + @Override public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = mLayoutInflater.inflate(getLayoutResId(viewType), parent, false); + View view = ViewCache.getInstance().getOrCreateView( + mLayoutInflater, getLayoutResId(viewType), parent); return new MyViewHolder(view); } diff --git a/src/com/android/tv/menu/Menu.java b/src/com/android/tv/menu/Menu.java index 1160a5b5..e373de61 100644 --- a/src/com/android/tv/menu/Menu.java +++ b/src/com/android/tv/menu/Menu.java @@ -26,24 +26,28 @@ import android.os.Message; import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; +import android.support.v17.leanback.widget.HorizontalGridView; import android.util.Log; import com.android.tv.ChannelTuner; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.analytics.DurationTimer; +import com.android.tv.TvOptionsManager; import com.android.tv.analytics.Tracker; import com.android.tv.common.TvCommonUtils; import com.android.tv.common.WeakHandler; 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 com.android.tv.util.DurationTimer; +import com.android.tv.util.ViewCache; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * A class which controls the menu. @@ -81,10 +85,21 @@ public class Menu { sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_NEXT } + private static final Map<Integer, Integer> PRELOAD_VIEW_IDS = new HashMap<>(); + static { + PRELOAD_VIEW_IDS.put(R.layout.menu_card_guide, 1); + PRELOAD_VIEW_IDS.put(R.layout.menu_card_setup, 1); + PRELOAD_VIEW_IDS.put(R.layout.menu_card_dvr, 1); + PRELOAD_VIEW_IDS.put(R.layout.menu_card_app_link, 1); + PRELOAD_VIEW_IDS.put(R.layout.menu_card_channel, ChannelsRow.MAX_COUNT_FOR_RECENT_CHANNELS); + PRELOAD_VIEW_IDS.put(R.layout.menu_card_action, 7); + } + private static final String SCREEN_NAME = "Menu"; private static final int MSG_HIDE_MENU = 1000; + private final Context mContext; private final IMenuView mMenuView; private final Tracker mTracker; private final DurationTimer mVisibleTimer = new DurationTimer(); @@ -103,15 +118,16 @@ public class Menu { @VisibleForTesting Menu(Context context, IMenuView menuView, MenuRowFactory menuRowFactory, OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { - this(context, null, menuView, menuRowFactory, onMenuVisibilityChangeListener); + this(context, null, null, menuView, menuRowFactory, onMenuVisibilityChangeListener); } - public Menu(Context context, TunableTvView tvView, IMenuView menuView, - MenuRowFactory menuRowFactory, + public Menu(Context context, TunableTvView tvView, TvOptionsManager optionsManager, + IMenuView menuView, MenuRowFactory menuRowFactory, OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) { + mContext = context; mMenuView = menuView; mTracker = TvApplication.getSingletons(context).getTracker(); - mMenuUpdater = new MenuUpdater(context, tvView, this); + mMenuUpdater = new MenuUpdater(this, tvView, optionsManager); Resources res = context.getResources(); mShowDurationMillis = res.getInteger(R.integer.menu_show_duration); mOnMenuVisibilityChangeListener = onMenuVisibilityChangeListener; @@ -130,7 +146,6 @@ public class Menu { addMenuRow(menuRowFactory.createMenuRow(this, ChannelsRow.class)); addMenuRow(menuRowFactory.createMenuRow(this, PartnerRow.class)); addMenuRow(menuRowFactory.createMenuRow(this, TvOptionsRow.class)); - addMenuRow(menuRowFactory.createMenuRow(this, PipOptionsRow.class)); mMenuView.setMenuRows(mMenuRows); } @@ -160,6 +175,16 @@ public class Menu { } /** + * Preloads the item view used for the menu. + */ + public void preloadItemViews() { + HorizontalGridView fakeParent = new HorizontalGridView(mContext); + for (int id : PRELOAD_VIEW_IDS.keySet()) { + ViewCache.getInstance().putView(mContext, id, fakeParent, PRELOAD_VIEW_IDS.get(id)); + } + } + + /** * Shows the main menu. * * @param reason A reason why this is called. See {@link MenuShowReason} @@ -293,9 +318,7 @@ public class Menu { */ public void onStreamInfoChanged() { if (DEBUG) Log.d(TAG, "update options row in main menu"); - for (MenuRow row : mMenuRows) { - row.onStreamInfoChanged(); - } + mMenuUpdater.onStreamInfoChanged(); } @VisibleForTesting diff --git a/src/com/android/tv/menu/MenuAction.java b/src/com/android/tv/menu/MenuAction.java index 0d59552a..b4356059 100644 --- a/src/com/android/tv/menu/MenuAction.java +++ b/src/com/android/tv/menu/MenuAction.java @@ -20,9 +20,9 @@ import android.content.Context; import android.graphics.drawable.Drawable; import android.text.TextUtils; -import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvOptionsManager; +import com.android.tv.TvOptionsManager.OptionType; /** * A class to define possible actions from main menu. @@ -36,12 +36,9 @@ public class MenuAction { public static final MenuAction SELECT_DISPLAY_MODE_ACTION = new MenuAction(R.string.options_item_display_mode, TvOptionsManager.OPTION_DISPLAY_MODE, R.drawable.ic_tvoption_aspect); - public static final MenuAction PIP_IN_APP_ACTION = - new MenuAction(R.string.options_item_pip, TvOptionsManager.OPTION_IN_APP_PIP, - 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_pip_option_layout2); + R.drawable.ic_tvoption_pip); 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); @@ -51,34 +48,36 @@ public class MenuAction { 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, R.drawable.ic_settings); - // Actions in the PIP option row. - public static final MenuAction PIP_SELECT_INPUT_ACTION = - new MenuAction(R.string.pip_options_item_source, TvOptionsManager.OPTION_PIP_INPUT, - R.drawable.ic_pip_option_input); - public static final MenuAction PIP_SWAP_ACTION = - new MenuAction(R.string.pip_options_item_swap, TvOptionsManager.OPTION_PIP_SWAP, - R.drawable.ic_pip_option_swap); - public static final MenuAction PIP_SOUND_ACTION = - new MenuAction(R.string.pip_options_item_sound, TvOptionsManager.OPTION_PIP_SOUND, - R.drawable.ic_pip_option_swap_audio); - public static final MenuAction PIP_LAYOUT_ACTION = - new MenuAction(R.string.pip_options_item_layout, TvOptionsManager.OPTION_PIP_LAYOUT, - R.drawable.ic_pip_option_layout1); - public static final MenuAction PIP_SIZE_ACTION = - new MenuAction(R.string.pip_options_item_size, TvOptionsManager.OPTION_PIP_SIZE, - R.drawable.ic_pip_option_size); private final String mActionName; private final int mActionNameResId; - private final int mType; + @OptionType private final int mType; + private String mActionDescription; private Drawable mDrawable; private int mDrawableResId; private boolean mEnabled = true; + /** + * Sets the action description. Returns {@code trye} if the description is changed. + */ + public static boolean setActionDescription(MenuAction action, String actionDescription) { + String oldDescription = action.mActionDescription; + action.mActionDescription = actionDescription; + return !TextUtils.equals(action.mActionDescription, oldDescription); + } + + /** + * Enables or disables the action. Returns {@code true} if the value is changed. + */ + public static boolean setEnabled(MenuAction action, boolean enabled) { + boolean changed = action.mEnabled != enabled; + action.mEnabled = enabled; + return changed; + } + public MenuAction(int actionNameResId, int type, int drawableResId) { mActionName = null; mActionNameResId = actionNameResId; @@ -102,11 +101,11 @@ public class MenuAction { return context.getString(mActionNameResId); } - public String getActionDescription(Context context) { - return ((MainActivity) context).getTvOptionsManager().getOptionString(mType); + public String getActionDescription() { + return mActionDescription; } - public int getType() { + @OptionType public int getType() { return mType; } @@ -120,28 +119,10 @@ public class MenuAction { return mDrawable; } - /** - * Sets drawable resource id. - * - * @return {@code true} if drawable is changed. - */ - public boolean setDrawableResId(int resId) { - if (mDrawableResId == resId) { - return false; - } - mDrawable = null; - mDrawableResId = resId; - return true; - } - public boolean isEnabled() { return mEnabled; } - public void setEnabled(boolean enabled) { - mEnabled = enabled; - } - public int getActionNameResId() { return mActionNameResId; } diff --git a/src/com/android/tv/menu/MenuLayoutManager.java b/src/com/android/tv/menu/MenuLayoutManager.java index 6c767247..173d4004 100644 --- a/src/com/android/tv/menu/MenuLayoutManager.java +++ b/src/com/android/tv/menu/MenuLayoutManager.java @@ -28,6 +28,7 @@ import android.support.annotation.UiThread; import android.support.v4.view.animation.FastOutLinearInInterpolator; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v4.view.animation.LinearOutSlowInInterpolator; +import android.support.v7.widget.RecyclerView; import android.util.Log; import android.util.Property; import android.view.View; @@ -56,12 +57,14 @@ public class MenuLayoutManager { // The visible duration of the title before it is hidden. private static final long TITLE_SHOW_DURATION_BEFORE_HIDDEN_MS = TimeUnit.SECONDS.toMillis(2); + private static final int INVALID_POSITION = -1; private final MenuView mMenuView; private final List<MenuRow> mMenuRows = new ArrayList<>(); private final List<MenuRowView> mMenuRowViews = new ArrayList<>(); private final List<Integer> mRemovingRowViews = new ArrayList<>(); - private int mSelectedPosition = -1; + private int mSelectedPosition = INVALID_POSITION; + private int mPendingSelectedPosition = INVALID_POSITION; private final int mRowAlignFromBottom; private final int mRowContentsPaddingTop; @@ -130,8 +133,8 @@ public class MenuLayoutManager { MenuRowView currentView = mMenuRowViews.get(mSelectedPosition); if (currentView.getVisibility() == View.GONE) { // If the selected row is not visible, select the first visible row. - int firstVisiblePosition = findNextVisiblePosition(-1); - if (firstVisiblePosition != -1) { + int firstVisiblePosition = findNextVisiblePosition(INVALID_POSITION); + if (firstVisiblePosition != INVALID_POSITION) { mSelectedPosition = firstVisiblePosition; } else { // No rows are visible. @@ -157,6 +160,10 @@ public class MenuLayoutManager { view.onDeselected(); } } + + if (mPendingSelectedPosition != INVALID_POSITION) { + setSelectedPositionSmooth(mPendingSelectedPosition); + } } private int findNextVisiblePosition(int start) { @@ -166,7 +173,7 @@ public class MenuLayoutManager { return i; } } - return -1; + return INVALID_POSITION; } private void dumpChildren(String prefix) { @@ -327,6 +334,7 @@ public class MenuLayoutManager { mMenuRowViews.get(mSelectedPosition).onDeselected(); } mSelectedPosition = position; + mPendingSelectedPosition = INVALID_POSITION; if (Utils.isIndexValid(mMenuRowViews, mSelectedPosition)) { mMenuRowViews.get(mSelectedPosition).onSelected(false); } @@ -380,14 +388,29 @@ public class MenuLayoutManager { // again from the intermediate state. mTitleFadeOutAnimator.cancel(); } - final int oldPosition = mSelectedPosition; - mSelectedPosition = position; if (DEBUG) dumpChildren("startRowAnimation()"); - MenuRowView currentView = mMenuRowViews.get(position); // Show the children of the next row. - currentView.getTitleView().setVisibility(View.VISIBLE); - currentView.getContentsView().setVisibility(View.VISIBLE); + final MenuRowView currentView = mMenuRowViews.get(position); + TextView currentTitleView = currentView.getTitleView(); + View currentContentsView = currentView.getContentsView(); + currentTitleView.setVisibility(View.VISIBLE); + currentContentsView.setVisibility(View.VISIBLE); + if (currentView instanceof PlayControlsRowView) { + ((PlayControlsRowView) currentView).onPreselected(); + } + // When contents view's visibility is gone, layouting might be delayed until it's shown and + // thus cause onBindViewHolder() and menu action updating occurs in front of users' sight. + // Therefore we call requestLayout() here if there are pending adapter updates. + if (currentContentsView instanceof RecyclerView + && ((RecyclerView) currentContentsView).hasPendingAdapterUpdates()) { + currentContentsView.requestLayout(); + mPendingSelectedPosition = position; + return; + } + final int oldPosition = mSelectedPosition; + mSelectedPosition = position; + mPendingSelectedPosition = INVALID_POSITION; // Request focus after the new contents view shows up. mMenuView.requestFocus(); if (mTempTitleViewForOld == null) { @@ -407,7 +430,7 @@ public class MenuLayoutManager { // Old row. MenuRow oldRow = mMenuRows.get(oldPosition); - MenuRowView oldView = mMenuRowViews.get(oldPosition); + final MenuRowView oldView = mMenuRowViews.get(oldPosition); View oldContentsView = oldView.getContentsView(); // Old contents view. animators.add(createAlphaAnimator(oldContentsView, 1.0f, 0.0f, 1.0f, mLinearOutSlowIn) @@ -468,8 +491,6 @@ public class MenuLayoutManager { } // Current row. Rect currentLayoutRect = new Rect(layouts.get(position)); - TextView currentTitleView = currentView.getTitleView(); - View currentContentsView = currentView.getContentsView(); currentContentsView.setAlpha(0.0f); if (scrollDown) { // Current title view. @@ -529,7 +550,7 @@ public class MenuLayoutManager { int nextPosition; if (scrollDown) { nextPosition = findNextVisiblePosition(position); - if (nextPosition != -1) { + if (nextPosition != INVALID_POSITION) { MenuRowView nextView = mMenuRowViews.get(nextPosition); Rect nextLayoutRect = layouts.get(nextPosition); animators.add(createTranslationYAnimator(nextView, @@ -539,7 +560,7 @@ public class MenuLayoutManager { } } else { nextPosition = findNextVisiblePosition(oldPosition); - if (nextPosition != -1) { + if (nextPosition != INVALID_POSITION) { MenuRowView nextView = mMenuRowViews.get(nextPosition); animators.add(createTranslationYAnimator(nextView, 0, mRowScrollUpAnimationOffset)); animators.add(createAlphaAnimator(nextView, @@ -572,9 +593,8 @@ public class MenuLayoutManager { for (ViewPropertyValueHolder holder : propertyValuesAfterAnimation) { holder.property.set(holder.view, holder.value); } - oldTitleView.setVisibility(View.VISIBLE); - mMenuRowViews.get(oldPosition).onDeselected(); - mMenuRowViews.get(position).onSelected(true); + oldView.onDeselected(); + currentView.onSelected(true); mTempTitleViewForOld.setVisibility(View.GONE); mTempTitleViewForCurrent.setVisibility(View.GONE); layout(mMenuView.getLeft(), mMenuView.getTop(), mMenuView.getRight(), diff --git a/src/com/android/tv/menu/MenuRow.java b/src/com/android/tv/menu/MenuRow.java index 6f98e615..47804f11 100644 --- a/src/com/android/tv/menu/MenuRow.java +++ b/src/com/android/tv/menu/MenuRow.java @@ -123,11 +123,6 @@ public abstract class MenuRow { public void onRecentChannelsChanged() { } /** - * This method is called when stream information is changed. - */ - public void onStreamInfoChanged() { } - - /** * Returns whether to hide the title when the row is selected. */ public boolean hideTitleWhenSelected() { diff --git a/src/com/android/tv/menu/MenuRowFactory.java b/src/com/android/tv/menu/MenuRowFactory.java index c67a0e04..570cfb8f 100644 --- a/src/com/android/tv/menu/MenuRowFactory.java +++ b/src/com/android/tv/menu/MenuRowFactory.java @@ -67,8 +67,6 @@ public class MenuRowFactory { } else if (TvOptionsRow.class.equals(key)) { return new TvOptionsRow(mMainActivity, menu, mTvCustomizationManager .getCustomActions(TvCustomizationManager.ID_OPTIONS_ROW)); - } else if (PipOptionsRow.class.equals(key)) { - return new PipOptionsRow(mMainActivity, menu); } return null; } @@ -77,36 +75,15 @@ public class MenuRowFactory { * A menu row which represents the TV options row. */ public static class TvOptionsRow extends ItemListRow { + /** + * The ID of the row. + */ + public static final String ID = TvOptionsRow.class.getName(); + private TvOptionsRow(Context context, Menu menu, List<CustomAction> customActions) { super(context, menu, R.string.menu_title_options, R.dimen.action_card_height, new TvOptionsRowAdapter(context, customActions)); } - - @Override - public void onStreamInfoChanged() { - if (getMenu().isActive()) { - update(); - } - } - } - - /** - * A menu row which represents the PIP options row. - */ - public static class PipOptionsRow extends ItemListRow { - private final MainActivity mMainActivity; - - private PipOptionsRow(Context context, Menu menu) { - super(context, menu, R.string.menu_title_pip_options, R.dimen.action_card_height, - new PipOptionsRowAdapter(context)); - mMainActivity = (MainActivity) context; - } - - @Override - public boolean isVisible() { - // TODO: Remove the dependency on MainActivity. - return super.isVisible() && mMainActivity.isPipEnabled(); - } } /** diff --git a/src/com/android/tv/menu/MenuUpdater.java b/src/com/android/tv/menu/MenuUpdater.java index 075b299e..18416c85 100644 --- a/src/com/android/tv/menu/MenuUpdater.java +++ b/src/com/android/tv/menu/MenuUpdater.java @@ -16,11 +16,14 @@ package com.android.tv.menu; -import android.content.Context; import android.support.annotation.Nullable; import com.android.tv.ChannelTuner; +import com.android.tv.TvOptionsManager; +import com.android.tv.TvOptionsManager.OptionChangedListener; +import com.android.tv.TvOptionsManager.OptionType; import com.android.tv.data.Channel; +import com.android.tv.menu.MenuRowFactory.TvOptionsRow; import com.android.tv.ui.TunableTvView; import com.android.tv.ui.TunableTvView.OnScreenBlockingChangedListener; @@ -30,10 +33,10 @@ import com.android.tv.ui.TunableTvView.OnScreenBlockingChangedListener; * <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; + // Can be null for testing. + @Nullable private final TunableTvView mTvView; + @Nullable private final TvOptionsManager mOptionsManager; private ChannelTuner mChannelTuner; private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() { @@ -42,7 +45,7 @@ public class MenuUpdater { @Override public void onBrowsableChannelListChanged() { - mMenu.update(); + mMenu.update(ChannelsRow.ID); } @Override @@ -53,10 +56,17 @@ public class MenuUpdater { mMenu.update(ChannelsRow.ID); } }; + private final OptionChangedListener mOptionChangeListener = new OptionChangedListener() { + @Override + public void onOptionChanged(@OptionType int optionType, String newString) { + mMenu.update(TvOptionsRow.ID); + } + }; - public MenuUpdater(Context context, TunableTvView tvView, Menu menu) { - mTvView = tvView; + public MenuUpdater(Menu menu, TunableTvView tvView, TvOptionsManager optionsManager) { mMenu = menu; + mTvView = tvView; + mOptionsManager = optionsManager; if (mTvView != null) { mTvView.setOnScreenBlockedListener(new OnScreenBlockingChangedListener() { @Override @@ -65,11 +75,18 @@ public class MenuUpdater { } }); } + if (mOptionsManager != null) { + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_CLOSED_CAPTIONS, + mOptionChangeListener); + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_DISPLAY_MODE, + mOptionChangeListener); + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_MULTI_AUDIO, + mOptionChangeListener); + } } /** - * Sets the instance of {@link ChannelTuner}. Call this method when the channel tuner is ready - * or not available any more. + * Sets the instance of {@link ChannelTuner}. Call this method when the channel tuner is ready. */ public void setChannelTuner(ChannelTuner channelTuner) { if (mChannelTuner != null) { @@ -79,7 +96,13 @@ public class MenuUpdater { if (mChannelTuner != null) { mChannelTuner.addListener(mChannelTunerListener); } - mMenu.update(); + } + + /** + * Called when the stream information changes. + */ + public void onStreamInfoChanged() { + mMenu.update(TvOptionsRow.ID); } /** @@ -92,5 +115,10 @@ public class MenuUpdater { if (mTvView != null) { mTvView.setOnScreenBlockedListener(null); } + if (mOptionsManager != null) { + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_CLOSED_CAPTIONS, null); + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_DISPLAY_MODE, null); + mOptionsManager.setOptionChangedListener(TvOptionsManager.OPTION_MULTI_AUDIO, null); + } } } diff --git a/src/com/android/tv/menu/OptionsRowAdapter.java b/src/com/android/tv/menu/OptionsRowAdapter.java index 93bd0a4d..dd6194a1 100644 --- a/src/com/android/tv/menu/OptionsRowAdapter.java +++ b/src/com/android/tv/menu/OptionsRowAdapter.java @@ -21,8 +21,6 @@ import android.view.View; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.TvOptionsManager; -import com.android.tv.TvOptionsManager.OptionChangedListener; import com.android.tv.analytics.Tracker; import java.util.List; @@ -66,12 +64,9 @@ public abstract class OptionsRowAdapter extends ItemListRowView.ItemListAdapter< public void update() { if (mActionList == null) { mActionList = createActions(); - updateActions(); setItemList(mActionList); } else { - if (updateActions()) { - setItemList(mActionList); - } + updateActions(); } } @@ -81,7 +76,7 @@ public abstract class OptionsRowAdapter extends ItemListRowView.ItemListAdapter< } protected abstract List<MenuAction> createActions(); - protected abstract boolean updateActions(); + protected abstract void updateActions(); protected abstract void executeAction(int type); /** @@ -93,37 +88,6 @@ public abstract class OptionsRowAdapter extends ItemListRowView.ItemListAdapter< return mActionList.get(position); } - /** - * Sets the action at the given position. - * Note that action at the position may differ from returned by {@link #createActions}. - * See {@link CustomizableOptionsRowAdapter} - */ - protected void setAction(int position, MenuAction action) { - mActionList.set(position, action); - } - - /** - * Adds an action to the given position. - * Note that action at the position may differ from returned by {@link #createActions}. - * See {@link CustomizableOptionsRowAdapter} - */ - protected void addAction(int position, MenuAction action) { - mActionList.add(position, action); - } - - /** - * Removes an action at the given position. - * Note that action at the position may differ from returned by {@link #createActions}. - * See {@link CustomizableOptionsRowAdapter} - */ - protected void removeAction(int position) { - mActionList.remove(position); - } - - protected int getActionSize() { - return mActionList.size(); - } - @Override public void onBindViewHolder(MyViewHolder viewHolder, int position) { super.onBindViewHolder(viewHolder, position); @@ -139,14 +103,4 @@ public abstract class OptionsRowAdapter extends ItemListRowView.ItemListAdapter< // be preserved. return mActionList.get(position).getType(); } - - protected void setOptionChangedListener(final MenuAction action) { - TvOptionsManager om = getMainActivity().getTvOptionsManager(); - om.setOptionChangedListener(action.getType(), new OptionChangedListener() { - @Override - public void onOptionChanged(String newOption) { - setItemList(mActionList); - } - }); - } } diff --git a/src/com/android/tv/menu/PartnerOptionsRowAdapter.java b/src/com/android/tv/menu/PartnerOptionsRowAdapter.java index f3e09f80..c8249a4c 100644 --- a/src/com/android/tv/menu/PartnerOptionsRowAdapter.java +++ b/src/com/android/tv/menu/PartnerOptionsRowAdapter.java @@ -38,8 +38,7 @@ public class PartnerOptionsRowAdapter extends CustomizableOptionsRowAdapter { } @Override - protected boolean updateActions() { + protected void updateActions() { // TODO: Support adding description for custom actions. - return false; } } diff --git a/src/com/android/tv/menu/PipOptionsRowAdapter.java b/src/com/android/tv/menu/PipOptionsRowAdapter.java deleted file mode 100644 index 87203e9d..00000000 --- a/src/com/android/tv/menu/PipOptionsRowAdapter.java +++ /dev/null @@ -1,137 +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.text.TextUtils; - -import com.android.tv.MainActivity; -import com.android.tv.R; -import com.android.tv.TvOptionsManager; -import com.android.tv.ui.TvViewUiManager; -import com.android.tv.ui.sidepanel.PipInputSelectorFragment; -import com.android.tv.util.PipInputManager.PipInput; -import com.android.tv.util.TvSettings; - -import java.util.ArrayList; -import java.util.List; - -/* - * An adapter of PIP options. - */ -public class PipOptionsRowAdapter extends OptionsRowAdapter { - private static final int[] DRAWABLE_ID_FOR_LAYOUT = { - R.drawable.ic_pip_option_layout1, - R.drawable.ic_pip_option_layout2, - R.drawable.ic_pip_option_layout3, - R.drawable.ic_pip_option_layout4, - R.drawable.ic_pip_option_layout5 }; - - private final TvOptionsManager mTvOptionsManager; - private final TvViewUiManager mTvViewUiManager; - - public PipOptionsRowAdapter(Context context) { - super(context); - mTvOptionsManager = getMainActivity().getTvOptionsManager(); - mTvViewUiManager = getMainActivity().getTvViewUiManager(); - } - - @Override - protected List<MenuAction> createActions() { - List<MenuAction> actionList = new ArrayList<>(); - actionList.add(MenuAction.PIP_SELECT_INPUT_ACTION); - actionList.add(MenuAction.PIP_SWAP_ACTION); - actionList.add(MenuAction.PIP_SOUND_ACTION); - actionList.add(MenuAction.PIP_LAYOUT_ACTION); - actionList.add(MenuAction.PIP_SIZE_ACTION); - for (MenuAction action : actionList) { - setOptionChangedListener(action); - } - return actionList; - } - - @Override - public boolean updateActions() { - boolean changed = false; - if (updateSelectInputAction()) { - changed = true; - } - if (updateLayoutAction()) { - changed = true; - } - if (updateSizeAction()) { - changed = true; - } - return changed; - } - - private boolean updateSelectInputAction() { - String oldInputLabel = mTvOptionsManager.getOptionString(TvOptionsManager.OPTION_PIP_INPUT); - - MainActivity tvActivity = getMainActivity(); - PipInput newInput = tvActivity.getPipInputManager().getPipInput(tvActivity.getPipChannel()); - String newInputLabel = newInput == null ? null : newInput.getLabel(); - - if (!TextUtils.equals(oldInputLabel, newInputLabel)) { - mTvOptionsManager.onPipInputChanged(newInputLabel); - return true; - } - return false; - } - - private boolean updateLayoutAction() { - return MenuAction.PIP_LAYOUT_ACTION.setDrawableResId( - DRAWABLE_ID_FOR_LAYOUT[mTvViewUiManager.getPipLayout()]); - } - - private boolean updateSizeAction() { - boolean oldEnabled = MenuAction.PIP_SIZE_ACTION.isEnabled(); - boolean newEnabled = mTvViewUiManager.getPipLayout() != TvSettings.PIP_LAYOUT_SIDE_BY_SIDE; - if (oldEnabled != newEnabled) { - MenuAction.PIP_SIZE_ACTION.setEnabled(newEnabled); - return true; - } - return false; - } - - @Override - protected void executeAction(int type) { - switch (type) { - case TvOptionsManager.OPTION_PIP_INPUT: - getMainActivity().getOverlayManager().getSideFragmentManager().show( - new PipInputSelectorFragment()); - break; - case TvOptionsManager.OPTION_PIP_SWAP: - getMainActivity().swapPip(); - break; - case TvOptionsManager.OPTION_PIP_SOUND: - getMainActivity().togglePipSoundMode(); - break; - case TvOptionsManager.OPTION_PIP_LAYOUT: - int oldLayout = mTvViewUiManager.getPipLayout(); - int newLayout = (oldLayout + 1) % (TvSettings.PIP_LAYOUT_LAST + 1); - mTvViewUiManager.setPipLayout(newLayout, true); - MenuAction.PIP_LAYOUT_ACTION.setDrawableResId(DRAWABLE_ID_FOR_LAYOUT[newLayout]); - break; - case TvOptionsManager.OPTION_PIP_SIZE: - int oldSize = mTvViewUiManager.getPipSize(); - int newSize = (oldSize + 1) % (TvSettings.PIP_SIZE_LAST + 1); - mTvViewUiManager.setPipSize(newSize, true); - break; - } - } -} diff --git a/src/com/android/tv/menu/PlayControlsButton.java b/src/com/android/tv/menu/PlayControlsButton.java index aff39db3..77715f28 100644 --- a/src/com/android/tv/menu/PlayControlsButton.java +++ b/src/com/android/tv/menu/PlayControlsButton.java @@ -39,6 +39,9 @@ public class PlayControlsButton extends FrameLayout { private final int mIconColor; private int mIconFocusedColor; + private int mImageResourceId; + private int mTintColor; + public PlayControlsButton(Context context) { this(context, null); } @@ -67,10 +70,21 @@ public class PlayControlsButton extends FrameLayout { * Sets the resource ID of the image to be displayed in the center of this control. */ public void setImageResId(int imageResId) { - mIcon.setImageResource(imageResId); - // Since on foucus changing, icons' color should be switched with animation, + int newTintColor = hasFocus() ? mIconFocusedColor : mIconColor; + if (mImageResourceId != imageResId) { + mImageResourceId = imageResId; + mIcon.setImageResource(imageResId); + updateTint(newTintColor); + } else if (newTintColor != mTintColor) { + updateTint(newTintColor); + } + } + + private void updateTint(int tintColor) { + mTintColor = tintColor; + // Since on focus 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); + mIcon.getDrawable().setTint(tintColor); } /** @@ -117,7 +131,9 @@ public class PlayControlsButton extends FrameLayout { } else { mIcon.setVisibility(View.GONE); mLabel.setVisibility(View.VISIBLE); - mLabel.setText(label); + if (!TextUtils.equals(mLabel.getText(), label)) { + mLabel.setText(label); + } } } diff --git a/src/com/android/tv/menu/PlayControlsRowView.java b/src/com/android/tv/menu/PlayControlsRowView.java index a620d4dd..4d766788 100644 --- a/src/com/android/tv/menu/PlayControlsRowView.java +++ b/src/com/android/tv/menu/PlayControlsRowView.java @@ -18,10 +18,10 @@ package com.android.tv.menu; import android.content.Context; import android.content.res.Resources; +import android.text.TextUtils; import android.text.format.DateFormat; import android.util.AttributeSet; import android.view.View; -import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; @@ -34,17 +34,16 @@ 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.dialog.HalfSizedDialogFragment; 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.data.ScheduledRecording; import com.android.tv.dvr.ui.DvrStopRecordingFragment; -import com.android.tv.dvr.ui.HalfSizedDialogFragment; +import com.android.tv.dvr.ui.DvrUiHelper; 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; @@ -53,14 +52,10 @@ public class PlayControlsRowView extends MenuRowView { private final int mTimeTextLeftMargin; private final int mTimelineWidth; // Views - private View mBackgroundView; + private TextView mBackgroundView; private View mTimeIndicator; private TextView mTimeText; - private View mProgressEmptyBefore; - private View mProgressWatched; - private View mProgressBuffered; - private View mProgressEmptyAfter; - private View mControlBar; + private PlaybackProgressBar mProgress; private PlayControlsButton mJumpPreviousButton; private PlayControlsButton mRewindButton; private PlayControlsButton mPlayPauseButton; @@ -69,7 +64,6 @@ public class PlayControlsRowView extends MenuRowView { private PlayControlsButton mRecordButton; private TextView mProgramStartTimeText; private TextView mProgramEndTimeText; - private View mUnavailableMessageText; private TunableTvView mTvView; private TimeShiftManager mTimeShiftManager; private final DvrDataManager mDvrDataManager; @@ -83,6 +77,8 @@ public class PlayControlsRowView extends MenuRowView { private final int mNormalButtonMargin; private final int mCompactButtonMargin; + private final String mUnavailableMessage; + private final ScheduledRecordingListener mScheduledRecordingListener = new ScheduledRecordingListener() { @Override @@ -138,6 +134,7 @@ public class PlayControlsRowView extends MenuRowView { mDvrManager = null; } mMainActivity = (MainActivity) context; + mUnavailableMessage = res.getString(R.string.play_controls_unavailable); } @Override @@ -171,14 +168,10 @@ public class PlayControlsRowView extends MenuRowView { super.onFinishInflate(); // Clip the ViewGroup(body) to the rounded rectangle of outline. findViewById(R.id.body).setClipToOutline(true); - mBackgroundView = findViewById(R.id.background); + mBackgroundView = (TextView) findViewById(R.id.background); mTimeIndicator = findViewById(R.id.time_indicator); mTimeText = (TextView) findViewById(R.id.time_text); - mProgressEmptyBefore = findViewById(R.id.timeline_bg_start); - mProgressWatched = findViewById(R.id.watched); - mProgressBuffered = findViewById(R.id.buffered); - mProgressEmptyAfter = findViewById(R.id.timeline_bg_end); - mControlBar = findViewById(R.id.play_control_bar); + mProgress = (PlaybackProgressBar) findViewById(R.id.progress); mJumpPreviousButton = (PlayControlsButton) findViewById(R.id.jump_previous); mRewindButton = (PlayControlsButton) findViewById(R.id.rewind); mPlayPauseButton = (PlayControlsButton) findViewById(R.id.play_pause); @@ -187,7 +180,6 @@ public class PlayControlsRowView extends MenuRowView { 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, null, new Runnable() { @@ -195,7 +187,7 @@ public class PlayControlsRowView extends MenuRowView { public void run() { if (mTimeShiftManager.isAvailable()) { mTimeShiftManager.jumpToPrevious(); - updateControls(); + updateControls(true); } } }); @@ -235,7 +227,7 @@ public class PlayControlsRowView extends MenuRowView { public void run() { if (mTimeShiftManager.isAvailable()) { mTimeShiftManager.jumpToNext(); - updateControls(); + updateControls(true); } } }); @@ -265,18 +257,17 @@ public class PlayControlsRowView extends MenuRowView { 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())) { + } else { 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(); - } + DvrUiHelper.checkStorageStatusAndShowErrorMessage(mMainActivity, + currentChannel.getInputId(), new Runnable() { + @Override + public void run() { + DvrUiHelper.requestRecordingCurrentProgram(mMainActivity, + currentChannel, program, true); + } + }); } } else if (currentChannel != null) { DvrUiHelper.showStopRecordingDialog(mMainActivity, currentChannel.getId(), @@ -318,39 +309,37 @@ public class PlayControlsRowView extends MenuRowView { @Override public void onAvailabilityChanged() { updateMenuVisibility(); - if (isShown()) { - PlayControlsRowView.this.updateAll(); - } + PlayControlsRowView.this.updateAll(false); } @Override public void onPlayStatusChanged(int status) { updateMenuVisibility(); - if (mTimeShiftManager.isAvailable() && isShown()) { - updateControls(); + if (mTimeShiftManager.isAvailable()) { + updateControls(false); } } @Override public void onRecordTimeRangeChanged() { - if (mTimeShiftManager.isAvailable() && isShown()) { - updateControls(); + if (mTimeShiftManager.isAvailable()) { + updateControls(false); } } @Override public void onCurrentPositionChanged() { - if (mTimeShiftManager.isAvailable() && isShown()) { + if (mTimeShiftManager.isAvailable()) { initializeTimeline(); - updateControls(); + updateControls(false); } } @Override public void onProgramInfoChanged() { - if (mTimeShiftManager.isAvailable() && isShown()) { + if (mTimeShiftManager.isAvailable()) { initializeTimeline(); - updateControls(); + updateControls(false); } } @@ -372,7 +361,8 @@ public class PlayControlsRowView extends MenuRowView { } } }); - updateAll(); + // force update to initialize everything + updateAll(true); } private void initializeTimeline() { @@ -380,6 +370,8 @@ public class PlayControlsRowView extends MenuRowView { mTimeShiftManager.getCurrentPositionMs()); mProgramStartTimeMs = program.getStartTimeUtcMillis(); mProgramEndTimeMs = program.getEndTimeUtcMillis(); + mProgress.setMax(mProgramEndTimeMs - mProgramStartTimeMs); + updateRecTimeText(); SoftPreconditions.checkArgument(mProgramStartTimeMs <= mProgramEndTimeMs); } @@ -389,10 +381,13 @@ public class PlayControlsRowView extends MenuRowView { getMenu().setKeepVisible(keepMenuVisible); } + public void onPreselected() { + updateControls(true); + } + @Override public void onSelected(boolean showTitle) { super.onSelected(showTitle); - updateControls(); postHideRippleAnimation(); } @@ -474,28 +469,32 @@ public class PlayControlsRowView extends MenuRowView { * Updates the view contents. It is called from the PlayControlsRow. */ public void update() { - updateAll(); + updateAll(false); } - private void updateAll() { + private void updateAll(boolean forceUpdate) { if (mTimeShiftManager.isAvailable() && !mTvView.isScreenBlocked()) { setEnabled(true); initializeTimeline(); mBackgroundView.setEnabled(true); + setTextIfNeeded(mBackgroundView, null); } else { setEnabled(false); mBackgroundView.setEnabled(false); + setTextIfNeeded(mBackgroundView, mUnavailableMessage); } - updateControls(); + // force the controls be updated no matter it's visible or not. + updateControls(forceUpdate); } - private void updateControls() { - updateTime(); - updateProgress(); - updateRecTimeText(); - updateButtons(); - updateRecordButton(); - updateButtonMargin(); + private void updateControls(boolean forceUpdate) { + if (forceUpdate || getContentsView().isShown()) { + updateTime(); + updateProgress(); + updateButtons(); + updateRecordButton(); + updateButtonMargin(); + } } private void updateTime() { @@ -504,70 +503,39 @@ public class PlayControlsRowView extends MenuRowView { mTimeIndicator.setVisibility(View.VISIBLE); } else { mTimeText.setVisibility(View.INVISIBLE); - mTimeIndicator.setVisibility(View.INVISIBLE); + mTimeIndicator.setVisibility(View.GONE); return; } long currentPositionMs = mTimeShiftManager.getCurrentPositionMs(); - ViewGroup.MarginLayoutParams params = - (ViewGroup.MarginLayoutParams) mTimeText.getLayoutParams(); int currentTimePositionPixel = convertDurationToPixel(currentPositionMs - mProgramStartTimeMs); - params.leftMargin = currentTimePositionPixel + mTimeTextLeftMargin; - mTimeText.setLayoutParams(params); - mTimeText.setText(getTimeString(currentPositionMs)); - params = (ViewGroup.MarginLayoutParams) mTimeIndicator.getLayoutParams(); - params.leftMargin = currentTimePositionPixel + mTimeIndicatorLeftMargin; - mTimeIndicator.setLayoutParams(params); + mTimeText.setTranslationX(currentTimePositionPixel + mTimeTextLeftMargin); + setTextIfNeeded(mTimeText, getTimeString(currentPositionMs)); + mTimeIndicator.setTranslationX(currentTimePositionPixel + mTimeIndicatorLeftMargin); } private void updateProgress() { if (isEnabled()) { - mProgressWatched.setVisibility(View.VISIBLE); - mProgressBuffered.setVisibility(View.VISIBLE); - mProgressEmptyAfter.setVisibility(View.VISIBLE); - } else { - mProgressWatched.setVisibility(View.INVISIBLE); - mProgressBuffered.setVisibility(View.INVISIBLE); - mProgressEmptyAfter.setVisibility(View.INVISIBLE); - if (mProgramStartTimeMs < mProgramEndTimeMs) { - layoutProgress(mProgressEmptyBefore, mProgramStartTimeMs, mProgramEndTimeMs); - } else { - // Not initialized yet. - layoutProgress(mProgressEmptyBefore, mTimelineWidth); - } - return; - } - - long progressStartTimeMs = Math.min(mProgramEndTimeMs, + long progressStartTimeMs = Math.min(mProgramEndTimeMs, Math.max(mProgramStartTimeMs, mTimeShiftManager.getRecordStartTimeMs())); - long currentPlayingTimeMs = Math.min(mProgramEndTimeMs, + long currentPlayingTimeMs = Math.min(mProgramEndTimeMs, Math.max(mProgramStartTimeMs, mTimeShiftManager.getCurrentPositionMs())); - long progressEndTimeMs = Math.min(mProgramEndTimeMs, + long progressEndTimeMs = Math.min(mProgramEndTimeMs, Math.max(mProgramStartTimeMs, mTimeShiftManager.getRecordEndTimeMs())); - - layoutProgress(mProgressEmptyBefore, mProgramStartTimeMs, progressStartTimeMs); - layoutProgress(mProgressWatched, progressStartTimeMs, currentPlayingTimeMs); - layoutProgress(mProgressBuffered, currentPlayingTimeMs, progressEndTimeMs); - } - - private void layoutProgress(View progress, long progressStartTimeMs, long progressEndTimeMs) { - layoutProgress(progress, Math.max(0, - convertDurationToPixel(progressEndTimeMs - progressStartTimeMs)) + 1); - } - - private void layoutProgress(View progress, int width) { - ViewGroup.MarginLayoutParams params = - (ViewGroup.MarginLayoutParams) progress.getLayoutParams(); - params.width = width; - progress.setLayoutParams(params); + mProgress.setProgressRange(progressStartTimeMs - mProgramStartTimeMs, + progressEndTimeMs - mProgramStartTimeMs); + mProgress.setProgress(currentPlayingTimeMs - mProgramStartTimeMs); + } else { + mProgress.setProgressRange(0, 0); + } } private void updateRecTimeText() { if (isEnabled()) { mProgramStartTimeText.setVisibility(View.VISIBLE); - mProgramStartTimeText.setText(getTimeString(mProgramStartTimeMs)); + setTextIfNeeded(mProgramStartTimeText, getTimeString(mProgramStartTimeMs)); mProgramEndTimeText.setVisibility(View.VISIBLE); - mProgramEndTimeText.setText(getTimeString(mProgramEndTimeMs)); + setTextIfNeeded(mProgramEndTimeText, getTimeString(mProgramEndTimeMs)); } else { mProgramStartTimeText.setVisibility(View.GONE); mProgramEndTimeText.setVisibility(View.GONE); @@ -576,11 +544,17 @@ public class PlayControlsRowView extends MenuRowView { private void updateButtons() { if (isEnabled()) { - mControlBar.setVisibility(View.VISIBLE); - mUnavailableMessageText.setVisibility(View.GONE); + mPlayPauseButton.setVisibility(View.VISIBLE); + mJumpPreviousButton.setVisibility(View.VISIBLE); + mJumpNextButton.setVisibility(View.VISIBLE); + mRewindButton.setVisibility(View.VISIBLE); + mFastForwardButton.setVisibility(View.VISIBLE); } else { - mControlBar.setVisibility(View.INVISIBLE); - mUnavailableMessageText.setVisibility(View.VISIBLE); + mPlayPauseButton.setVisibility(View.GONE); + mJumpPreviousButton.setVisibility(View.GONE); + mJumpNextButton.setVisibility(View.GONE); + mRewindButton.setVisibility(View.GONE); + mFastForwardButton.setVisibility(View.GONE); return; } @@ -622,6 +596,12 @@ public class PlayControlsRowView extends MenuRowView { } private void updateRecordButton() { + if (isEnabled()) { + mRecordButton.setVisibility(VISIBLE); + } else { + mRecordButton.setVisibility(GONE); + return; + } if (!(mDvrManager != null && mDvrManager.isChannelRecordable(mMainActivity.getCurrentChannel()))) { mRecordButton.setVisibility(View.GONE); @@ -682,4 +662,10 @@ public class PlayControlsRowView extends MenuRowView { mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener); } } + + private void setTextIfNeeded(TextView textView, String text) { + if (!TextUtils.equals(textView.getText(), text)) { + textView.setText(text); + } + } } diff --git a/src/com/android/tv/menu/PlaybackProgressBar.java b/src/com/android/tv/menu/PlaybackProgressBar.java new file mode 100644 index 00000000..e8061bc6 --- /dev/null +++ b/src/com/android/tv/menu/PlaybackProgressBar.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2017 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.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.util.AttributeSet; +import android.view.View; + +import com.android.tv.R; + +/** + * A progress bar control which has two progresses which start in the middle of the control. + */ +public class PlaybackProgressBar extends View { + private final LayerDrawable mProgressDrawable; + private final Drawable mPrimaryDrawable; + private final Drawable mSecondaryDrawable; + private long mMax = 100; + private long mProgressStart = 0; + private long mProgressEnd = 0; + private long mProgress = 0; + + public PlaybackProgressBar(Context context) { + this(context, null); + } + + public PlaybackProgressBar(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public PlaybackProgressBar(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public PlaybackProgressBar(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.PlaybackProgressBar, defStyleAttr, defStyleRes); + mProgressDrawable = + (LayerDrawable) a.getDrawable(R.styleable.PlaybackProgressBar_progressDrawable); + mPrimaryDrawable = mProgressDrawable.findDrawableByLayerId(android.R.id.progress); + mSecondaryDrawable = + mProgressDrawable.findDrawableByLayerId(android.R.id.secondaryProgress); + a.recycle(); + refreshProgress(); + } + + @Override + protected void onDraw(Canvas canvas) { + final int saveCount = canvas.save(); + canvas.translate(getPaddingLeft(), getPaddingTop()); + mProgressDrawable.draw(canvas); + canvas.restoreToCount(saveCount); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + refreshProgress(); + } + + public void setMax(long max) { + if (max < 0) { + max = 0; + } + if (max != mMax) { + mMax = max; + if (mProgressStart > max) { + mProgressStart = max; + } + if (mProgressEnd > max) { + mProgressEnd = max; + } + if (mProgress > max) { + mProgress = max; + } + refreshProgress(); + } + } + + /** + * Sets the start and end position of the progress. + */ + public void setProgressRange(long start, long end) { + start = constrain(start, 0, mMax); + end = constrain(end, start, mMax); + mProgress = constrain(mProgress, start, end); + if (start != mProgressStart || end != mProgressEnd) { + mProgressStart = start; + mProgressEnd = end; + setProgressLevels(); + } + } + + /** + * Sets the progress position. + */ + public void setProgress(long progress) { + progress = constrain(progress, mProgressStart, mProgressEnd); + if (progress != mProgress) { + mProgress = progress; + setProgressLevels(); + } + } + + private long constrain(long value, long min, long max) { + return Math.min(Math.max(value, min), max); + } + + private void refreshProgress() { + int width = getWidth() - getPaddingStart() - getPaddingEnd(); + int height = getHeight() - getPaddingTop() - getPaddingBottom(); + mProgressDrawable.setBounds(0, 0, width, height); + setProgressLevels(); + } + + private void setProgressLevels() { + boolean progressUpdated = setProgressBound(mPrimaryDrawable, mProgressStart, mProgress); + progressUpdated |= setProgressBound(mSecondaryDrawable, mProgress, mProgressEnd); + if (progressUpdated) { + postInvalidate(); + } + } + + private boolean setProgressBound(Drawable drawable, long start, long end) { + Rect oldBounds = drawable.getBounds(); + if (mMax == 0) { + if (!isEqualRect(oldBounds, 0, 0, 0, 0)) { + drawable.setBounds(0, 0, 0, 0); + return true; + } + return false; + } + int width = mProgressDrawable.getBounds().width(); + int height = mProgressDrawable.getBounds().height(); + int left = (int) (width * start / mMax); + int right = (int) (width * end / mMax); + if (!isEqualRect(oldBounds, left, 0, right, height)) { + drawable.setBounds(left, 0, right, height); + return true; + } + return false; + } + + private boolean isEqualRect(Rect rect, int left, int top, int right, int bottom) { + return rect.left == left && rect.top == top && rect.right == right && rect.bottom == bottom; + } +} diff --git a/src/com/android/tv/menu/SimpleCardView.java b/src/com/android/tv/menu/SimpleCardView.java index c99834be..fc5192da 100644 --- a/src/com/android/tv/menu/SimpleCardView.java +++ b/src/com/android/tv/menu/SimpleCardView.java @@ -19,12 +19,10 @@ package com.android.tv.menu; import android.content.Context; import android.util.AttributeSet; -import com.android.tv.data.Channel; - /** * A view to render a guide card. */ -public class SimpleCardView extends BaseCardView<Channel> { +public class SimpleCardView extends BaseCardView<ChannelsRowItem> { public SimpleCardView(Context context) { this(context, null, 0); diff --git a/src/com/android/tv/menu/TvOptionsRowAdapter.java b/src/com/android/tv/menu/TvOptionsRowAdapter.java index fb062246..6e035f22 100644 --- a/src/com/android/tv/menu/TvOptionsRowAdapter.java +++ b/src/com/android/tv/menu/TvOptionsRowAdapter.java @@ -21,7 +21,6 @@ import android.media.tv.TvTrackInfo; import android.support.annotation.VisibleForTesting; import com.android.tv.Features; -import com.android.tv.R; import com.android.tv.TvOptionsManager; import com.android.tv.customization.CustomAction; import com.android.tv.data.DisplayMode; @@ -30,7 +29,7 @@ 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; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -39,12 +38,6 @@ 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; - public TvOptionsRowAdapter(Context context, List<CustomAction> customActions) { super(context, customActions); } @@ -53,123 +46,73 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { protected List<MenuAction> createBaseActions() { List<MenuAction> actionList = new ArrayList<>(); actionList.add(MenuAction.SELECT_CLOSED_CAPTION_ACTION); - setOptionChangedListener(MenuAction.SELECT_CLOSED_CAPTION_ACTION); actionList.add(MenuAction.SELECT_DISPLAY_MODE_ACTION); - setOptionChangedListener(MenuAction.SELECT_DISPLAY_MODE_ACTION); - actionList.add(MenuAction.PIP_IN_APP_ACTION); - setOptionChangedListener(MenuAction.PIP_IN_APP_ACTION); - mPositionPipAction = actionList.size() - 1; + if (Features.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) { + actionList.add(MenuAction.SYSTEMWIDE_PIP_ACTION); + } actionList.add(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); - setOptionChangedListener(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); actionList.add(MenuAction.MORE_CHANNELS_ACTION); - if (DeveloperOptionFragment.shouldShow()) { + if (Utils.isDeveloper()) { actionList.add(MenuAction.DEV_ACTION); } actionList.add(MenuAction.SETTINGS_ACTION); - if (getCustomActions() != null) { - // Adjust Pip action position which will be changed by applying custom actions. - for (CustomAction customAction : getCustomActions()) { - if (customAction.isFront()) { - mPositionPipAction++; - } - } - } - + updateClosedCaptionAction(); + updatePipAction(); + updateMultiAudioAction(); + updateDisplayModeAction(); return actionList; } @Override - protected boolean updateActions() { - boolean changed = false; + protected void updateActions() { + if (updateClosedCaptionAction()) { + notifyItemChanged(getItemPosition(MenuAction.SELECT_CLOSED_CAPTION_ACTION)); + } if (updatePipAction()) { - changed = true; + notifyItemChanged(getItemPosition(MenuAction.SYSTEMWIDE_PIP_ACTION)); } if (updateMultiAudioAction()) { - changed = true; + notifyItemChanged(getItemPosition(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION)); } if (updateDisplayModeAction()) { - changed = true; + notifyItemChanged(getItemPosition(MenuAction.SELECT_DISPLAY_MODE_ACTION)); } - return changed; } - private boolean updatePipAction() { - // There are four states. - // Case 1. The device doesn't even have any input for PIP. (e.g. OTT box without HDMI input) - // => Remove the icon. - // Case 2. The device has one or more inputs for PIP but none of them are currently - // available. - // => Show the icon but disable it. - // Case 3. The device has one or more available PIP inputs and now it's tuned off. - // => Show the icon with "Off". - // Case 4. The device has one or more available PIP inputs but it's already turned on. - // => Show the icon with "On". - - boolean changed = false; - - // Case 1 - PipInputManager pipInputManager = getMainActivity().getPipInputManager(); - 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 (Features.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) { - addAction(mPositionPipAction, MenuAction.SYSTEMWIDE_PIP_ACTION); - } - return true; - } - return false; - } + @VisibleForTesting + private boolean updateClosedCaptionAction() { + return updateActionDescription(MenuAction.SELECT_CLOSED_CAPTION_ACTION); + } - // Case 2 - boolean isPipEnabled = getMainActivity().isPipEnabled(); - boolean oldEnabled = MenuAction.PIP_IN_APP_ACTION.isEnabled(); - boolean newEnabled = pipInputManager.getPipInputSize(true) > 0; - if (oldEnabled != newEnabled) { - // Should not disable the item if the PIP is already turned on so that the user can - // force exit it. - if (newEnabled || !isPipEnabled) { - MenuAction.PIP_IN_APP_ACTION.setEnabled(newEnabled); - changed = true; - } + private boolean updatePipAction() { + if (containsItem(MenuAction.SYSTEMWIDE_PIP_ACTION)) { + return MenuAction.setEnabled(MenuAction.SYSTEMWIDE_PIP_ACTION, + !getMainActivity().isScreenBlockedByResourceConflictOrParentalControl()); } - - // Case 3 & 4 - we just need to update the icon. - MenuAction.PIP_IN_APP_ACTION.setDrawableResId( - isPipEnabled ? R.drawable.ic_tvoption_pip : R.drawable.ic_tvoption_pip_off); - return changed; + return false; } - @VisibleForTesting boolean updateMultiAudioAction() { List<TvTrackInfo> audioTracks = getMainActivity().getTracks(TvTrackInfo.TYPE_AUDIO); - boolean oldEnabled = MenuAction.SELECT_AUDIO_LANGUAGE_ACTION.isEnabled(); - boolean newEnabled = audioTracks != null && audioTracks.size() > 1; - if (oldEnabled != newEnabled) { - MenuAction.SELECT_AUDIO_LANGUAGE_ACTION.setEnabled(newEnabled); - return true; - } - return false; + boolean enabled = audioTracks != null && audioTracks.size() > 1; + // Use "|" operator for non-short-circuit evaluation. + return MenuAction.setEnabled(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION, enabled) + | updateActionDescription(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION); } private boolean updateDisplayModeAction() { TvViewUiManager uiManager = getMainActivity().getTvViewUiManager(); - boolean oldEnabled = MenuAction.SELECT_DISPLAY_MODE_ACTION.isEnabled(); - boolean newEnabled = uiManager.isDisplayModeAvailable(DisplayMode.MODE_FULL) + boolean enabled = uiManager.isDisplayModeAvailable(DisplayMode.MODE_FULL) || uiManager.isDisplayModeAvailable(DisplayMode.MODE_ZOOM); - if (oldEnabled != newEnabled) { - MenuAction.SELECT_DISPLAY_MODE_ACTION.setEnabled(newEnabled); - return true; - } - return false; + // Use "|" operator for non-short-circuit evaluation. + return MenuAction.setEnabled(MenuAction.SELECT_DISPLAY_MODE_ACTION, enabled) + | updateActionDescription(MenuAction.SELECT_DISPLAY_MODE_ACTION); + } + + private boolean updateActionDescription(MenuAction action) { + return MenuAction.setActionDescription(action, + getMainActivity().getTvOptionsManager().getOptionString(action.getType())); } @Override @@ -183,9 +126,6 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { getMainActivity().getOverlayManager().getSideFragmentManager() .show(new DisplayModeFragment()); break; - case TvOptionsManager.OPTION_IN_APP_PIP: - getMainActivity().togglePipView(); - break; case TvOptionsManager.OPTION_SYSTEMWIDE_PIP: getMainActivity().enterPictureInPictureMode(); break; @@ -205,4 +145,4 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter { break; } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/onboarding/SetupSourcesFragment.java b/src/com/android/tv/onboarding/SetupSourcesFragment.java index 7607822c..f56daec5 100644 --- a/src/com/android/tv/onboarding/SetupSourcesFragment.java +++ b/src/com/android/tv/onboarding/SetupSourcesFragment.java @@ -38,6 +38,7 @@ 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.tuner.TunerInputController; import com.android.tv.ui.GuidedActionsStylistWithDivider; import com.android.tv.util.SetupUtils; import com.android.tv.util.TvInputManagerHelper; @@ -204,6 +205,7 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment { mChannelDataManager.addListener(mChannelDataManagerListener); super.onCreate(savedInstanceState); mParentFragment = (SetupSourcesFragment) getParentFragment(); + TunerInputController.executeNetworkTunerDiscoveryAsyncTask(getContext()); } @Override diff --git a/src/com/android/tv/parental/ContentRatingSystem.java b/src/com/android/tv/parental/ContentRatingSystem.java index 6b5d6635..5672b793 100644 --- a/src/com/android/tv/parental/ContentRatingSystem.java +++ b/src/com/android/tv/parental/ContentRatingSystem.java @@ -110,6 +110,15 @@ public class ContentRatingSystem { return mRatings; } + public Rating getRating(String name) { + for (Rating rating : mRatings) { + if (TextUtils.equals(rating.getName(), name)) { + return rating; + } + } + return null; + } + public List<SubRating> getSubRatings(){ return mSubRatings; } diff --git a/src/com/android/tv/parental/ContentRatingsManager.java b/src/com/android/tv/parental/ContentRatingsManager.java index 57c25f48..c3bb8c0f 100644 --- a/src/com/android/tv/parental/ContentRatingsManager.java +++ b/src/com/android/tv/parental/ContentRatingsManager.java @@ -20,7 +20,10 @@ import android.content.Context; import android.media.tv.TvContentRating; import android.media.tv.TvContentRatingSystemInfo; import android.media.tv.TvInputManager; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import com.android.tv.R; import com.android.tv.parental.ContentRatingSystem.Rating; import com.android.tv.parental.ContentRatingSystem.SubRating; @@ -53,6 +56,19 @@ public class ContentRatingsManager { } /** + * Returns the content rating system with the give ID. + */ + @Nullable + public ContentRatingSystem getContentRatingSystem(String contentRatingSystemId) { + for (ContentRatingSystem ratingSystem : mContentRatingSystems) { + if (TextUtils.equals(ratingSystem.getId(), contentRatingSystemId)) { + return ratingSystem; + } + } + return null; + } + + /** * Returns a new list of all content rating systems defined. */ public List<ContentRatingSystem> getContentRatingSystems() { @@ -64,6 +80,9 @@ public class ContentRatingsManager { * displayed to the user. For example, "TV-PG (L, S)". */ public String getDisplayNameForRating(TvContentRating canonicalRating) { + if (TvContentRating.UNRATED.equals(canonicalRating)) { + return mContext.getResources().getString(R.string.unrated_rating_name); + } Rating rating = getRating(canonicalRating); if (rating == null) { return null; diff --git a/src/com/android/tv/parental/ParentalControlSettings.java b/src/com/android/tv/parental/ParentalControlSettings.java index d7e1846e..2471c565 100644 --- a/src/com/android/tv/parental/ParentalControlSettings.java +++ b/src/com/android/tv/parental/ParentalControlSettings.java @@ -20,6 +20,7 @@ import android.content.Context; import android.media.tv.TvContentRating; import android.media.tv.TvInputManager; +import com.android.tv.experiments.Experiments; import com.android.tv.parental.ContentRatingSystem.Rating; import com.android.tv.parental.ContentRatingSystem.SubRating; import com.android.tv.util.TvSettings; @@ -109,6 +110,10 @@ public class ParentalControlSettings { @ContentRatingLevel int currentLevel = getContentRatingLevel(); if (currentLevel != TvSettings.CONTENT_RATING_LEVEL_CUSTOM) { mRatings = ContentRatingLevelPolicy.getRatingsForLevel(this, manager, currentLevel); + if (currentLevel != TvSettings.CONTENT_RATING_LEVEL_NONE) { + // UNRATED contents should be blocked unless the rating level is none or custom + mRatings.add(TvContentRating.UNRATED); + } storeRatings(); } } @@ -129,6 +134,11 @@ public class ParentalControlSettings { } } else { mRatings = ContentRatingLevelPolicy.getRatingsForLevel(this, manager, level); + if (level != TvSettings.CONTENT_RATING_LEVEL_NONE + && Boolean.TRUE.equals(Experiments.ENABLE_UNRATED_CONTENT_SETTINGS.get())) { + // UNRATED contents should be blocked unless the rating level is none or custom + mRatings.add(TvContentRating.UNRATED); + } } storeRatings(); } @@ -138,6 +148,23 @@ public class ParentalControlSettings { return TvSettings.getContentRatingLevel(mContext); } + /** Sets the blocked status of a unrated contents. */ + public boolean setUnratedBlocked(boolean blocked) { + boolean changed; + if (blocked) { + changed = mRatings.add(TvContentRating.UNRATED); + mTvInputManager.addBlockedRating(TvContentRating.UNRATED); + } else { + changed = mRatings.remove(TvContentRating.UNRATED); + mTvInputManager.removeBlockedRating(TvContentRating.UNRATED); + } + if (changed) { + // change to custom level if the blocked status is changed + changeToCustomLevel(); + } + return changed; + } + /** * Sets the blocked status of a given content rating. * <p> @@ -172,8 +199,10 @@ public class ParentalControlSettings { * @return The {@link TvContentRating} that is blocked. */ public TvContentRating getBlockedRating(TvContentRating[] ratings) { - if (ratings == null) { - return null; + if (ratings == null || ratings.length <= 0) { + return mTvInputManager.isRatingBlocked(TvContentRating.UNRATED) + ? TvContentRating.UNRATED + : null; } for (TvContentRating rating : ratings) { if (mTvInputManager.isRatingBlocked(rating)) { diff --git a/src/com/android/tv/perf/EventNames.java b/src/com/android/tv/perf/EventNames.java new file mode 100644 index 00000000..6026897b --- /dev/null +++ b/src/com/android/tv/perf/EventNames.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2017 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.perf; + +import android.support.annotation.StringDef; + +import java.lang.annotation.Retention; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +/** + * Constants for performance event names. + * + * <p>Only constants are used to insure no PII is sent. + * + */ +public final class EventNames { + + @Retention(SOURCE) + @StringDef({ + APPLICATION_ONCREATE, + FETCH_EPG_TASK, + MAIN_ACTIVITY_ONCREATE, + MAIN_ACTIVITY_ONSTART, + MAIN_ACTIVITY_ONRESUME, + ON_DEVICE_SEARCH + }) + public @interface EventName {} + + public static final String APPLICATION_ONCREATE = "Application.onCreate"; + public static final String FETCH_EPG_TASK = "FetchEpgTask"; + public static final String MAIN_ACTIVITY_ONCREATE = "MainActivity.onCreate"; + public static final String MAIN_ACTIVITY_ONSTART = "MainActivity.onStart"; + public static final String MAIN_ACTIVITY_ONRESUME = "MainActivity.onResume"; + /** + * Event name for query running time of on-device search in + * {@link com.android.tv.search.LocalSearchProvider}. + */ + public static final String ON_DEVICE_SEARCH = "OnDeviceSearch"; + + private EventNames() {} +} diff --git a/src/com/android/tv/perf/PerformanceMonitor.java b/src/com/android/tv/perf/PerformanceMonitor.java new file mode 100644 index 00000000..40368b41 --- /dev/null +++ b/src/com/android/tv/perf/PerformanceMonitor.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2017 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.perf; + +import android.content.Context; + +import static com.android.tv.perf.EventNames.EventName; + +/** Measures Performance. */ +public interface PerformanceMonitor { + + /** + * Starts monitoring application's lifecylce for interesting memory events, captures and records + * memory usage data whenever these events are fired. + */ + void startMemoryMonitor(); + + /** + * Collects and records memory usage for a specific custom event + * + * @param eventName to record + */ + void recordMemory(@EventName String eventName); + + /** + * Starts a timer for a global event to allow measuring the event's latency across activities If + * multiple events with the same name are started, only the last event is retained. + * + * @param eventName for which the timer starts + */ + void startGlobalTimer(@EventName String eventName); + + /** + * Stops a cross activities timer for a specific eventName and records the timer duration. If no + * timer found for the event specified an error will be logged, and recording will be skipped. + * + * @param eventName for which the timer stops + */ + void stopGlobalTimer(@EventName String eventName); + + /** + * Starts a timer to record latency of a specific scenario or event. Use this method to track + * latency in the same method/class + * + * @return TimerEvent object to be used for stopping/recording the timer for a specific event. + * If PerformanceMonitor is not initialized for any reason, an empty TimerEvent will be + * returned. + */ + TimerEvent startTimer(); + + + /** + * Stops timer for a specific event and records the timer duration. passing a null TimerEvent + * will cause this operation to be skipped. + * + * @param event that needs to be stopped + * @param eventName for which the timer stops. This must be constant with no PII. + */ + void stopTimer(TimerEvent event, @EventName String eventName); + + /** + * Starts recording jank for a specific scenario or event. + * + * <p>If jank recording was started already for an event with the current name, but was never + * stopped, the previously recorded event will be skipped. + * + * @param eventName of the event for which tracking is started + */ + void startJankRecorder(@EventName String eventName); + + /** + * Stops recording jank for a specific event and records the jank event. + * + * @param eventName of the event that needs to be stopped + */ + void stopJankRecorder(@EventName String eventName); + + /** + * Starts activity to display PerformanceMonitor events recorded in local database for debug + * purpose. + * + * @return true if the activity is available to start + */ + boolean startPerformanceMonitorEventDebugActivity(Context context); +} diff --git a/src/com/android/tv/perf/StubPerformanceMonitor.java b/src/com/android/tv/perf/StubPerformanceMonitor.java new file mode 100644 index 00000000..3742a2a7 --- /dev/null +++ b/src/com/android/tv/perf/StubPerformanceMonitor.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2017 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.perf; + +import android.app.Application; +import android.content.Context; + +/** Do nothing implementation of {@link PerformanceMonitor}. */ +public final class StubPerformanceMonitor implements PerformanceMonitor { + + private static final TimerEvent TIMER_EVENT = new TimerEvent() {}; + + public static PerformanceMonitor initialize(Application app) { + return new StubPerformanceMonitor(); + } + + @Override + public void startMemoryMonitor() {} + + @Override + public void recordMemory(String customEventName) {} + + @Override + public void startGlobalTimer(String customEventName) {} + + @Override + public void stopGlobalTimer(String customEventName) {} + + @Override + public TimerEvent startTimer() { + return TIMER_EVENT; + } + + @Override + public void stopTimer(TimerEvent event, String name) {} + + @Override + public void startJankRecorder(String eventName) {} + + @Override + public void stopJankRecorder(String eventName) {} + + @Override + public boolean startPerformanceMonitorEventDebugActivity(Context context) { + return false; + } + + public static TimerEvent startBootstrapTimer() { + return new TimerEvent() {}; + } +} diff --git a/src/com/android/tv/perf/TimerEvent.java b/src/com/android/tv/perf/TimerEvent.java new file mode 100644 index 00000000..f8ac6b2d --- /dev/null +++ b/src/com/android/tv/perf/TimerEvent.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2017 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.perf; + +/** An event to time */ +public interface TimerEvent {} diff --git a/src/com/android/tv/receiver/BootCompletedReceiver.java b/src/com/android/tv/receiver/BootCompletedReceiver.java index 8d6c5a14..369e7d54 100644 --- a/src/com/android/tv/receiver/BootCompletedReceiver.java +++ b/src/com/android/tv/receiver/BootCompletedReceiver.java @@ -21,13 +21,15 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.os.Build; 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.dvr.recorder.DvrRecordingService; +import com.android.tv.dvr.recorder.RecordingScheduler; +import com.android.tv.recommendation.ChannelPreviewUpdater; import com.android.tv.recommendation.NotificationService; import com.android.tv.util.OnboardingUtils; import com.android.tv.util.SetupUtils; @@ -49,12 +51,20 @@ public class BootCompletedReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + return; + } 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); - context.startService(notificationIntent); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ChannelPreviewUpdater.getInstance(context).updatePreviewDataForChannelsImmediately(); + } else { + Intent notificationIntent = new Intent(context, NotificationService.class); + notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION); + context.startService(notificationIntent); + } // Grant permission to already set up packages after the system has finished booting. SetupUtils.grantEpgPermissionToSetUpPackages(context); @@ -74,8 +84,9 @@ public class BootCompletedReceiver extends BroadcastReceiver { } } - if (CommonFeatures.DVR.isEnabled(context)) { - DvrRecordingService.startService(context); + RecordingScheduler scheduler = TvApplication.getSingletons(context).getRecordingScheduler(); + if (scheduler != null) { + scheduler.updateAndStartServiceIfNeeded(); } } } diff --git a/src/com/android/tv/receiver/GlobalKeyReceiver.java b/src/com/android/tv/receiver/GlobalKeyReceiver.java index 8cd4fdf1..cc8e76c4 100644 --- a/src/com/android/tv/receiver/GlobalKeyReceiver.java +++ b/src/com/android/tv/receiver/GlobalKeyReceiver.java @@ -19,7 +19,8 @@ package com.android.tv.receiver; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.media.tv.TvContract; +import android.os.AsyncTask; +import android.provider.Settings; import android.util.Log; import android.view.KeyEvent; @@ -31,27 +32,64 @@ import com.android.tv.TvApplication; public class GlobalKeyReceiver extends BroadcastReceiver { private static final boolean DEBUG = false; private static final String TAG = "GlobalKeyReceiver"; + private static final String ACTION_GLOBAL_BUTTON = "android.intent.action.GLOBAL_BUTTON"; + // Settings.Secure.USER_SETUP_COMPLETE is hidden. + private static final String SETTINGS_USER_SETUP_COMPLETE = "user_setup_complete"; + + private static long sLastEventTime; + private static boolean sUserSetupComplete; @Override public void onReceive(Context context, Intent intent) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + return; + } TvApplication.setCurrentRunningProcess(context, true); + Context appContext = context.getApplicationContext(); + if (DEBUG) Log.d(TAG, "onReceive: " + intent); + if (sUserSetupComplete) { + handleIntent(appContext, intent); + } else { + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + return Settings.Secure.getInt(appContext.getContentResolver(), + SETTINGS_USER_SETUP_COMPLETE, 0) != 0; + } + + @Override + protected void onPostExecute(Boolean setupComplete) { + if (DEBUG) Log.d(TAG, "Is setup complete: " + setupComplete); + sUserSetupComplete = setupComplete; + if (sUserSetupComplete) { + handleIntent(appContext, intent); + } + } + }.execute(); + } + } + + private void handleIntent(Context appContext, Intent intent) { if (ACTION_GLOBAL_BUTTON.equals(intent.getAction())) { KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); - if (DEBUG) Log.d(TAG, "onReceive: " + event); + if (DEBUG) Log.d(TAG, "handleIntent: " + event); int keyCode = event.getKeyCode(); int action = event.getAction(); - if (action == KeyEvent.ACTION_UP) { + long eventTime = event.getEventTime(); + if (action == KeyEvent.ACTION_UP && sLastEventTime != eventTime) { + // Workaround for b/23947504, the same key event may be sent twice, filter it. + sLastEventTime = eventTime; switch (keyCode) { case KeyEvent.KEYCODE_GUIDE: - context.startActivity( - new Intent(Intent.ACTION_VIEW, TvContract.Programs.CONTENT_URI)); + ((TvApplication) appContext).handleGuideKey(); break; case KeyEvent.KEYCODE_TV: - ((TvApplication) context.getApplicationContext()).handleTvKey(); + ((TvApplication) appContext).handleTvKey(); break; case KeyEvent.KEYCODE_TV_INPUT: - ((TvApplication) context.getApplicationContext()).handleTvInputKey(); + ((TvApplication) appContext).handleTvInputKey(); break; default: // Do nothing diff --git a/src/com/android/tv/receiver/PackageIntentsReceiver.java b/src/com/android/tv/receiver/PackageIntentsReceiver.java index 26d000e7..124172f0 100644 --- a/src/com/android/tv/receiver/PackageIntentsReceiver.java +++ b/src/com/android/tv/receiver/PackageIntentsReceiver.java @@ -19,17 +19,29 @@ package com.android.tv.receiver; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.util.Log; import com.android.tv.TvApplication; +import com.android.tv.util.Partner; /** * A class for handling the broadcast intents from PackageManager. */ public class PackageIntentsReceiver extends BroadcastReceiver { + private static final String TAG = "PackageIntentsReceiver"; @Override public void onReceive(Context context, Intent intent) { + if (!TvApplication.getSingletons(context).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + return; + } TvApplication.setCurrentRunningProcess(context, true); ((TvApplication) context.getApplicationContext()).handleInputCountChanged(); + + Uri uri = intent.getData(); + final String packageName = (uri != null ? uri.getSchemeSpecificPart() : null); + Partner.reset(context, packageName); } } diff --git a/src/com/android/tv/recommendation/ChannelPreviewUpdater.java b/src/com/android/tv/recommendation/ChannelPreviewUpdater.java new file mode 100644 index 00000000..2709ebe1 --- /dev/null +++ b/src/com/android/tv/recommendation/ChannelPreviewUpdater.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2017 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.recommendation; + +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.os.AsyncTask; +import android.os.Build; +import android.support.annotation.RequiresApi; +import android.support.media.tv.TvContractCompat; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.data.Channel; +import com.android.tv.data.PreviewDataManager; +import com.android.tv.data.PreviewProgramContent; +import com.android.tv.data.Program; +import com.android.tv.parental.ParentalControlSettings; +import com.android.tv.util.Utils; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** Class for updating the preview programs for {@link Channel}. */ +@RequiresApi(Build.VERSION_CODES.O) +public class ChannelPreviewUpdater { + private static final String TAG = "ChannelPreviewUpdater"; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; + + private static final int UPATE_PREVIEW_PROGRAMS_JOB_ID = 1000001; + private static final long ROUTINE_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10); + // The left time of a program should meet the threshold so that it could be recommended. + private static final long RECOMMENDATION_THRESHOLD_LEFT_TIME_MS = + TimeUnit.MINUTES.toMillis(10); + private static final int RECOMMENDATION_THRESHOLD_PROGRESS = 90; // 90% + private static final int RECOMMENDATION_COUNT = 6; + private static final int MIN_COUNT_TO_ADD_ROW = 4; + + private static ChannelPreviewUpdater sChannelPreviewUpdater; + + /** + * Creates and returns the {@link ChannelPreviewUpdater}. + */ + public static ChannelPreviewUpdater getInstance(Context context) { + if (sChannelPreviewUpdater == null) { + sChannelPreviewUpdater = new ChannelPreviewUpdater(context.getApplicationContext()); + } + return sChannelPreviewUpdater; + } + + private final Context mContext; + private final Recommender mRecommender; + private final PreviewDataManager mPreviewDataManager; + private JobService mJobService; + private JobParameters mJobParams; + + private final ParentalControlSettings mParentalControlSettings; + + private boolean mNeedUpdateAfterRecommenderReady = false; + + private Recommender.Listener mRecommenderListener = new Recommender.Listener() { + @Override + public void onRecommenderReady() { + if (mNeedUpdateAfterRecommenderReady) { + if (DEBUG) Log.d(TAG, "Recommender is ready"); + updatePreviewDataForChannelsImmediately(); + mNeedUpdateAfterRecommenderReady = false; + } + } + + @Override + public void onRecommendationChanged() { + updatePreviewDataForChannelsImmediately(); + } + }; + + private ChannelPreviewUpdater(Context context) { + mContext = context; + mRecommender = new Recommender(context, mRecommenderListener, true); + mRecommender.registerEvaluator(new RandomEvaluator(), 0.1, 0.1); + mRecommender.registerEvaluator(new FavoriteChannelEvaluator(), 0.5, 0.5); + mRecommender.registerEvaluator(new RoutineWatchEvaluator(), 1.0, 1.0); + ApplicationSingletons appSingleton = TvApplication.getSingletons(context); + mPreviewDataManager = appSingleton.getPreviewDataManager(); + mParentalControlSettings = appSingleton.getTvInputManagerHelper() + .getParentalControlSettings(); + } + + /** + * Starts the routine service for updating the preview programs. + */ + public void startRoutineService() { + JobScheduler jobScheduler = + (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE); + if (jobScheduler.getPendingJob(UPATE_PREVIEW_PROGRAMS_JOB_ID) != null) { + if (DEBUG) Log.d(TAG, "UPDATE_PREVIEW_JOB already exists"); + return; + } + JobInfo job = new JobInfo.Builder(UPATE_PREVIEW_PROGRAMS_JOB_ID, + new ComponentName(mContext, ChannelPreviewUpdateService.class)) + .setPeriodic(ROUTINE_INTERVAL_MS) + .setPersisted(true) + .build(); + if (jobScheduler.schedule(job) < 0) { + Log.i(TAG, "JobScheduler failed to schedule the job"); + } + } + + /** Called when {@link ChannelPreviewUpdateService} is started. */ + void onStartJob(JobService service, JobParameters params) { + if (DEBUG) Log.d(TAG, "onStartJob"); + mJobService = service; + mJobParams = params; + updatePreviewDataForChannelsImmediately(); + } + + /** + * Updates the preview programs table. + */ + public void updatePreviewDataForChannelsImmediately() { + if (!mRecommender.isReady()) { + mNeedUpdateAfterRecommenderReady = true; + return; + } + + if (!mPreviewDataManager.isLoadFinished()) { + mPreviewDataManager.addListener(new PreviewDataManager.PreviewDataListener() { + @Override + public void onPreviewDataLoadFinished() { + mPreviewDataManager.removeListener(this); + updatePreviewDataForChannels(); + } + + @Override + public void onPreviewDataUpdateFinished() { } + }); + return; + } + updatePreviewDataForChannels(); + } + + /** Called when {@link ChannelPreviewUpdateService} is stopped. */ + void onStopJob() { + if (DEBUG) Log.d(TAG, "onStopJob"); + mJobService = null; + mJobParams = null; + } + + private void updatePreviewDataForChannels() { + new AsyncTask<Void, Void, Set<Program>>() { + @Override + protected Set<Program> doInBackground(Void... params) { + Set<Program> programs = new HashSet<>(); + List<Channel> channels = new ArrayList<>(mRecommender.recommendChannels()); + for (Channel channel : channels) { + if (channel.isPhysicalTunerChannel()) { + final Program program = Utils.getCurrentProgram(mContext, channel.getId()); + if (program != null + && isChannelRecommendationApplicable(channel, program)) { + programs.add(program); + if (programs.size() >= RECOMMENDATION_COUNT) { + break; + } + } + } + } + return programs; + } + + private boolean isChannelRecommendationApplicable(Channel channel, Program program) { + final long programDurationMs = + program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis(); + if (programDurationMs <= 0) { + return false; + } + if (TextUtils.isEmpty(program.getPosterArtUri())) { + return false; + } + if (mParentalControlSettings.isParentalControlsEnabled() + && (channel.isLocked() + || mParentalControlSettings.isRatingBlocked( + program.getContentRatings()))) { + return false; + } + long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis(); + final int programProgress = + (programDurationMs <= 0) + ? -1 + : 100 - (int) (programLeftTimsMs * 100 / programDurationMs); + + // We recommend those programs that meet the condition only. + return programProgress < RECOMMENDATION_THRESHOLD_PROGRESS + || programLeftTimsMs > RECOMMENDATION_THRESHOLD_LEFT_TIME_MS; + } + + @Override + protected void onPostExecute(Set<Program> programs) { + updatePreviewDataForChannelsInternal(programs); + } + }.execute(); + } + + private void updatePreviewDataForChannelsInternal(Set<Program> programs) { + long defaultPreviewChannelId = mPreviewDataManager.getPreviewChannelId( + PreviewDataManager.TYPE_DEFAULT_PREVIEW_CHANNEL); + if (defaultPreviewChannelId == PreviewDataManager.INVALID_PREVIEW_CHANNEL_ID) { + // Only create if there is enough programs + if (programs.size() > MIN_COUNT_TO_ADD_ROW) { + mPreviewDataManager.createDefaultPreviewChannel( + new PreviewDataManager.OnPreviewChannelCreationResultListener() { + @Override + public void onPreviewChannelCreationResult( + long createdPreviewChannelId) { + if (createdPreviewChannelId + != PreviewDataManager.INVALID_PREVIEW_CHANNEL_ID) { + TvContractCompat.requestChannelBrowsable( + mContext, createdPreviewChannelId); + updatePreviewProgramsForPreviewChannel( + createdPreviewChannelId, + generatePreviewProgramContentsFromPrograms( + createdPreviewChannelId, programs)); + } + } + }); + } + } else { + updatePreviewProgramsForPreviewChannel(defaultPreviewChannelId, + generatePreviewProgramContentsFromPrograms(defaultPreviewChannelId, programs)); + } + } + + private Set<PreviewProgramContent> generatePreviewProgramContentsFromPrograms( + long previewChannelId, Set<Program> programs) { + Set<PreviewProgramContent> result = new HashSet<>(); + for (Program program : programs) { + PreviewProgramContent previewProgramContent = + PreviewProgramContent.createFromProgram(mContext, previewChannelId, program); + if (previewProgramContent != null) { + result.add(previewProgramContent); + } + } + return result; + } + + private void updatePreviewProgramsForPreviewChannel(long previewChannelId, + Set<PreviewProgramContent> previewProgramContents) { + PreviewDataManager.PreviewDataListener previewDataListener + = new PreviewDataManager.PreviewDataListener() { + @Override + public void onPreviewDataLoadFinished() { } + + @Override + public void onPreviewDataUpdateFinished() { + mPreviewDataManager.removeListener(this); + if (mJobService != null && mJobParams != null) { + if (DEBUG) Log.d(TAG, "UpdateAsyncTask.onPostExecute with JobService"); + mJobService.jobFinished(mJobParams, false); + mJobService = null; + mJobParams = null; + } else { + if (DEBUG) Log.d(TAG, "UpdateAsyncTask.onPostExecute without JobService"); + } + } + }; + mPreviewDataManager.updatePreviewProgramsForChannel( + previewChannelId, previewProgramContents, previewDataListener); + } + + /** + * Job to execute the update of preview programs. + */ + public static class ChannelPreviewUpdateService extends JobService { + private ChannelPreviewUpdater mChannelPreviewUpdater; + + @Override + public void onCreate() { + TvApplication.setCurrentRunningProcess(this, true); + if (DEBUG) Log.d(TAG, "ChannelPreviewUpdateService.onCreate"); + mChannelPreviewUpdater = ChannelPreviewUpdater.getInstance(this); + } + + @Override + public boolean onStartJob(JobParameters params) { + mChannelPreviewUpdater.onStartJob(this, params); + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + mChannelPreviewUpdater.onStopJob(); + return false; + } + + @Override + public void onDestroy() { + if (DEBUG) Log.d(TAG, "ChannelPreviewUpdateService.onDestroy"); + } + } +} diff --git a/src/com/android/tv/recommendation/NotificationService.java b/src/com/android/tv/recommendation/NotificationService.java index 30ec73e3..a44eca41 100644 --- a/src/com/android/tv/recommendation/NotificationService.java +++ b/src/com/android/tv/recommendation/NotificationService.java @@ -426,6 +426,7 @@ public class NotificationService extends Service implements Recommender.Listener : 100 - (int) (programLeftTimsMs * 100 / programDurationMs); Intent intent = new Intent(Intent.ACTION_VIEW, channel.getUri()); intent.putExtra(TUNE_PARAMS_RECOMMENDATION_TYPE, mRecommendationType); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); final PendingIntent notificationIntent = PendingIntent.getActivity(this, 0, intent, 0); // This callback will run on the main thread. @@ -455,11 +456,17 @@ public class NotificationService extends Service implements Recommender.Listener } private Bitmap overlayChannelLogo(Bitmap logo, Bitmap background) { - Bitmap result = BitmapUtils.scaleBitmap( + Bitmap result = BitmapUtils.getScaledMutableBitmap( background, Integer.MAX_VALUE, mCardImageHeight); Bitmap scaledLogo = BitmapUtils.scaleBitmap( logo, mChannelLogoMaxWidth, mChannelLogoMaxHeight); - Canvas canvas = new Canvas(result); + Canvas canvas; + try { + canvas = new Canvas(result); + } catch (Exception e) { + Log.w(TAG, "Failed to create Canvas", e); + return background; + } canvas.drawBitmap(result, new Matrix(), null); Rect rect = new Rect(); int startPadding; diff --git a/src/com/android/tv/recommendation/RecommendationDataManager.java b/src/com/android/tv/recommendation/RecommendationDataManager.java index 62ccd578..dc148ec8 100644 --- a/src/com/android/tv/recommendation/RecommendationDataManager.java +++ b/src/com/android/tv/recommendation/RecommendationDataManager.java @@ -41,7 +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 com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Collection; @@ -505,8 +505,8 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener @SuppressLint("SwitchIntDef") @Override public void onChange(final boolean selfChange, final Uri uri) { - switch (TvProviderUriMatcher.match(uri)) { - case TvProviderUriMatcher.MATCH_WATCHED_PROGRAM_ID: + switch (TvUriMatcher.match(uri)) { + case TvUriMatcher.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/recommendation/RecordedProgramPreviewUpdater.java b/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java new file mode 100644 index 00000000..ad55afb7 --- /dev/null +++ b/src/com/android/tv/recommendation/RecordedProgramPreviewUpdater.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2017 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.recommendation; + +import android.content.Context; +import android.os.Build; +import android.support.annotation.RequiresApi; +import android.text.TextUtils; +import android.util.Log; + +import com.android.tv.ApplicationSingletons; +import com.android.tv.TvApplication; +import com.android.tv.data.PreviewDataManager; +import com.android.tv.data.PreviewProgramContent; +import com.android.tv.dvr.DvrDataManager; +import com.android.tv.dvr.data.RecordedProgram; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Class to update the preview data for {@link RecordedProgram} + */ +@RequiresApi(Build.VERSION_CODES.O) +public class RecordedProgramPreviewUpdater { + private static final String TAG = "RecordedProgramPreviewUpdater"; + // STOPSHIP: set it to false. + private static final boolean DEBUG = true; + + private static final int RECOMMENDATION_COUNT = 6; + + private static RecordedProgramPreviewUpdater sRecordedProgramPreviewUpdater; + + /** + * Creates and returns the {@link RecordedProgramPreviewUpdater}. + */ + public static RecordedProgramPreviewUpdater getInstance(Context context) { + if (sRecordedProgramPreviewUpdater == null) { + sRecordedProgramPreviewUpdater + = new RecordedProgramPreviewUpdater(context.getApplicationContext()); + } + return sRecordedProgramPreviewUpdater; + } + + private final Context mContext; + private final PreviewDataManager mPreviewDataManager; + private final DvrDataManager mDvrDataManager; + + private RecordedProgramPreviewUpdater(Context context) { + mContext = context.getApplicationContext(); + ApplicationSingletons applicationSingletons = TvApplication.getSingletons(mContext); + mPreviewDataManager = applicationSingletons.getPreviewDataManager(); + mDvrDataManager = applicationSingletons.getDvrDataManager(); + mDvrDataManager.addRecordedProgramListener(new DvrDataManager.RecordedProgramListener() { + @Override + public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { + if (DEBUG) Log.d(TAG, "Add new preview recorded programs"); + updatePreviewDataForRecordedPrograms(); + } + + @Override + public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { + if (DEBUG) Log.d(TAG, "Update preview recorded programs"); + updatePreviewDataForRecordedPrograms(); + } + + @Override + public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) { + if (DEBUG) Log.d(TAG, "Delete preview recorded programs"); + updatePreviewDataForRecordedPrograms(); + } + }); + } + + /** + * Updates the preview data for recorded programs. + */ + public void updatePreviewDataForRecordedPrograms() { + if (!mPreviewDataManager.isLoadFinished()) { + mPreviewDataManager.addListener(new PreviewDataManager.PreviewDataListener() { + @Override + public void onPreviewDataLoadFinished() { + mPreviewDataManager.removeListener(this); + updatePreviewDataForRecordedPrograms(); + } + + @Override + public void onPreviewDataUpdateFinished() { } + }); + return; + } + if (!mDvrDataManager.isRecordedProgramLoadFinished()) { + mDvrDataManager.addRecordedProgramLoadFinishedListener( + new DvrDataManager.OnRecordedProgramLoadFinishedListener() { + @Override + public void onRecordedProgramLoadFinished() { + mDvrDataManager.removeRecordedProgramLoadFinishedListener(this); + updatePreviewDataForRecordedPrograms(); + } + }); + return; + } + updatePreviewDataForRecordedProgramsInternal(); + } + + private void updatePreviewDataForRecordedProgramsInternal() { + Set<RecordedProgram> recordedPrograms = generateRecommendationRecordedPrograms(); + Long recordedPreviewChannelId = mPreviewDataManager.getPreviewChannelId( + PreviewDataManager.TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL); + if (recordedPreviewChannelId == PreviewDataManager.INVALID_PREVIEW_CHANNEL_ID + && !recordedPrograms.isEmpty()) { + createPreviewChannelForRecordedPrograms(); + } else { + mPreviewDataManager.updatePreviewProgramsForChannel(recordedPreviewChannelId, + generatePreviewProgramContentsFromRecordedPrograms( + recordedPreviewChannelId, recordedPrograms), null); + } + } + + private void createPreviewChannelForRecordedPrograms() { + mPreviewDataManager.createPreviewChannel( + PreviewDataManager.TYPE_RECORDED_PROGRAM_PREVIEW_CHANNEL, + new PreviewDataManager.OnPreviewChannelCreationResultListener() { + @Override + public void onPreviewChannelCreationResult(long createdPreviewChannelId) { + if (createdPreviewChannelId + != PreviewDataManager.INVALID_PREVIEW_CHANNEL_ID) { + updatePreviewDataForRecordedProgramsInternal(); + } + } + }); + } + + private Set<RecordedProgram> generateRecommendationRecordedPrograms() { + Set<RecordedProgram> programs = new HashSet<>(); + ArrayList<RecordedProgram> sortedRecordedPrograms = + new ArrayList<>(mDvrDataManager.getRecordedPrograms()); + Collections.sort( + sortedRecordedPrograms, RecordedProgram.START_TIME_THEN_ID_COMPARATOR.reversed()); + for (RecordedProgram recordedProgram : sortedRecordedPrograms) { + if (!TextUtils.isEmpty(recordedProgram.getPosterArtUri())) { + programs.add(recordedProgram); + if (programs.size() >= RECOMMENDATION_COUNT) { + break; + } + } + } + return programs; + } + + private Set<PreviewProgramContent> generatePreviewProgramContentsFromRecordedPrograms( + long previewChannelId, Set<RecordedProgram> recordedPrograms) { + Set<PreviewProgramContent> result = new HashSet<>(); + for (RecordedProgram recordedProgram : recordedPrograms) { + result.add(PreviewProgramContent.createFromRecordedProgram(mContext, previewChannelId, + recordedProgram)); + } + return result; + } +} diff --git a/src/com/android/tv/search/DataManagerSearch.java b/src/com/android/tv/search/DataManagerSearch.java index 5f89a21a..d90908f1 100644 --- a/src/com/android/tv/search/DataManagerSearch.java +++ b/src/com/android/tv/search/DataManagerSearch.java @@ -265,9 +265,7 @@ public class DataManagerSearch implements SearchInterface { } private String buildIntentData(long channelId) { - return TvContract.buildChannelUri(channelId).buildUpon() - .appendQueryParameter(Utils.PARAM_SOURCE, SOURCE_TV_SEARCH) - .build().toString(); + return TvContract.buildChannelUri(channelId).toString(); } private boolean isRatingBlocked(TvContentRating[] ratings) { diff --git a/src/com/android/tv/search/LocalSearchProvider.java b/src/com/android/tv/search/LocalSearchProvider.java index 9255a43d..ef9336d7 100644 --- a/src/com/android/tv/search/LocalSearchProvider.java +++ b/src/com/android/tv/search/LocalSearchProvider.java @@ -23,10 +23,19 @@ import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; import android.text.TextUtils; import android.util.Log; +import com.android.tv.TvApplication; +import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; +import com.android.tv.perf.EventNames; +import com.android.tv.perf.PerformanceMonitor; +import com.android.tv.perf.TimerEvent; import com.android.tv.util.PermissionUtils; +import com.android.tv.util.TvUriMatcher; import java.util.ArrayList; import java.util.Arrays; @@ -36,6 +45,9 @@ public class LocalSearchProvider extends ContentProvider { private static final String TAG = "LocalSearchProvider"; private static final boolean DEBUG = false; + /** The authority for LocalSearchProvider. */ + public static final String AUTHORITY = "com.android.tv.search"; + public static final int PROGRESS_PERCENTAGE_HIDE = -1; // TODO: Remove this once added to the SearchManager. @@ -56,58 +68,93 @@ public class LocalSearchProvider extends ContentProvider { }; private static final String EXPECTED_PATH_PREFIX = "/" + SearchManager.SUGGEST_URI_PATH_QUERY; + static final String SUGGEST_PARAMETER_ACTION = "action"; // The launcher passes 10 as a 'limit' parameter by default. - private static final int DEFAULT_SEARCH_LIMIT = 10; + @VisibleForTesting + static final int DEFAULT_SEARCH_LIMIT = 10; + @VisibleForTesting + static final int DEFAULT_SEARCH_ACTION = SearchInterface.ACTION_TYPE_AMBIGUOUS; private static final String NO_LIVE_CONTENTS = "0"; private static final String LIVE_CONTENTS = "1"; - static final String SUGGEST_PARAMETER_ACTION = "action"; - static final int DEFAULT_SEARCH_ACTION = SearchInterface.ACTION_TYPE_AMBIGUOUS; + private PerformanceMonitor mPerformanceMonitor; + + /** Used only for testing */ + private SearchInterface mSearchInterface; @Override public boolean onCreate() { + mPerformanceMonitor = TvApplication.getSingletons(getContext()).getPerformanceMonitor(); return true; } + @VisibleForTesting + void setSearchInterface(SearchInterface searchInterface) { + SoftPreconditions.checkState(TvCommonUtils.isRunningInTest()); + mSearchInterface = searchInterface; + } + @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, - String sortOrder) { + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + if (TvUriMatcher.match(uri) != TvUriMatcher.MATCH_ON_DEVICE_SEARCH) { + throw new IllegalArgumentException("Unknown URI: " + uri); + } + TimerEvent queryTimer = mPerformanceMonitor.startTimer(); if (DEBUG) { 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()); + SearchInterface search = mSearchInterface; + if (search == null) { + 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(); - int limit = DEFAULT_SEARCH_LIMIT; - int action = DEFAULT_SEARCH_ACTION; - try { - limit = Integer.parseInt(uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)); - action = Integer.parseInt(uri.getQueryParameter(SUGGEST_PARAMETER_ACTION)); - } catch (NumberFormatException | UnsupportedOperationException e) { - // Ignore the exceptions + int limit = getQueryParamater(uri, SearchManager.SUGGEST_PARAMETER_LIMIT, + DEFAULT_SEARCH_LIMIT); + if (limit <= 0) { + limit = DEFAULT_SEARCH_LIMIT; + } + int action = getQueryParamater(uri, SUGGEST_PARAMETER_ACTION, DEFAULT_SEARCH_ACTION); + if (action < SearchInterface.ACTION_TYPE_START + || action > SearchInterface.ACTION_TYPE_END) { + action = DEFAULT_SEARCH_ACTION; } List<SearchResult> results = new ArrayList<>(); if (!TextUtils.isEmpty(query)) { results.addAll(search.search(query, limit, action)); } Cursor c = createSuggestionsCursor(results); - if (DEBUG) Log.d(TAG, "Elapsed time: " + (SystemClock.elapsedRealtime() - time) + "(msec)"); + if (DEBUG) { + Log.d(TAG, "Elapsed time(count=" + c.getCount() + "): " + + (SystemClock.elapsedRealtime() - time) + "(msec)"); + } + mPerformanceMonitor.stopTimer(queryTimer, EventNames.ON_DEVICE_SEARCH); return c; } + private int getQueryParamater(Uri uri, String key, int defaultValue) { + try { + return Integer.parseInt(uri.getQueryParameter(key)); + } catch (NumberFormatException | UnsupportedOperationException e) { + // Ignore the exceptions + } + return defaultValue; + } + private Cursor createSuggestionsCursor(List<SearchResult> results) { MatrixCursor cursor = new MatrixCursor(SEARCHABLE_COLUMNS, results.size()); List<String> row = new ArrayList<>(SEARCHABLE_COLUMNS.length); + int index = 0; for (SearchResult result : results) { row.clear(); row.add(result.title); @@ -122,6 +169,7 @@ public class LocalSearchProvider extends ContentProvider { row.add(result.duration == 0 ? null : String.valueOf(result.duration)); row.add(String.valueOf(result.progressPercentage)); cursor.addRow(row); + if (DEBUG) Log.d(TAG, "Result[" + (++index) + "]: " + result); } return cursor; } @@ -171,9 +219,20 @@ public class LocalSearchProvider extends ContentProvider { @Override public String toString() { - return "channelId: " + channelId + - ", channelNumber: " + channelNumber + - ", title: " + title; + return "SearchResult{channelId=" + channelId + + ", channelNumber=" + channelNumber + + ", title=" + title + + ", description=" + description + + ", imageUri=" + imageUri + + ", intentAction=" + intentAction + + ", intentData=" + intentData + + ", contentType=" + contentType + + ", isLive=" + isLive + + ", videoWidth=" + videoWidth + + ", videoHeight=" + videoHeight + + ", duration=" + duration + + ", progressPercentage=" + progressPercentage + + "}"; } } }
\ No newline at end of file diff --git a/src/com/android/tv/search/SearchInterface.java b/src/com/android/tv/search/SearchInterface.java index caa45812..d631972a 100644 --- a/src/com/android/tv/search/SearchInterface.java +++ b/src/com/android/tv/search/SearchInterface.java @@ -24,11 +24,11 @@ import java.util.List; * Interface for channel and program search. */ public interface SearchInterface { - String SOURCE_TV_SEARCH = "TvSearch"; - + int ACTION_TYPE_START = 1; int ACTION_TYPE_AMBIGUOUS = 1; int ACTION_TYPE_SWITCH_CHANNEL = 2; int ACTION_TYPE_SWITCH_INPUT = 3; + int ACTION_TYPE_END = 3; /** * Search channels, inputs, or programs. diff --git a/src/com/android/tv/search/TvProviderSearch.java b/src/com/android/tv/search/TvProviderSearch.java index 2ceec19a..e7d8a02d 100644 --- a/src/com/android/tv/search/TvProviderSearch.java +++ b/src/com/android/tv/search/TvProviderSearch.java @@ -402,9 +402,7 @@ public class TvProviderSearch implements SearchInterface { } private String buildIntentData(long channelId) { - return TvContract.buildChannelUri(channelId).buildUpon() - .appendQueryParameter(Utils.PARAM_SOURCE, SOURCE_TV_SEARCH) - .build().toString(); + return TvContract.buildChannelUri(channelId).toString(); } private boolean isRatingBlocked(String ratings) { @@ -432,6 +430,9 @@ public class TvProviderSearch implements SearchInterface { // Find exact matches first. for (TvInputInfo input : inputList) { + if (input.getType() == TvInputInfo.TYPE_TUNER) { + continue; + } String label = canonicalizeLabel(input.loadLabel(mContext)); String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) { @@ -449,6 +450,9 @@ public class TvProviderSearch implements SearchInterface { // Then look for partial matches. for (TvInputInfo input : inputList) { + if (input.getType() == TvInputInfo.TYPE_TUNER) { + continue; + } String label = canonicalizeLabel(input.loadLabel(mContext)); String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext)); if ((label != null && label.contains(query)) || diff --git a/src/com/android/tv/tuner/ChannelScanFileParser.java b/src/com/android/tv/tuner/ChannelScanFileParser.java index 2dd36074..a255de3e 100644 --- a/src/com/android/tv/tuner/ChannelScanFileParser.java +++ b/src/com/android/tv/tuner/ChannelScanFileParser.java @@ -90,10 +90,6 @@ public class ChannelScanFileParser { 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)); } diff --git a/src/com/android/tv/tuner/UsbTunerHal.java b/src/com/android/tv/tuner/DvbTunerHal.java index 22e35ea1..ea977230 100644 --- a/src/com/android/tv/tuner/UsbTunerHal.java +++ b/src/com/android/tv/tuner/DvbTunerHal.java @@ -26,9 +26,9 @@ import java.util.SortedSet; import java.util.TreeSet; /** - * A class to handle a hardware USB tuner device. + * A class to handle a hardware Linux DVB API supported tuner device. */ -public class UsbTunerHal extends TunerHal { +public class DvbTunerHal extends TunerHal { private static final Object sLock = new Object(); // @GuardedBy("sLock") @@ -37,7 +37,7 @@ public class UsbTunerHal extends TunerHal { private final DvbDeviceAccessor mDvbDeviceAccessor; private DvbDeviceInfoWrapper mDvbDeviceInfo; - protected UsbTunerHal(Context context) { + protected DvbTunerHal(Context context) { super(context); mDvbDeviceAccessor = new DvbDeviceAccessor(context); } @@ -55,6 +55,7 @@ public class UsbTunerHal extends TunerHal { if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo); mDvbDeviceInfo = deviceInfo; sUsedDvbDevices.add(deviceInfo); + getDeliverySystemTypeFromDevice(); return true; } } @@ -169,6 +170,10 @@ public class UsbTunerHal extends TunerHal { * Gets the number of USB tuner devices currently present. */ public static int getNumberOfDevices(Context context) { - return (new DvbDeviceAccessor(context)).getNumOfDvbDevices(); + try { + return (new DvbDeviceAccessor(context)).getNumOfDvbDevices(); + } catch (Exception e) { + return 0; + } } } diff --git a/src/com/android/tv/tuner/TunerHal.java b/src/com/android/tv/tuner/TunerHal.java index de19766e..1176cdf0 100644 --- a/src/com/android/tv/tuner/TunerHal.java +++ b/src/com/android/tv/tuner/TunerHal.java @@ -19,7 +19,12 @@ package com.android.tv.tuner; import android.content.Context; import android.support.annotation.IntDef; import android.support.annotation.StringDef; +import android.support.annotation.WorkerThread; import android.util.Log; +import android.util.Pair; + +import com.android.tv.Features; +import com.android.tv.customization.TvCustomizationManager; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -46,14 +51,43 @@ public abstract class TunerHal implements AutoCloseable { public static final String MODULATION_8VSB = "8VSB"; public static final String MODULATION_QAM256 = "QAM256"; + @IntDef({ DELIVERY_SYSTEM_UNDEFINED, DELIVERY_SYSTEM_ATSC, DELIVERY_SYSTEM_DVBC, + DELIVERY_SYSTEM_DVBS, DELIVERY_SYSTEM_DVBS2, DELIVERY_SYSTEM_DVBT, + DELIVERY_SYSTEM_DVBT2 }) + @Retention(RetentionPolicy.SOURCE) + public @interface DeliverySystemType {} + public static final int DELIVERY_SYSTEM_UNDEFINED = 0; + public static final int DELIVERY_SYSTEM_ATSC = 1; + public static final int DELIVERY_SYSTEM_DVBC = 2; + public static final int DELIVERY_SYSTEM_DVBS = 3; + public static final int DELIVERY_SYSTEM_DVBS2 = 4; + public static final int DELIVERY_SYSTEM_DVBT = 5; + public static final int DELIVERY_SYSTEM_DVBT2 = 6; + + @IntDef({ TUNER_TYPE_BUILT_IN, TUNER_TYPE_USB, TUNER_TYPE_NETWORK }) + @Retention(RetentionPolicy.SOURCE) + public @interface TunerType {} public static final int TUNER_TYPE_BUILT_IN = 1; public static final int TUNER_TYPE_USB = 2; + public static final int TUNER_TYPE_NETWORK = 3; protected static final int PID_PAT = 0; protected static final int PID_ATSC_SI_BASE = 0x1ffb; + protected static final int PID_DVB_SDT = 0x0011; + protected static final int PID_DVB_EIT = 0x0012; 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. + @IntDef({ + BUILT_IN_TUNER_TYPE_LINUX_DVB + }) + @Retention(RetentionPolicy.SOURCE) + private @interface BuiltInTunerType {} + private static final int BUILT_IN_TUNER_TYPE_LINUX_DVB = 1; + + private static Integer sBuiltInTunerType; + + protected @DeliverySystemType int mDeliverySystemType; private boolean mIsStreaming; private int mFrequency; private String mModulation; @@ -67,33 +101,62 @@ public abstract class TunerHal implements AutoCloseable { * @param context context for creating the TunerHal instance * @return the TunerHal instance */ + @WorkerThread 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; + if (DvbTunerHal.getNumberOfDevices(context) > 0) { + if (DEBUG) Log.d(TAG, "Use DvbTunerHal"); + tunerHal = new DvbTunerHal(context); } - return null; + return tunerHal != null && tunerHal.openFirstAvailable() ? tunerHal : null; } /** * Gets the number of tuner devices currently present. */ - public static int getTunerCount(Context context) { - if (getTunerType(context) == TUNER_TYPE_BUILT_IN) { + @WorkerThread + public static Pair<Integer, Integer> getTunerTypeAndCount(Context context) { + if (useBuiltInTuner(context)) { + if (getBuiltInTunerType(context) == BUILT_IN_TUNER_TYPE_LINUX_DVB) { + return new Pair<>(TUNER_TYPE_BUILT_IN, DvbTunerHal.getNumberOfDevices(context)); + } + } else { + int usbTunerCount = DvbTunerHal.getNumberOfDevices(context); + if (usbTunerCount > 0) { + return new Pair<>(TUNER_TYPE_USB, usbTunerCount); + } } - return UsbTunerHal.getNumberOfDevices(context); + return new Pair<>(null, 0); + } + + /** + * Check a delivery system is for DVB or not. + */ + public static boolean isDvbDeliverySystem(@DeliverySystemType int deliverySystemType) { + return deliverySystemType == DELIVERY_SYSTEM_DVBC + || deliverySystemType == DELIVERY_SYSTEM_DVBS + || deliverySystemType == DELIVERY_SYSTEM_DVBS2 + || deliverySystemType == DELIVERY_SYSTEM_DVBT + || deliverySystemType == DELIVERY_SYSTEM_DVBT2; } /** - * Gets the type of tuner devices currently used. + * Returns if tuner input service would use built-in tuners instead of USB tuners or network + * tuners. */ - public static int getTunerType(Context context) { - return TUNER_TYPE_USB; + static boolean useBuiltInTuner(Context context) { + return getBuiltInTunerType(context) != 0; + } + + private static @BuiltInTunerType int getBuiltInTunerType(Context context) { + if (sBuiltInTunerType == null) { + sBuiltInTunerType = 0; + if (TvCustomizationManager.hasLinuxDvbBuiltInTuner(context) + && DvbTunerHal.getNumberOfDevices(context) > 0) { + sBuiltInTunerType = BUILT_IN_TUNER_TYPE_LINUX_DVB; + } + } + return sBuiltInTunerType; } protected TunerHal(Context context) { @@ -106,6 +169,20 @@ public abstract class TunerHal implements AutoCloseable { return mIsStreaming; } + protected void getDeliverySystemTypeFromDevice() { + if (mDeliverySystemType == DELIVERY_SYSTEM_UNDEFINED) { + mDeliverySystemType = nativeGetDeliverySystemType(getDeviceId()); + } + } + + /** + * Returns {@code true} if this tuner HAL can be reused to save tuning time between channels + * of the same frequency. + */ + public boolean isReusable() { + return true; + } + @Override protected void finalize() throws Throwable { super.finalize(); @@ -131,9 +208,12 @@ public abstract class TunerHal implements AutoCloseable { * * @param frequency a frequency of the channel to tune to * @param modulation a modulation method of the channel to tune to + * @param channelNumber channel number when channel number is already known. Some tuner HAL + * may use channelNumber instead of frequency for tune. * @return {@code true} if the operation was successful, {@code false} otherwise */ - public synchronized boolean tune(int frequency, @ModulationType String modulation) { + public synchronized boolean tune(int frequency, @ModulationType String modulation, + String channelNumber) { if (!isDeviceOpen()) { Log.e(TAG, "There's no available device"); return false; @@ -148,6 +228,10 @@ public abstract class TunerHal implements AutoCloseable { if (mFrequency == frequency && Objects.equals(mModulation, modulation)) { addPidFilter(PID_PAT, FILTER_TYPE_OTHER); addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER); + if (isDvbDeliverySystem(mDeliverySystemType)) { + addPidFilter(PID_DVB_SDT, FILTER_TYPE_OTHER); + addPidFilter(PID_DVB_EIT, FILTER_TYPE_OTHER); + } mIsStreaming = true; return true; } @@ -156,6 +240,10 @@ public abstract class TunerHal implements AutoCloseable { if (nativeTune(getDeviceId(), frequency, modulation, timeout_ms)) { addPidFilter(PID_PAT, FILTER_TYPE_OTHER); addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER); + if (isDvbDeliverySystem(mDeliverySystemType)) { + addPidFilter(PID_DVB_SDT, FILTER_TYPE_OTHER); + addPidFilter(PID_DVB_EIT, FILTER_TYPE_OTHER); + } mFrequency = frequency; mModulation = modulation; mIsStreaming = true; @@ -189,6 +277,7 @@ public abstract class TunerHal implements AutoCloseable { 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); + protected native int nativeGetDeliverySystemType(long deviceId); /** * Stops current tuning. The tuner device and pid filters will be reset by this call and make @@ -210,6 +299,10 @@ public abstract class TunerHal implements AutoCloseable { nativeSetHasPendingTune(getDeviceId(), hasPendingTune); } + public int getDeliverySystemType() { + return mDeliverySystemType; + } + protected native void nativeStopTune(long deviceId); /** @@ -235,7 +328,7 @@ public abstract class TunerHal implements AutoCloseable { /** * Opens Linux DVB frontend device. This method is called from native JNI and used only for - * UsbTunerHal. + * DvbTunerHal. */ protected int openDvbFrontEndFd() { return -1; @@ -243,7 +336,7 @@ public abstract class TunerHal implements AutoCloseable { /** * Opens Linux DVB demux device. This method is called from native JNI and used only for - * UsbTunerHal. + * DvbTunerHal. */ protected int openDvbDemuxFd() { return -1; @@ -251,7 +344,7 @@ public abstract class TunerHal implements AutoCloseable { /** * Opens Linux DVB dvr device. This method is called from native JNI and used only for - * UsbTunerHal. + * DvbTunerHal. */ protected int openDvbDvrFd() { return -1; diff --git a/src/com/android/tv/tuner/TunerInputController.java b/src/com/android/tv/tuner/TunerInputController.java index d89b6a0c..e06b9b4a 100644 --- a/src/com/android/tv/tuner/TunerInputController.java +++ b/src/com/android/tv/tuner/TunerInputController.java @@ -16,30 +16,43 @@ package com.android.tv.tuner; +import android.app.AlarmManager; +import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; 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.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.os.Message; -import android.support.v4.os.BuildCompat; +import android.os.SystemClock; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.text.TextUtils; import android.util.Log; import android.widget.Toast; import com.android.tv.Features; +import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.tuner.R; +import com.android.tv.common.SoftPreconditions; import com.android.tv.tuner.setup.TunerSetupActivity; import com.android.tv.tuner.tvinput.TunerTvInputService; +import com.android.tv.tuner.util.SystemPropertiesProxy; import com.android.tv.tuner.util.TunerInputInfoUtils; +import java.text.ParseException; +import java.text.SimpleDateFormat; import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; /** * Controls the package visibility of {@link TunerTvInputService}. @@ -48,84 +61,94 @@ import java.util.Map; * {@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 { +public class TunerInputController { private static final boolean DEBUG = true; private static final String TAG = "TunerInputController"; + private static final String PREFERENCE_IS_NETWORK_TUNER_ATTACHED = "network_tuner"; + private static final String SECURITY_PATCH_LEVEL_KEY = "ro.build.version.security_patch"; + private static final String SECURITY_PATCH_LEVEL_FORMAT = "yyyy-MM-dd"; + + /** + * Action of {@link Intent} to check network connection repeatedly when it is necessary. + */ + private static final String CHECKING_NETWORK_CONNECTION = + "com.android.tv.action.CHECKING_NETWORK_CONNECTION"; + + private static final String EXTRA_CHECKING_DURATION = + "com.android.tv.action.extra.CHECKING_DURATION"; + + private static final long INITIAL_CHECKING_DURATION_MS = TimeUnit.SECONDS.toMillis(10); + private static final long MAXIMUM_CHECKING_DURATION_MS = TimeUnit.MINUTES.toMillis(10); private static final TunerDevice[] TUNER_DEVICES = { - new TunerDevice(0x2040, 0xb123), // WinTV-HVR-955Q - new TunerDevice(0x07ca, 0x0837) // AverTV Volar Hybrid Q + new TunerDevice(0x2040, 0xb123, null), // WinTV-HVR-955Q + new TunerDevice(0x07ca, 0x0837, null), // AverTV Volar Hybrid Q + // WinTV-dualHD (bulk) will be supported after 2017 April security patch. + new TunerDevice(0x2040, 0x826d, "2017-04-01"), // WinTV-dualHD (bulk) + // STOPSHIP: Add WinTV-soloHD (Isoc) temporary for test. Remove this after test complete. + new TunerDevice(0x2040, 0x0264, null), }; 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}. + * Checks status of USB devices to see if there are available USB tuners connected. */ - 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; - } + public static void onCheckingUsbTunerStatus(Context context, String action) { + onCheckingUsbTunerStatus(context, action, new CheckDvbDeviceHandler()); } - @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); + private static void onCheckingUsbTunerStatus(Context context, String action, + @NonNull CheckDvbDeviceHandler handler) { + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context); + if (TunerHal.useBuiltInTuner(context)) { + enableTunerTvInputService(context, true, false, TunerHal.TUNER_TYPE_BUILT_IN); return; } + // Falls back to the below to check USB tuner devices. + boolean enabled = isUsbTunerConnected(context); + handler.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. + handler.sendMessageDelayed(handler.obtainMessage(MSG_ENABLE_INPUT_SERVICE, context), + DVB_DRIVER_CHECK_DELAY_MS); + } else { + if (sharedPreferences.getBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false)) { + // Since network tuner is attached, do not disable TunerTvInput, + // just updates the TvInputInfo. + TunerInputInfoUtils.updateTunerInputInfo(context); + return; + } + enableTunerTvInputService(context, false, false, TextUtils + .equals(action, UsbManager.ACTION_USB_DEVICE_DETACHED) ? + TunerHal.TUNER_TYPE_USB : null); + } + } - 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; + private static void onNetworkTunerChanged(Context context, boolean enabled) { + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context); + if (enabled) { + // Network tuner detection is initiated by UI. So the app should not + // be killed. + sharedPreferences.edit().putBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, true).apply(); + enableTunerTvInputService(context, true, true, TunerHal.TUNER_TYPE_NETWORK); + } else { + sharedPreferences.edit() + .putBoolean(PREFERENCE_IS_NETWORK_TUNER_ATTACHED, false).apply(); + if(!isUsbTunerConnected(context) && !TunerHal.useBuiltInTuner(context)) { + // Network tuner detection is initiated by UI. So the app should not + // be killed. + enableTunerTvInputService(context, false, true, TunerHal.TUNER_TYPE_NETWORK); + } else { + // Since USB tuner is attached, do not disable TunerTvInput, + // just updates the TvInputInfo. + TunerInputInfoUtils.updateTunerInputInfo(context); + } } } @@ -135,15 +158,18 @@ public class TunerInputController extends BroadcastReceiver { * @param context {@link Context} instance * @return {@code true} if any tuner device we support is plugged in */ - private boolean isUsbTunerConnected(Context context) { + private static boolean isUsbTunerConnected(Context context) { UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); Map<String, UsbDevice> deviceList = manager.getDeviceList(); + String currentSecurityLevel = + SystemPropertiesProxy.getString(SECURITY_PATCH_LEVEL_KEY, null); + for (UsbDevice device : deviceList.values()) { if (DEBUG) { Log.d(TAG, "Device: " + device); } for (TunerDevice tuner : TUNER_DEVICES) { - if (tuner.equals(device)) { + if (tuner.equals(device) && tuner.isSupported(currentSecurityLevel)) { Log.i(TAG, "Tuner found"); return true; } @@ -158,7 +184,8 @@ public class TunerInputController extends BroadcastReceiver { * @param context {@link Context} instance * @param enabled {@code true} to enable the service; otherwise {@code false} */ - private void enableTunerTvInputService(Context context, boolean enabled) { + private static void enableTunerTvInputService(Context context, boolean enabled, + boolean forceDontKillApp, Integer tunerType) { if (DEBUG) Log.d(TAG, "enableTunerTvInputService: " + enabled); PackageManager pm = context.getPackageManager(); ComponentName componentName = new ComponentName(context, TunerTvInputService.class); @@ -170,23 +197,182 @@ public class TunerInputController extends BroadcastReceiver { // 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() + int flags = forceDontKillApp + || 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); + // Send/cancel the USB tuner TV input setup notification. + TunerSetupActivity.onTvInputEnabled(context, enabled, tunerType); // 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 (!enabled && tunerType != null) { + if (tunerType == TunerHal.TUNER_TYPE_USB) { + Toast.makeText(context, R.string.msg_usb_tuner_disconnected, + Toast.LENGTH_SHORT).show(); + } else if (tunerType == TunerHal.TUNER_TYPE_NETWORK) { + Toast.makeText(context, R.string.msg_network_tuner_disconnected, + 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. + // When # of tuners is changed or the tuner input service is switching from/to using + // network tuners or the device just boots. TunerInputInfoUtils.updateTunerInputInfo(context); } } + + /** + * Discovers a network tuner. If the network connection is down, it won't repeatedly checking. + */ + public static void executeNetworkTunerDiscoveryAsyncTask(final Context context) { + boolean runningInMainProcess = + TvApplication.getSingletons(context).isRunningInMainProcess(); + SoftPreconditions.checkState(runningInMainProcess); + if (!runningInMainProcess) { + return; + } + executeNetworkTunerDiscoveryAsyncTask(context, 0); + } + + /** + * Discovers a network tuner. + * @param context {@link Context} + * @param repeatedDurationMs the time length to wait to repeatedly check network status to start + * finding network tuner when the network connection is not available. + * {@code 0} to disable repeatedly checking. + */ + private static void executeNetworkTunerDiscoveryAsyncTask(final Context context, + final long repeatedDurationMs) { + if (!Features.NETWORK_TUNER.isEnabled(context)) { + return; + } + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + if (isNetworkConnected(context)) { + // Implement and execute network tuner discovery AsyncTask here. + } else if (repeatedDurationMs > 0) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent networkCheckingIntent = new Intent(context, IntentReceiver.class); + networkCheckingIntent.setAction(CHECKING_NETWORK_CONNECTION); + networkCheckingIntent.putExtra(EXTRA_CHECKING_DURATION, repeatedDurationMs); + PendingIntent alarmIntent = PendingIntent.getBroadcast( + context, 0, networkCheckingIntent, PendingIntent.FLAG_UPDATE_CURRENT); + alarmManager.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + + repeatedDurationMs, alarmIntent); + } + return null; + } + + @Override + protected void onPostExecute(Boolean result) { + if (result == null) { + return; + } + onNetworkTunerChanged(context, result); + } + }.execute(); + } + + private static boolean isNetworkConnected(Context context) { + ConnectivityManager cm = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = cm.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); + } + + public static class IntentReceiver extends BroadcastReceiver { + private final CheckDvbDeviceHandler mHandler = new CheckDvbDeviceHandler(); + + @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, false, null); + return; + } + switch (intent.getAction()) { + case Intent.ACTION_BOOT_COMPLETED: + executeNetworkTunerDiscoveryAsyncTask(context, INITIAL_CHECKING_DURATION_MS); + case TvApplication.ACTION_APPLICATION_FIRST_LAUNCHED: + case UsbManager.ACTION_USB_DEVICE_ATTACHED: + case UsbManager.ACTION_USB_DEVICE_DETACHED: + onCheckingUsbTunerStatus(context, intent.getAction(), mHandler); + break; + case CHECKING_NETWORK_CONNECTION: + long repeatedDurationMs = intent.getLongExtra(EXTRA_CHECKING_DURATION, + INITIAL_CHECKING_DURATION_MS); + executeNetworkTunerDiscoveryAsyncTask(context, + Math.min(repeatedDurationMs * 2, MAXIMUM_CHECKING_DURATION_MS)); + 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; + + // security patch level from which the specific tuner type is supported. + private final String minSecurityLevel; + + private TunerDevice(int vendorId, int productId, String minSecurityLevel) { + this.vendorId = vendorId; + this.productId = productId; + this.minSecurityLevel = minSecurityLevel; + } + + private boolean equals(UsbDevice device) { + return device.getVendorId() == vendorId && device.getProductId() == productId; + } + + private boolean isSupported(String currentSecurityLevel) { + if (minSecurityLevel == null) { + return true; + } + + long supportSecurityLevelTimeStamp = 0; + long currentSecurityLevelTimestamp = 0; + try { + SimpleDateFormat format = new SimpleDateFormat(SECURITY_PATCH_LEVEL_FORMAT); + supportSecurityLevelTimeStamp = format.parse(minSecurityLevel).getTime(); + currentSecurityLevelTimestamp = format.parse(currentSecurityLevel).getTime(); + } catch (ParseException e) { + } + return supportSecurityLevelTimeStamp != 0 + && supportSecurityLevelTimeStamp <= currentSecurityLevelTimestamp; + } + } + + private static class CheckDvbDeviceHandler extends Handler { + private DvbDeviceAccessor mDvbDeviceAccessor; + + CheckDvbDeviceHandler() { + super(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); + } + boolean enabled = mDvbDeviceAccessor.isDvbDeviceAvailable(); + enableTunerTvInputService( + context, enabled, false, enabled ? TunerHal.TUNER_TYPE_USB : null); + break; + } + } + } } diff --git a/src/com/android/tv/tuner/TunerPreferences.java b/src/com/android/tv/tuner/TunerPreferences.java index 1547e3ae..11a6a969 100644 --- a/src/com/android/tv/tuner/TunerPreferences.java +++ b/src/com/android/tv/tuner/TunerPreferences.java @@ -25,11 +25,15 @@ import android.database.Cursor; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; +import android.support.annotation.GuardedBy; +import android.support.annotation.IntDef; 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; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; /** * A helper class for the USB tuner preferences. @@ -39,21 +43,53 @@ public class 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_LAST_POSTAL_CODE = "last_postal_code"; 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 PREFS_KEY_TRICKPLAY_SETTING = "trickplay_setting"; + private static final String PREFS_KEY_TRICKPLAY_EXPIRED_MS = "trickplay_expired_ms"; private static final String SHARED_PREFS_NAME = "com.android.tv.tuner.preferences"; public static final int CHANNEL_DATA_VERSION_NOT_SET = -1; + @IntDef({TRICKPLAY_SETTING_NOT_SET, TRICKPLAY_SETTING_DISABLED, TRICKPLAY_SETTING_ENABLED}) + @Retention(RetentionPolicy.SOURCE) + public @interface TrickplaySetting { + } + + /** + * Trickplay setting is not changed by a user. Trickplay will be enabled in this case. + */ + public static final int TRICKPLAY_SETTING_NOT_SET = -1; + + /** + * Trickplay setting is disabled. + */ + public static final int TRICKPLAY_SETTING_DISABLED = 0; + + /** + * Trickplay setting is enabled. + */ + public static final int TRICKPLAY_SETTING_ENABLED = 1; + + @GuardedBy("TunerPreferences.class") private static final Bundle sPreferenceValues = new Bundle(); private static LoadPreferencesTask sLoadPreferencesTask; private static ContentObserver sContentObserver; + private static TunerPreferencesChangedListener sPreferencesChangedListener = null; private static boolean sInitialized; /** + * Listeners for TunerPreferences change. + */ + public interface TunerPreferencesChangedListener { + void onTunerPreferencesChanged(); + } + + /** * Initializes the USB tuner preferences. */ @MainThread @@ -86,11 +122,19 @@ public class TunerPreferences { /** * Releases the resources. */ - @MainThread - public static void release(Context context) { + public static synchronized void release(Context context) { if (useContentProvider(context) && sContentObserver != null) { context.getContentResolver().unregisterContentObserver(sContentObserver); } + setTunerPreferencesChangedListener(null); + } + + /** + * Sets the listener for TunerPreferences change. + */ + public static void setTunerPreferencesChangedListener( + TunerPreferencesChangedListener listener) { + sPreferencesChangedListener = listener; } /** @@ -99,7 +143,8 @@ public class TunerPreferences { * This preferences is used across processes, so the preferences should be loaded again when the * databases changes. */ - public static synchronized void loadPreferences(Context context) { + @MainThread + public static void loadPreferences(Context context) { if (sLoadPreferencesTask != null && sLoadPreferencesTask.getStatus() != AsyncTask.Status.FINISHED) { sLoadPreferencesTask.cancel(true); @@ -113,8 +158,7 @@ public class TunerPreferences { return TisConfiguration.isPackagedWithLiveChannels(context); } - @MainThread - public static int getChannelDataVersion(Context context) { + public static synchronized int getChannelDataVersion(Context context) { SoftPreconditions.checkState(sInitialized); if (useContentProvider(context)) { return sPreferenceValues.getInt(PREFS_KEY_CHANNEL_DATA_VERSION, @@ -126,8 +170,7 @@ public class TunerPreferences { } } - @MainThread - public static void setChannelDataVersion(Context context, int version) { + public static synchronized void setChannelDataVersion(Context context, int version) { if (useContentProvider(context)) { setPreference(context, PREFS_KEY_CHANNEL_DATA_VERSION, version); } else { @@ -137,8 +180,7 @@ public class TunerPreferences { } } - @MainThread - public static int getScannedChannelCount(Context context) { + public static synchronized int getScannedChannelCount(Context context) { SoftPreconditions.checkState(sInitialized); if (useContentProvider(context)) { return sPreferenceValues.getInt(PREFS_KEY_SCANNED_CHANNEL_COUNT); @@ -148,8 +190,7 @@ public class TunerPreferences { } } - @MainThread - public static void setScannedChannelCount(Context context, int channelCount) { + public static synchronized void setScannedChannelCount(Context context, int channelCount) { if (useContentProvider(context)) { setPreference(context, PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount); } else { @@ -159,8 +200,25 @@ public class TunerPreferences { } } - @MainThread - public static boolean isScanDone(Context context) { + public static synchronized String getLastPostalCode(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getString(PREFS_KEY_LAST_POSTAL_CODE); + } else { + return getSharedPreferences(context).getString(PREFS_KEY_LAST_POSTAL_CODE, null); + } + } + + public static synchronized void setLastPostalCode(Context context, String postalCode) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_LAST_POSTAL_CODE, postalCode); + } else { + getSharedPreferences(context).edit() + .putString(PREFS_KEY_LAST_POSTAL_CODE, postalCode).apply(); + } + } + + public static synchronized boolean isScanDone(Context context) { SoftPreconditions.checkState(sInitialized); if (useContentProvider(context)) { return sPreferenceValues.getBoolean(PREFS_KEY_SCAN_DONE); @@ -170,8 +228,7 @@ public class TunerPreferences { } } - @MainThread - public static void setScanDone(Context context) { + public static synchronized void setScanDone(Context context) { if (useContentProvider(context)) { setPreference(context, PREFS_KEY_SCAN_DONE, true); } else { @@ -181,8 +238,7 @@ public class TunerPreferences { } } - @MainThread - public static boolean shouldShowSetupActivity(Context context) { + public static synchronized boolean shouldShowSetupActivity(Context context) { SoftPreconditions.checkState(sInitialized); if (useContentProvider(context)) { return sPreferenceValues.getBoolean(PREFS_KEY_LAUNCH_SETUP); @@ -192,8 +248,7 @@ public class TunerPreferences { } } - @MainThread - public static void setShouldShowSetupActivity(Context context, boolean need) { + public static synchronized void setShouldShowSetupActivity(Context context, boolean need) { if (useContentProvider(context)) { setPreference(context, PREFS_KEY_LAUNCH_SETUP, need); } else { @@ -203,8 +258,50 @@ public class TunerPreferences { } } - @MainThread - public static boolean getStoreTsStream(Context context) { + public static synchronized long getTrickplayExpiredMs(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getLong(PREFS_KEY_TRICKPLAY_EXPIRED_MS, 0); + } else { + return getSharedPreferences(context) + .getLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, 0); + } + } + + public static synchronized void setTrickplayExpiredMs(Context context, long timeMs) { + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_TRICKPLAY_EXPIRED_MS, timeMs); + } else { + getSharedPreferences(context).edit() + .putLong(TunerPreferences.PREFS_KEY_TRICKPLAY_EXPIRED_MS, timeMs) + .apply(); + } + } + + public static synchronized @TrickplaySetting int getTrickplaySetting(Context context) { + SoftPreconditions.checkState(sInitialized); + if (useContentProvider(context)) { + return sPreferenceValues.getInt(PREFS_KEY_TRICKPLAY_SETTING, TRICKPLAY_SETTING_NOT_SET); + } else { + return getSharedPreferences(context) + .getInt(TunerPreferences.PREFS_KEY_TRICKPLAY_SETTING, TRICKPLAY_SETTING_NOT_SET); + } + } + + public static synchronized void setTrickplaySetting(Context context, + @TrickplaySetting int trickplaySetting) { + SoftPreconditions.checkState(sInitialized); + SoftPreconditions.checkArgument(trickplaySetting != TRICKPLAY_SETTING_NOT_SET); + if (useContentProvider(context)) { + setPreference(context, PREFS_KEY_TRICKPLAY_SETTING, trickplaySetting); + } else { + getSharedPreferences(context).edit() + .putInt(TunerPreferences.PREFS_KEY_TRICKPLAY_SETTING, trickplaySetting) + .apply(); + } + } + + public static synchronized boolean getStoreTsStream(Context context) { SoftPreconditions.checkState(sInitialized); if (useContentProvider(context)) { return sPreferenceValues.getBoolean(PREFS_KEY_STORE_TS_STREAM, false); @@ -214,8 +311,7 @@ public class TunerPreferences { } } - @MainThread - public static void setStoreTsStream(Context context, boolean shouldStore) { + public static synchronized void setStoreTsStream(Context context, boolean shouldStore) { if (useContentProvider(context)) { setPreference(context, PREFS_KEY_STORE_TS_STREAM, shouldStore); } else { @@ -229,8 +325,28 @@ public class TunerPreferences { return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); } - @MainThread - private static void setPreference(final Context context, final String key, final String value) { + private static synchronized void setPreference(Context context, String key, String value) { + sPreferenceValues.putString(key, value); + savePreference(context, key, value); + } + + private static synchronized void setPreference(Context context, String key, int value) { + sPreferenceValues.putInt(key, value); + savePreference(context, key, Integer.toString(value)); + } + + private static synchronized void setPreference(Context context, String key, long value) { + sPreferenceValues.putLong(key, value); + savePreference(context, key, Long.toString(value)); + } + + private static synchronized void setPreference(Context context, String key, boolean value) { + sPreferenceValues.putBoolean(key, value); + savePreference(context, key, Boolean.toString(value)); + } + + private static void savePreference(final Context context, final String key, + final String value) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { @@ -249,18 +365,6 @@ public class TunerPreferences { }.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) { @@ -279,8 +383,12 @@ public class TunerPreferences { String key = cursor.getString(0); String value = cursor.getString(1); switch (key) { + case PREFS_KEY_TRICKPLAY_EXPIRED_MS: + bundle.putLong(key, Long.parseLong(value)); + break; case PREFS_KEY_CHANNEL_DATA_VERSION: case PREFS_KEY_SCANNED_CHANNEL_COUNT: + case PREFS_KEY_TRICKPLAY_SETTING: try { bundle.putInt(key, Integer.parseInt(value)); } catch (NumberFormatException e) { @@ -292,6 +400,9 @@ public class TunerPreferences { case PREFS_KEY_STORE_TS_STREAM: bundle.putBoolean(key, Boolean.parseBoolean(value)); break; + case PREFS_KEY_LAST_POSTAL_CODE: + bundle.putString(key, value); + break; } } } @@ -304,7 +415,14 @@ public class TunerPreferences { @Override protected void onPostExecute(Bundle bundle) { - sPreferenceValues.putAll(bundle); + synchronized (TunerPreferences.class) { + if (bundle != null) { + sPreferenceValues.putAll(bundle); + } + } + if (sPreferencesChangedListener != null) { + sPreferencesChangedListener.onTunerPreferencesChanged(); + } } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java index 3c75caa9..24a0f354 100644 --- a/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java +++ b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java @@ -245,6 +245,10 @@ public class CaptionTrackRenderer implements Handler.Callback { } } + public void clear() { + mHandler.sendEmptyMessage(MSG_CAPTION_CLEAR); + } + public void reset() { mCurrentWindowLayout = null; mIsDelayed = false; diff --git a/src/com/android/tv/tuner/cc/Cea708Parser.java b/src/com/android/tv/tuner/cc/Cea708Parser.java index 92ab0620..d0f6cf11 100644 --- a/src/com/android/tv/tuner/cc/Cea708Parser.java +++ b/src/com/android/tv/tuner/cc/Cea708Parser.java @@ -140,6 +140,7 @@ public class Cea708Parser { private int mCommand = 0; private int mListenServiceNumber = 0; private boolean mDtvCcPacking = false; + private boolean mFirstServiceNumberDiscovered; // Assign a dummy listener in order to avoid null checks. private OnCea708ParserListener mListener = new OnCea708ParserListener() { @@ -208,6 +209,15 @@ public class Cea708Parser { } } + public void clear() { + mDtvCcPacket.clear(); + mCcPackets.clear(); + mBuffer.setLength(0); + mDiscoveredNumBytes.clear(); + mCommand = 0; + mDtvCcPacking = false; + } + public void setListenServiceNumber(int serviceNumber) { mListenServiceNumber = serviceNumber; } @@ -332,12 +342,14 @@ public class Cea708Parser { mDiscoveredNumBytes.put( serviceNumber, blockSize + mDiscoveredNumBytes.get(serviceNumber, 0)); } - if (mLastDiscoveryLaunchedMs + DISCOVERY_PERIOD_MS < SystemClock.elapsedRealtime()) { + if (mLastDiscoveryLaunchedMs + DISCOVERY_PERIOD_MS < SystemClock.elapsedRealtime() + || !mFirstServiceNumberDiscovered) { 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); + mFirstServiceNumberDiscovered = true; } } mDiscoveredNumBytes.clear(); diff --git a/src/com/android/tv/tuner/data/PsipData.java b/src/com/android/tv/tuner/data/PsipData.java index aead4be8..8f98e67c 100644 --- a/src/com/android/tv/tuner/data/PsipData.java +++ b/src/com/android/tv/tuner/data/PsipData.java @@ -24,9 +24,10 @@ import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import com.android.tv.tuner.ts.SectionParser; import com.android.tv.tuner.util.ConvertUtils; -import com.android.tv.tuner.util.StringUtils; +import com.android.tv.util.StringUtils; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -226,6 +227,50 @@ public class PsipData { } } + public static class SdtItem { + private final String mServiceName; + private final String mServiceProviderName; + private final int mServiceType; + private final int mServiceId; + private final int mOriginalNetWorkId; + + public SdtItem(String serviceName, String serviceProviderName, int serviceType, + int serviceId, int originalNetWorkId) { + mServiceName = serviceName; + mServiceProviderName = serviceProviderName; + mServiceType = serviceType; + mServiceId = serviceId; + mOriginalNetWorkId = originalNetWorkId; + } + + public String getServiceName() { + return mServiceName; + } + + public String getServiceProviderName() { + return mServiceProviderName; + } + + public int getServiceType() { + return mServiceType; + } + + public int getServiceId() { + return mServiceId; + } + + public int getOriginalNetworkId() { + return mOriginalNetWorkId; + } + + @Override + public String toString() { + return String.format("ServiceName: %s ServiceProviderName:%s ServiceType:%d " + + "OriginalNetworkId:%d", + mServiceName, mServiceProviderName, mServiceType, mOriginalNetWorkId); + } + } + /** * A base class for descriptors of Ts packets. */ @@ -462,6 +507,92 @@ public class PsipData { } } + public static class ServiceDescriptor extends TsDescriptor { + private final int mServiceType; + private final String mServiceProviderName; + private final String mServiceName; + + public ServiceDescriptor(int serviceType, String serviceProviderName, String serviceName) { + mServiceType = serviceType; + mServiceProviderName = serviceProviderName; + mServiceName = serviceName; + } + + @Override + public int getTag() { + return SectionParser.DVB_DESCRIPTOR_TAG_SERVICE; + } + + public int getServiceType() { + return mServiceType; + } + + public String getServiceProviderName() { + return mServiceProviderName; + } + + public String getServiceName() { + return mServiceName; + } + + @Override + public String toString() { + return String.format( + "Service descriptor, service type: %d, " + + "service provider name: %s, " + + "service name: %s", mServiceType, mServiceProviderName, mServiceName); + } + } + + public static class ShortEventDescriptor extends TsDescriptor { + private final String mLanguage; + private final String mEventName; + private final String mText; + + public ShortEventDescriptor(String language, String eventName, String text) { + mLanguage = language; + mEventName = eventName; + mText = text; + } + + public String getEventName() { + return mEventName; + } + + @Override + public int getTag() { + return SectionParser.DVB_DESCRIPTOR_TAG_SHORT_EVENT; + } + + @Override + public String toString() { + return String.format("ShortEvent Descriptor, language:%s, event name: %s, " + + "text:%s", mLanguage, mEventName, mText); + } + } + + public static class ParentalRatingDescriptor extends TsDescriptor { + private final HashMap<String, Integer> mRatings; + + public ParentalRatingDescriptor(HashMap<String, Integer> ratings) { + mRatings = ratings; + } + + @Override + public int getTag() { + return SectionParser.DVB_DESCRIPTOR_TAG_PARENTAL_RATING; + } + + public HashMap<String, Integer> getRatings() { + return mRatings; + } + + @Override + public String toString() { + return String.format("Parental rating descriptor, ratings:" + mRatings); + } + } + public static class RatingRegion { private final int mName; private final String mDescription; diff --git a/src/com/android/tv/tuner/data/TunerChannel.java b/src/com/android/tv/tuner/data/TunerChannel.java index 89079d77..1cf514c1 100644 --- a/src/com/android/tv/tuner/data/TunerChannel.java +++ b/src/com/android/tv/tuner/data/TunerChannel.java @@ -24,7 +24,7 @@ import com.android.tv.tuner.data.nano.Channel.TunerChannelProto; import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import com.android.tv.tuner.util.Ints; -import com.android.tv.tuner.util.StringUtils; +import com.android.tv.util.StringUtils; import com.google.protobuf.nano.MessageNano; import java.io.IOException; @@ -40,6 +40,11 @@ import java.util.Objects; public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracksInterface { private static final String TAG = "TunerChannel"; + /** + * Channel number separator between major number and minor number. + */ + public static final char CHANNEL_NUMBER_SEPARATOR = '-'; + // See ATSC Code Points Registry. private static final String[] ATSC_SERVICE_TYPE_NAMES = new String[] { "ATSC Reserved", @@ -63,6 +68,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks // According to ISO13818-1, Mpeg2 StreamType has a range from 0x00 to 0xff. public static final int INVALID_STREAMTYPE = -1; + // @GuardedBy(this) Writing operations and toByteArray will be guarded. b/34197766 private final TunerChannelProto mProto; private TunerChannel(PsipData.VctItem channel, int programNumber, @@ -88,6 +94,10 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks } mProto.serviceType = channel.getServiceType(); } + initProto(pmtItems, type); + } + + private void initProto(List<PsiData.PmtItem> pmtItems, int type) { mProto.type = type; mProto.channelId = -1L; mProto.frequency = INVALID_FREQUENCY; @@ -129,14 +139,44 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks mProto.audioTrackIndex = (audioPids.size() > 0) ? 0 : -1; } + private TunerChannel(int programNumber, int type, PsipData.SdtItem channel, + List<PsiData.PmtItem> pmtItems) { + mProto = new TunerChannelProto(); + mProto.tsid = 0; + mProto.virtualMajor = 0; + mProto.virtualMinor = 0; + if (channel == null) { + mProto.shortName = ""; + mProto.programNumber = programNumber; + } else { + mProto.shortName = channel.getServiceName(); + mProto.programNumber = channel.getServiceId(); + mProto.serviceType = channel.getServiceType(); + } + initProto(pmtItems, type); + } + + /** + * Initialize tuner channel with VCT items and PMT items. + */ public TunerChannel(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) { this(channel, 0, pmtItems, Channel.TYPE_TUNER); } + /** + * Initialize tuner channel with program number and PMT items. + */ public TunerChannel(int programNumber, List<PsiData.PmtItem> pmtItems) { this(null, programNumber, pmtItems, Channel.TYPE_TUNER); } + /** + * Initialize tuner channel with SDT items and PMT items. + */ + public TunerChannel(PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) { + this(0, Channel.TYPE_TUNER, channel, pmtItems); + } + private TunerChannel(TunerChannelProto tunerChannelProto) { mProto = tunerChannelProto; } @@ -145,6 +185,50 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return new TunerChannel(channel, 0, pmtItems, Channel.TYPE_FILE); } + public static TunerChannel forDvbFile( + PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) { + return new TunerChannel(0, Channel.TYPE_FILE, channel, pmtItems); + } + + /** + * Create a TunerChannel object suitable for network tuners + * @param major Channel number major + * @param minor Channel number minor + * @param programNumber Program number + * @param shortName Short name + * @param recordingProhibited Recording prohibition info + * @param videoFormat Video format. Should be {@code null} or one of the followings: + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_240P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_360P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_480I}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_480P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_576I}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_576P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_720P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_1080I}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_1080P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_2160P}, + * {@link android.media.tv.TvContract.Channels#VIDEO_FORMAT_4320P} + * @return a TunerChannel object + */ + public static TunerChannel forNetwork(int major, int minor, int programNumber, + String shortName, boolean recordingProhibited, String videoFormat) { + TunerChannel tunerChannel = new TunerChannel( + null, programNumber, Collections.EMPTY_LIST, Channel.TYPE_NETWORK); + tunerChannel.setVirtualMajor(major); + tunerChannel.setVirtualMinor(minor); + tunerChannel.setShortName(shortName); + // Set audio and video pids in order to work around the audio-only channel check. + tunerChannel.setAudioPids(new ArrayList<>(Arrays.asList(0))); + tunerChannel.selectAudioTrack(0); + tunerChannel.setVideoPid(0); + tunerChannel.setRecordingProhibited(recordingProhibited); + if (videoFormat != null) { + tunerChannel.setVideoFormat(videoFormat); + } + return tunerChannel; + } + public String getName() { return (!mProto.shortName.isEmpty()) ? mProto.shortName : mProto.longName; } @@ -193,7 +277,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return mProto.videoPid; } - public void setVideoPid(int videoPid) { + synchronized public void setVideoPid(int videoPid) { mProto.videoPid = videoPid; } @@ -219,7 +303,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return Ints.asList(mProto.audioPids); } - public void setAudioPids(List<Integer> audioPids) { + synchronized public void setAudioPids(List<Integer> audioPids) { mProto.audioPids = Ints.toArray(audioPids); } @@ -227,7 +311,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return Ints.asList(mProto.audioStreamTypes); } - public void setAudioStreamTypes(List<Integer> audioStreamTypes) { + synchronized public void setAudioStreamTypes(List<Integer> audioStreamTypes) { mProto.audioStreamTypes = Ints.toArray(audioStreamTypes); } @@ -239,32 +323,32 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return mProto.type; } - public void setFilepath(String filepath) { - mProto.filepath = filepath; + synchronized public void setFilepath(String filepath) { + mProto.filepath = filepath == null ? "" : filepath; } public String getFilepath() { return mProto.filepath; } - public void setVirtualMajor(int virtualMajor) { + synchronized public void setVirtualMajor(int virtualMajor) { mProto.virtualMajor = virtualMajor; } - public void setVirtualMinor(int virtualMinor) { + synchronized public void setVirtualMinor(int virtualMinor) { mProto.virtualMinor = virtualMinor; } - public void setShortName(String shortName) { - mProto.shortName = shortName; + synchronized public void setShortName(String shortName) { + mProto.shortName = shortName == null ? "" : shortName; } - public void setFrequency(int frequency) { + synchronized public void setFrequency(int frequency) { mProto.frequency = frequency; } - public void setModulation(String modulation) { - mProto.modulation = modulation; + synchronized public void setModulation(String modulation) { + mProto.modulation = modulation == null ? "" : modulation; } public boolean hasVideo() { @@ -279,13 +363,18 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return mProto.channelId; } - public void setChannelId(long channelId) { + synchronized 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); + return getDisplayNumber(true); + } + + public String getDisplayNumber(boolean ignoreZeroMinorNumber) { + if (mProto.virtualMajor != 0 && (mProto.virtualMinor != 0 || !ignoreZeroMinorNumber)) { + return String.format("%d%c%d", mProto.virtualMajor, CHANNEL_NUMBER_SEPARATOR, + mProto.virtualMinor); } else if (mProto.virtualMajor != 0) { return Integer.toString(mProto.virtualMajor); } else { @@ -298,7 +387,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks } @Override - public void setHasCaptionTrack() { + synchronized public void setHasCaptionTrack() { mProto.hasCaptionTrack = true; } @@ -312,7 +401,7 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return Collections.unmodifiableList(Arrays.asList(mProto.audioTracks)); } - public void setAudioTracks(List<AtscAudioTrack> audioTracks) { + synchronized public void setAudioTracks(List<AtscAudioTrack> audioTracks) { mProto.audioTracks = audioTracks.toArray(new AtscAudioTrack[audioTracks.size()]); } @@ -321,11 +410,11 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks return Collections.unmodifiableList(Arrays.asList(mProto.captionTracks)); } - public void setCaptionTracks(List<AtscCaptionTrack> captionTracks) { + synchronized public void setCaptionTracks(List<AtscCaptionTrack> captionTracks) { mProto.captionTracks = captionTracks.toArray(new AtscCaptionTrack[captionTracks.size()]); } - public void selectAudioTrack(int index) { + synchronized public void selectAudioTrack(int index) { if (0 <= index && index < mProto.audioPids.length) { mProto.audioTrackIndex = index; } else { @@ -333,6 +422,22 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks } } + synchronized public void setRecordingProhibited(boolean recordingProhibited) { + mProto.recordingProhibited = recordingProhibited; + } + + public boolean isRecordingProhibited() { + return mProto.recordingProhibited; + } + + synchronized public void setVideoFormat(String videoFormat) { + mProto.videoFormat = videoFormat == null ? "" : videoFormat; + } + + public String getVideoFormat() { + return mProto.videoFormat; + } + @Override public String toString() { switch (mProto.type) { @@ -359,7 +464,10 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks if (ret != 0) { return ret; } - + ret = StringUtils.compare(getName(), channel.getName()); + if (ret != 0) { + return ret; + } // For FileTsStreamer, file paths should be compared. return StringUtils.compare(getFilepath(), channel.getFilepath()); } @@ -374,12 +482,19 @@ public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracks @Override public int hashCode() { - return Objects.hash(getFrequency(), getProgramNumber(), getFilepath()); + return Objects.hash(getFrequency(), getProgramNumber(), getName(), getFilepath()); } // Serialization - public byte[] toByteArray() { - return MessageNano.toByteArray(mProto); + synchronized public byte[] toByteArray() { + try { + return MessageNano.toByteArray(mProto); + } catch (Exception e) { + // Retry toByteArray. b/34197766 + Log.w(TAG, "TunerChannel or its variables are modified in multiple thread without lock", + e); + return MessageNano.toByteArray(mProto); + } } public static TunerChannel parseFrom(byte[] data) { diff --git a/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java index 5e839223..5f536708 100644 --- a/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java +++ b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java @@ -40,6 +40,7 @@ public class Cea708TextTrackRenderer extends TrackRenderer implements private static final boolean DEBUG = false; public static final int MSG_SERVICE_NUMBER = 1; + public static final int MSG_ENABLE_CLOSED_CAPTION = 2; // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps. private static final int DEFAULT_INPUT_BUFFER_SIZE = 9600 / 8; @@ -52,11 +53,13 @@ public class Cea708TextTrackRenderer extends TrackRenderer implements private long mCurrentPositionUs; private long mPresentationTimeUs; private int mTrackIndex; + private boolean mRenderingDisabled; private Cea708Parser mCea708Parser; private CcListener mCcListener; public interface CcListener { void emitEvent(CaptionEvent captionEvent); + void clearCaption(); void discoverServiceNumber(int serviceNumber); } @@ -204,7 +207,7 @@ public class Cea708TextTrackRenderer extends TrackRenderer implements } case SampleSource.SAMPLE_READ: { mSampleHolder.data.flip(); - if (mCea708Parser != null) { + if (mCea708Parser != null && !mRenderingDisabled) { mCea708Parser.parseClosedCaption(mSampleHolder.data, mSampleHolder.timeUs); } return true; @@ -274,10 +277,26 @@ public class Cea708TextTrackRenderer extends TrackRenderer implements @Override public void handleMessage(int messageType, Object message) throws ExoPlaybackException { - if (messageType == MSG_SERVICE_NUMBER) { - setServiceNumber((int) message); - } else { - super.handleMessage(messageType, message); + switch (messageType) { + case MSG_SERVICE_NUMBER: + setServiceNumber((int) message); + break; + case MSG_ENABLE_CLOSED_CAPTION: + boolean renderingDisabled = (Boolean) message == false; + if (mRenderingDisabled != renderingDisabled) { + mRenderingDisabled = renderingDisabled; + if (mRenderingDisabled) { + if (mCea708Parser != null) { + mCea708Parser.clear(); + } + if (mCcListener != null) { + mCcListener.clearCaption(); + } + } + } + break; + default: + super.handleMessage(messageType, message); } } } diff --git a/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java new file mode 100644 index 00000000..0ab6d8c4 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ExoPlayerExtractorsFactory.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2017 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.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorsFactory; +import com.google.android.exoplayer2.extractor.TimestampAdjuster; +import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import com.google.android.exoplayer2.extractor.ts.TsExtractor; + +import java.util.ArrayList; +import java.util.List; + +/** + * Extractor factory, mainly aim at create TsExtractor with FLAG_ALLOW_NON_IDR_KEYFRAMES flags for + * H.264 stream + */ +public final class ExoPlayerExtractorsFactory implements ExtractorsFactory { + @Override + public Extractor[] createExtractors() { + // Only create TsExtractor since we only target MPEG2TS stream. + Extractor[] extractors = { + new TsExtractor(new TimestampAdjuster(0), new DefaultTsPayloadReaderFactory( + DefaultTsPayloadReaderFactory.FLAG_ALLOW_NON_IDR_KEYFRAMES), false) }; + return extractors; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java index c105e222..0b648400 100644 --- a/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java +++ b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java @@ -23,17 +23,28 @@ import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.os.SystemClock; +import android.util.Pair; -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.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.FormatHolder; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.ExtractorMediaSource.EventListener; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.android.tv.tuner.exoplayer.audio.MpegTsDefaultAudioTrackRenderer; import com.android.tv.tuner.exoplayer.buffer.BufferManager; import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer; import com.android.tv.tuner.exoplayer.buffer.SimpleSampleBuffer; @@ -42,10 +53,11 @@ import com.android.tv.tuner.tvinput.PlaybackBufferListener; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Locale; +import java.util.Map; 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. @@ -54,11 +66,7 @@ import java.util.concurrent.atomic.AtomicLong; 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 static final int INVALID_TRACK_INDEX = -1; private final HandlerThread mSourceReaderThread; private final long mId; @@ -70,36 +78,69 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { private AtomicBoolean mOnCompletionCalled = new AtomicBoolean(); private IOException mExceptionOnPrepare; private List<MediaFormat> mTrackFormats; + private int mVideoTrackIndex = INVALID_TRACK_INDEX; + private boolean mVideoTrackMet; + private long mBaseSamplePts = Long.MIN_VALUE; private HashMap<Integer, Long> mLastExtractedPositionUsMap = new HashMap<>(); + private final List<Pair<Integer, SampleHolder>> mPendingSamples = new LinkedList<>(); private OnCompletionListener mOnCompletionListener; private Handler mOnCompletionListenerHandler; private IOException mError; - public ExoPlayerSampleExtractor(Uri uri, DataSource source, BufferManager bufferManager, + public ExoPlayerSampleExtractor(Uri uri, final 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; + public void onLoadError(IOException error) { + mError = error; } }; mSourceReaderThread = new HandlerThread("SourceReaderThread"); - mSourceReaderWorker = new SourceReaderWorker(new ExtractorSampleSource(uri, source, - allocator, BUFFER_SEGMENT_COUNT * BUFFER_SEGMENT_SIZE_IN_BYTES, + mSourceReaderWorker = new SourceReaderWorker(new ExtractorMediaSource(uri, + new com.google.android.exoplayer2.upstream.DataSource.Factory() { + @Override + public com.google.android.exoplayer2.upstream.DataSource createDataSource() { + // Returns an adapter implementation for ExoPlayer V2 DataSource interface. + return new com.google.android.exoplayer2.upstream.DataSource() { + @Override + public long open(DataSpec dataSpec) throws IOException { + return source.open( + new com.google.android.exoplayer.upstream.DataSpec( + dataSpec.uri, dataSpec.postBody, + dataSpec.absoluteStreamPosition, dataSpec.position, + dataSpec.length, dataSpec.key, dataSpec.flags)); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) + throws IOException { + return source.read(buffer, offset, readLength); + } + + @Override + public Uri getUri() { + return null; + } + + @Override + public void close() throws IOException { + source.close(); + } + }; + } + }, + new ExoPlayerExtractorsFactory(), // Do not create a handler if we not on a looper. e.g. test. - Looper.myLooper() != null ? new Handler() : null, - eventListener, 0)); + Looper.myLooper() != null ? new Handler() : null, eventListener)); if (isRecording) { mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, false, RecordingSampleBuffer.BUFFER_REASON_RECORDING); } else { - if (bufferManager == null || bufferManager.isDisabled()) { + if (bufferManager == null) { mSampleBuffer = new SimpleSampleBuffer(bufferListener); } else { mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, true, @@ -114,43 +155,141 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { mOnCompletionListenerHandler = handler; } - private class SourceReaderWorker implements Handler.Callback { + private class SourceReaderWorker implements Handler.Callback, MediaPeriod.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 final MediaSource mSampleSource; + private MediaPeriod mMediaPeriod; + private SampleStream[] mStreams; private boolean[] mTrackMetEos; private boolean mMetEos = false; private long mCurrentPosition; + private DecoderInputBuffer mDecoderInputBuffer; + private SampleHolder mSampleHolder; + private boolean mPrepareRequested; - public SourceReaderWorker(SampleSource sampleSource) { + public SourceReaderWorker(MediaSource sampleSource) { mSampleSource = sampleSource; + mSampleSource.prepareSource(null, false, new MediaSource.Listener() { + @Override + public void onSourceInfoRefreshed(Timeline timeline, Object manifest) { + // Dynamic stream change is not supported yet. b/28169263 + // For now, this will cause EOS and playback reset. + } + }); + mDecoderInputBuffer = new DecoderInputBuffer( + DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + } + + MediaFormat convertFormat(Format format) { + if (format.sampleMimeType.startsWith("audio/")) { + return MediaFormat.createAudioFormat(format.id, format.sampleMimeType, + format.bitrate, format.maxInputSize, + com.google.android.exoplayer.C.UNKNOWN_TIME_US, format.channelCount, + format.sampleRate, format.initializationData, format.language, + format.pcmEncoding); + } else if (format.sampleMimeType.startsWith("video/")) { + return MediaFormat.createVideoFormat( + format.id, format.sampleMimeType, format.bitrate, format.maxInputSize, + com.google.android.exoplayer.C.UNKNOWN_TIME_US, format.width, format.height, + format.initializationData, format.rotationDegrees, + format.pixelWidthHeightRatio, format.projectionData, format.stereoMode); + } else if (format.sampleMimeType.endsWith("/cea-608") + || format.sampleMimeType.startsWith("text/")) { + return MediaFormat.createTextFormat( + format.id, format.sampleMimeType, format.bitrate, + com.google.android.exoplayer.C.UNKNOWN_TIME_US, format.language); + } else { + return MediaFormat.createFormatForMimeType( + format.id, format.sampleMimeType, format.bitrate, + com.google.android.exoplayer.C.UNKNOWN_TIME_US); + } + } + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + if (mMediaPeriod == null) { + // This instance is already released while the extractor is preparing. + return; + } + TrackSelection.Factory selectionFactory = new FixedTrackSelection.Factory(); + TrackGroupArray trackGroupArray = mMediaPeriod.getTrackGroups(); + TrackSelection[] selections = new TrackSelection[trackGroupArray.length]; + for (int i = 0; i < selections.length; ++i) { + selections[i] = selectionFactory.createTrackSelection(trackGroupArray.get(i), 0); + } + boolean retain[] = new boolean[trackGroupArray.length]; + boolean reset[] = new boolean[trackGroupArray.length]; + mStreams = new SampleStream[trackGroupArray.length]; + mMediaPeriod.selectTracks(selections, retain, mStreams, reset, 0); + if (mTrackFormats == null) { + int trackCount = trackGroupArray.length; + mTrackMetEos = new boolean[trackCount]; + List<MediaFormat> trackFormats = new ArrayList<>(); + int videoTrackCount = 0; + for (int i = 0; i < trackCount; i++) { + Format format = trackGroupArray.get(i).getFormat(0); + if (format.sampleMimeType.startsWith("video/")) { + videoTrackCount++; + mVideoTrackIndex = i; + } + trackFormats.add(convertFormat(format)); + } + if (videoTrackCount > 1) { + // Disable dropping samples when there are multiple video tracks. + mVideoTrackIndex = INVALID_TRACK_INDEX; + } + 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; + } + mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES); + mPrepared = true; + } + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + source.continueLoading(mCurrentPosition); } @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); + if (!mPrepareRequested) { + mPrepareRequested = true; + mMediaPeriod = mSampleSource.createPeriod(0, + new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), 0); + mMediaPeriod.prepare(this); + try { + mMediaPeriod.maybeThrowPrepareError(); + } catch (IOException e) { + mError = e; + } } 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(); + int trackCount = mStreams.length; for (int i = 0; i < trackCount; ++i) { - if (!mTrackMetEos[i] && SampleSource.NOTHING_READ - != fetchSample(i, sample, conditionVariable)) { + if (!mTrackMetEos[i] && C.RESULT_NOTHING_READ + != fetchSample(i, mSampleHolder, conditionVariable)) { if (mMetEos) { // If mMetEos was on during fetchSample() due to an error, // fetching from other tracks is not necessary. @@ -159,6 +298,7 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { didSomething = true; } } + mMediaPeriod.continueLoading(mCurrentPosition); if (!mMetEos) { if (didSomething) { mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES); @@ -171,17 +311,10 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { } 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; + if (mMediaPeriod != null) { + mSampleSource.releasePeriod(mMediaPeriod); + mSampleSource.releaseSource(); + mMediaPeriod = null; } cleanUp(); mSourceReaderHandler.removeCallbacksAndMessages(null); @@ -190,91 +323,110 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { 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; + FormatHolder dummyFormatHolder = new FormatHolder(); + mDecoderInputBuffer.clear(); + int ret = mStreams[track].readData(dummyFormatHolder, mDecoderInputBuffer); + if (ret == C.RESULT_BUFFER_READ + // Double-check if the extractor provided the data to prevent NPE. b/33758354 + && mDecoderInputBuffer.data != null) { + if (mCurrentPosition < mDecoderInputBuffer.timeUs) { + mCurrentPosition = mDecoderInputBuffer.timeUs; } try { Long lastExtractedPositionUs = mLastExtractedPositionUsMap.get(track); if (lastExtractedPositionUs == null) { - mLastExtractedPositionUsMap.put(track, sample.timeUs); + mLastExtractedPositionUsMap.put(track, mDecoderInputBuffer.timeUs); } else { mLastExtractedPositionUsMap.put(track, - Math.max(lastExtractedPositionUs, sample.timeUs)); + Math.max(lastExtractedPositionUs, mDecoderInputBuffer.timeUs)); } - queueSample(track, sample, conditionVariable); + queueSample(track, conditionVariable); } catch (IOException e) { mLastExtractedPositionUsMap.clear(); mMetEos = true; mSampleBuffer.setEos(); } - } else if (ret == SampleSource.END_OF_STREAM) { + } else if (ret == C.RESULT_END_OF_INPUT) { mTrackMetEos[track] = true; for (int i = 0; i < mTrackMetEos.length; ++i) { if (!mTrackMetEos[i]) { break; } - if (i == mTrackMetEos.length -1) { + if (i == mTrackMetEos.length - 1) { mMetEos = true; mSampleBuffer.setEos(); } } } - // TODO: Handle SampleSource.FORMAT_READ for dynamic resolution change. b/28169263 + // TODO: Handle C.RESULT_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(); + private void queueSample(int index, ConditionVariable conditionVariable) + throws IOException { + if (mVideoTrackIndex != INVALID_TRACK_INDEX) { + if (!mVideoTrackMet) { + if (index != mVideoTrackIndex) { + SampleHolder sample = + new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL); + mSampleHolder.flags = + (mDecoderInputBuffer.isKeyFrame() + ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC : 0) + | (mDecoderInputBuffer.isDecodeOnly() + ? com.google.android.exoplayer.C.SAMPLE_FLAG_DECODE_ONLY + : 0); + sample.timeUs = mDecoderInputBuffer.timeUs; + sample.size = mDecoderInputBuffer.data.position(); + sample.ensureSpaceForWrite(sample.size); + mDecoderInputBuffer.flip(); + sample.data.position(0); + sample.data.put(mDecoderInputBuffer.data); + sample.data.flip(); + mPendingSamples.add(new Pair<>(index, sample)); + return; + } + mVideoTrackMet = true; + mBaseSamplePts = + mDecoderInputBuffer.timeUs + - MpegTsDefaultAudioTrackRenderer + .INITIAL_AUDIO_BUFFERING_TIME_US; + for (Pair<Integer, SampleHolder> pair : mPendingSamples) { + if (pair.second.timeUs >= mBaseSamplePts) { + mSampleBuffer.writeSample(pair.first, pair.second, conditionVariable); + } + } + mPendingSamples.clear(); + } else { + if (mDecoderInputBuffer.timeUs < mBaseSamplePts + && mVideoTrackIndex != index) { + return; + } + } + } + // Copy the decoder input to the sample holder. + mSampleHolder.clearData(); + mSampleHolder.flags = + (mDecoderInputBuffer.isKeyFrame() + ? com.google.android.exoplayer.C.SAMPLE_FLAG_SYNC : 0) + | (mDecoderInputBuffer.isDecodeOnly() + ? com.google.android.exoplayer.C.SAMPLE_FLAG_DECODE_ONLY : 0); + mSampleHolder.timeUs = mDecoderInputBuffer.timeUs; + mSampleHolder.size = mDecoderInputBuffer.data.position(); + mSampleHolder.ensureSpaceForWrite(mSampleHolder.size); + mDecoderInputBuffer.flip(); + mSampleHolder.data.position(0); + mSampleHolder.data.put(mDecoderInputBuffer.data); + mSampleHolder.data.flip(); + long writeStartTimeNs = SystemClock.elapsedRealtimeNanos(); + mSampleBuffer.writeSample(index, mSampleHolder, conditionVariable); + + // Checks whether the storage has enough bandwidth for recording samples. + if (mSampleBuffer.isWriteSpeedSlow(mSampleHolder.size, + SystemClock.elapsedRealtimeNanos() - writeStartTimeNs)) { + mSampleBuffer.handleWriteSpeedSlow(); + } } } @@ -328,7 +480,7 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { } @Override - public boolean continueBuffering(long positionUs) { + public boolean continueBuffering(long positionUs) { return mSampleBuffer.continueBuffering(positionUs); } @@ -386,12 +538,14 @@ public class ExoPlayerSampleExtractor implements SampleExtractor { } private long getLastExtractedPositionUs() { - long lastExtractedPositionUs = Long.MAX_VALUE; - for (long value : mLastExtractedPositionUsMap.values()) { - lastExtractedPositionUs = Math.min(lastExtractedPositionUs, value); + long lastExtractedPositionUs = Long.MIN_VALUE; + for (Map.Entry<Integer, Long> entry : mLastExtractedPositionUsMap.entrySet()) { + if (mVideoTrackIndex != entry.getKey()) { + lastExtractedPositionUs = Math.max(lastExtractedPositionUs, entry.getValue()); + } } - if (lastExtractedPositionUs == Long.MAX_VALUE) { - lastExtractedPositionUs = C.UNKNOWN_TIME_US; + if (lastExtractedPositionUs == Long.MIN_VALUE) { + lastExtractedPositionUs = com.google.android.exoplayer.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 index ec7b4b16..b7e42a7c 100644 --- a/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java +++ b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java @@ -25,7 +25,6 @@ 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; @@ -61,18 +60,17 @@ public class FileSampleExtractor implements SampleExtractor{ @Override public boolean prepare() throws IOException { - ArrayList<Pair<String, android.media.MediaFormat>> trackInfos = - mBufferManager.readTrackInfoFiles(); - if (trackInfos == null || trackInfos.isEmpty()) { + List<BufferManager.TrackFormat> trackFormatList = mBufferManager.readTrackInfoFiles(); + if (trackFormatList == null || trackFormatList.isEmpty()) { throw new IOException("Cannot find meta files for the recording."); } - mTrackCount = trackInfos.size(); + mTrackCount = trackFormatList.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)); + BufferManager.TrackFormat trackFormat = trackFormatList.get(i); + ids.add(trackFormat.trackId); + mTrackFormats.add(MediaFormatUtil.createMediaFormat(trackFormat.format)); } mSampleBuffer = new RecordingSampleBuffer(mBufferManager, mBufferListener, true, RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK); diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java index 381b22e9..2694298a 100644 --- a/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java +++ b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java @@ -39,20 +39,22 @@ 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.exoplayer.audio.MpegTsDefaultAudioTrackRenderer; +import com.android.tv.tuner.exoplayer.audio.MpegTsMediaCodecAudioTrackRenderer; import com.android.tv.tuner.source.TsDataSource; import com.android.tv.tuner.source.TsDataSourceManager; import com.android.tv.tuner.tvinput.EventDetector; +import com.android.tv.tuner.tvinput.TunerDebug; 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 { +/** MPEG-2 TS stream player implementation using ExoPlayer. */ +public class MpegTsPlayer + implements ExoPlayer.Listener, + MediaCodecVideoTrackRenderer.EventListener, + MpegTsDefaultAudioTrackRenderer.EventListener, + MpegTsMediaCodecAudioTrackRenderer.Ac3EventListener { private int mCaptionServiceNumber = Cea708Data.EMPTY_SERVICE_NUMBER; /** @@ -60,7 +62,7 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen */ public interface RendererBuilder { void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource, - RendererBuilderCallback callback); + boolean hasSoftwareAudioDecoder, RendererBuilderCallback callback); } /** @@ -94,6 +96,11 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen void onEmitCaptionEvent(CaptionEvent event); /** + * Notifies clearing up whole closed caption event. + */ + void onClearCaptionEvent(); + + /** * Notifies the discovered caption service number. */ void onDiscoverCaptionServiceNumber(int serviceNumber); @@ -215,10 +222,11 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen * Creates renderers and {@link DataSource} and initializes player. * @param context a {@link Context} instance * @param channel to play + * @param hasSoftwareAudioDecoder {@code true} if there is connected software decoder * @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, + public boolean prepare(Context context, TunerChannel channel, boolean hasSoftwareAudioDecoder, EventDetector.EventListener eventListener) { TsDataSource source = null; if (channel != null) { @@ -236,7 +244,7 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen } mRendererBuildingState = RENDERER_BUILDING_STATE_BUILDING; mBuilderCallback = new InternalRendererBuilderCallback(); - mRendererBuilder.buildRenderers(this, source, mBuilderCallback); + mRendererBuilder.buildRenderers(this, source, hasSoftwareAudioDecoder, mBuilderCallback); return true; } @@ -304,8 +312,10 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen SoftPreconditions.checkState(supportSmoothTrickPlay(playbackParams.getSpeed())); mPlayer.setPlayWhenReady(true); mTrickplayRunning = true; - if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { - mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED, + if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) { + mPlayer.sendMessage( + mAudioRenderer, + MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED, playbackParams.getSpeed()); } else { mPlayer.sendMessage(mAudioRenderer, @@ -317,9 +327,9 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen private void stopSmoothTrickplay(boolean calledBySeek) { if (mTrickplayRunning) { mTrickplayRunning = false; - if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { - mPlayer.sendMessage(mAudioRenderer, - Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED, + if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) { + mPlayer.sendMessage( + mAudioRenderer, MpegTsDefaultAudioTrackRenderer.MSG_SET_PLAYBACK_SPEED, 1.0f); } else { mPlayer.sendMessage(mAudioRenderer, @@ -423,8 +433,9 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen */ public void setVolume(float volume) { mVolume = volume; - if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { - mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_VOLUME, volume); + if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, MpegTsDefaultAudioTrackRenderer.MSG_SET_VOLUME, + volume); } else { mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, volume); @@ -432,18 +443,20 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen } /** - * Enables or disables audio. + * Enables or disables audio and closed caption. * - * @param enable enables the audio when {@code true}, disables otherwise. + * @param enable enables the audio and closed caption when {@code true}, disables otherwise. */ - public void setAudioTrack(boolean enable) { - if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) { - mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_AUDIO_TRACK, + public void setAudioTrackAndClosedCaption(boolean enable) { + if (mAudioRenderer instanceof MpegTsDefaultAudioTrackRenderer) { + mPlayer.sendMessage(mAudioRenderer, MpegTsDefaultAudioTrackRenderer.MSG_SET_AUDIO_TRACK, enable ? 1 : 0); } else { mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME, enable ? mVolume : 0.0f); } + mPlayer.sendMessage(mTextRenderer, Cea708TextTrackRenderer.MSG_ENABLE_CLOSED_CAPTION, + enable); } /** @@ -495,6 +508,28 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen } /** + * Returns the index of the currently selected track for the specified renderer. + * + * @param rendererIndex The index of the renderer. + * @return The selected track. A negative value or a value greater than or equal to the renderer's + * track count indicates that the renderer is disabled. + */ + public int getSelectedTrack(int rendererIndex) { + return mPlayer.getSelectedTrack(rendererIndex); + } + + /** + * Returns the format of a track. + * + * @param rendererIndex The index of the renderer. + * @param trackIndex The index of the track. + * @return The format of the track. + */ + public MediaFormat getTrackFormat(int rendererIndex, int trackIndex) { + return mPlayer.getTrackFormat(rendererIndex, trackIndex); + } + + /** * Gets the main handler of the player. */ /* package */ Handler getMainHandler() { @@ -579,6 +614,7 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen @Override public void onDroppedFrames(int count, long elapsed) { + TunerDebug.notifyVideoFrameDrop(count, elapsed); if (mTrickplayRunning && mListener != null) { mListener.onSmoothTrickplayForceStopped(); } @@ -622,6 +658,13 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen } @Override + public void clearCaption() { + if (mVideoEventListener != null) { + mVideoEventListener.onClearCaptionEvent(); + } + } + + @Override public void discoverServiceNumber(int serviceNumber) { if (mVideoEventListener != null) { mVideoEventListener.onDiscoverCaptionServiceNumber(serviceNumber); @@ -650,4 +693,4 @@ public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRen } } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java index 0e46c9cf..006ccac2 100644 --- a/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java +++ b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java @@ -18,12 +18,14 @@ package com.android.tv.tuner.exoplayer; import android.content.Context; +import com.google.android.exoplayer.MediaCodecSelector; import com.google.android.exoplayer.SampleSource; import com.google.android.exoplayer.TrackRenderer; import com.google.android.exoplayer.upstream.DataSource; +import com.android.tv.Features; 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.audio.MpegTsDefaultAudioTrackRenderer; import com.android.tv.tuner.exoplayer.buffer.BufferManager; import com.android.tv.tuner.tvinput.PlaybackBufferListener; @@ -44,7 +46,7 @@ public class MpegTsRendererBuilder implements RendererBuilder { @Override public void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource, - RendererBuilderCallback callback) { + boolean mHasSoftwareAudioDecoder, RendererBuilderCallback callback) { // Build the video and audio renderers. SampleExtractor extractor = dataSource == null ? new MpegTsSampleExtractor(mBufferManager, mBufferListener) : @@ -52,10 +54,16 @@ public class MpegTsRendererBuilder implements RendererBuilder { 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); + // TODO: Only using MpegTsDefaultAudioTrackRenderer for A/V sync issue. We will use + // {@link MpegTsMediaCodecAudioTrackRenderer} when we use ExoPlayer's extractor. + TrackRenderer audioRenderer = + new MpegTsDefaultAudioTrackRenderer( + sampleSource, + MediaCodecSelector.DEFAULT, + mpegTsPlayer.getMainHandler(), + mpegTsPlayer, + mHasSoftwareAudioDecoder, + !Features.AC3_SOFTWARE_DECODE.isEnabled(mContext)); Cea708TextTrackRenderer textRenderer = new Cea708TextTrackRenderer(sampleSource); TrackRenderer[] renderers = new TrackRenderer[MpegTsPlayer.RENDERER_COUNT]; diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java b/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java index 600c2c88..5666c5b9 100644 --- a/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java +++ b/src/com/android/tv/tuner/exoplayer/audio/AudioClock.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.tuner.exoplayer.ac3; +package com.android.tv.tuner.exoplayer.audio; import com.android.tv.common.SoftPreconditions; diff --git a/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java b/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java new file mode 100644 index 00000000..e581092a --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/audio/AudioDecoder.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2017 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.audio; + +import com.google.android.exoplayer.ExoPlaybackException; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.SampleHolder; + +import java.nio.ByteBuffer; + +/** A base class for audio decoders. */ +public abstract class AudioDecoder { + + /** + * Decodes an audio sample. + * + * @param sampleHolder a holder that contains the sample data and corresponding metadata + */ + public abstract void decode(SampleHolder sampleHolder); + + /** Returns a decoded sample from decoder. */ + public abstract ByteBuffer getDecodedSample(); + + /** Returns the presentation time for the decoded sample. */ + public abstract long getDecodedTimeUs(); + + /** + * Clear previous decode state if any. Prepares to decode samples of the specified encoding. + * This method should be called before using decode. + * + * @param mime audio encoding + */ + public abstract void resetDecoderState(String mimeType); + + /** Releases all the resource. */ + public abstract void release(); + + /** + * Init decoder if needed. + * + * @param format the format used to initialize decoder + */ + public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException { + // Do nothing. + } + + /** Returns input buffer that will be used in decoder. */ + public ByteBuffer getInputBuffer() { + return null; + } + + /** Returns the output format. */ + public android.media.MediaFormat getOutputFormat() { + return null; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java index bfdf08ac..ec616b13 100644 --- a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java +++ b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackMonitor.java @@ -14,12 +14,13 @@ * limitations under the License. */ -package com.android.tv.tuner.exoplayer.ac3; +package com.android.tv.tuner.exoplayer.audio; import android.os.SystemClock; import android.util.Log; import android.util.Pair; +import com.google.android.exoplayer.util.MimeTypes; import java.util.ArrayList; import java.util.HashSet; import java.util.Set; @@ -35,7 +36,7 @@ public class AudioTrackMonitor { 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 final Set<Integer> mHeader = new HashSet<>(); private long mExpireMs; private long mDuration; @@ -43,6 +44,8 @@ public class AudioTrackMonitor { private long mTotalCount; private long mStartMs; + private boolean mIsMp2; + private void flush() { mExpireMs += mDuration; mSampleCount = 0; @@ -61,10 +64,14 @@ public class AudioTrackMonitor { mTotalCount = 0; mStartMs = 0; mSampleSize.clear(); - mAc3Header.clear(); + mHeader.clear(); flush(); } + public void setEncoding(String mime) { + mIsMp2 = MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mime); + } + /** * Adds an audio sample information for monitoring. * @@ -76,7 +83,7 @@ public class AudioTrackMonitor { mTotalCount++; mSampleCount++; mSampleSize.add(sampleSize); - mAc3Header.add(header); + mHeader.add(header); mCurSampleSize.add(sampleSize); if (mTotalCount == 1) { mStartMs = SystemClock.elapsedRealtime(); @@ -98,8 +105,9 @@ public class AudioTrackMonitor { long now = SystemClock.elapsedRealtime(); if (mExpireMs != 0 && now >= mExpireMs) { if (DEBUG) { - long sampleDuration = (mTotalCount - 1) * - Ac3PassthroughTrackRenderer.AC3_SAMPLE_DURATION_US / 1000; + long unitDuration = mIsMp2 ? MpegTsDefaultAudioTrackRenderer.MP2_SAMPLE_DURATION_US + : MpegTsDefaultAudioTrackRenderer.AC3_SAMPLE_DURATION_US; + long sampleDuration = (mTotalCount - 1) * unitDuration / 1000; long totalDuration = now - mStartMs; StringBuilder ptsBuilder = new StringBuilder(); ptsBuilder.append("PTS received ").append(mSampleCount).append(", ") @@ -113,7 +121,7 @@ public class AudioTrackMonitor { } if (DEBUG || mCurSampleSize.size() > 1) { Log.d(TAG, "PTS received sample size: " - + String.valueOf(mSampleSize) + mCurSampleSize + mAc3Header); + + String.valueOf(mSampleSize) + mCurSampleSize + mHeader); } flush(); } diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java index bc3c5d00..953c9fc4 100644 --- a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java +++ b/src/com/android/tv/tuner/exoplayer/audio/AudioTrackWrapper.java @@ -14,10 +14,11 @@ * limitations under the License. */ -package com.android.tv.tuner.exoplayer.ac3; +package com.android.tv.tuner.exoplayer.audio; import android.media.MediaFormat; +import com.google.android.exoplayer.C; import com.google.android.exoplayer.audio.AudioTrack; import java.nio.ByteBuffer; @@ -28,6 +29,10 @@ import java.nio.ByteBuffer; * This wrapper class will do nothing in disabled status for those operations. */ public class AudioTrackWrapper { + private static final int PCM16_FRAME_BYTES = 2; + private static final int AC3_FRAMES_IN_ONE_SAMPLE = 1536; + private static final int BUFFERED_SAMPLES_IN_AUDIOTRACK = + MpegTsDefaultAudioTrackRenderer.BUFFERED_SAMPLES_IN_AUDIOTRACK; private final AudioTrack mAudioTrack = new AudioTrack(); private int mAudioSessionID; private boolean mIsEnabled; @@ -106,7 +111,7 @@ public class AudioTrackWrapper { mAudioTrack.setVolume(volume); } - public void reconfigure(MediaFormat format) { + public void reconfigure(MediaFormat format, int audioBufferSize) { if (!mIsEnabled || format == null) { return; } @@ -117,9 +122,9 @@ public class AudioTrackWrapper { try { pcmEncoding = format.getInteger(MediaFormat.KEY_PCM_ENCODING); } catch (Exception e) { - pcmEncoding = com.google.android.exoplayer.MediaFormat.NO_VALUE; + pcmEncoding = C.ENCODING_PCM_16BIT; } - // TODO: Handle non-AC3 or non-passthrough audio. + // TODO: Handle non-AC3. 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, @@ -127,7 +132,14 @@ public class AudioTrackWrapper { // In other words, the channel count should be always 2. channelCount = 2; } - mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding); + if (MediaFormat.MIMETYPE_AUDIO_RAW.equalsIgnoreCase(mimeType)) { + audioBufferSize = + channelCount + * PCM16_FRAME_BYTES + * AC3_FRAMES_IN_ONE_SAMPLE + * BUFFERED_SAMPLES_IN_AUDIOTRACK; + } + mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding, audioBufferSize); } public void handleDiscontinuity() { diff --git a/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java b/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java new file mode 100644 index 00000000..72bc68b6 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/audio/MediaCodecAudioDecoder.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2017 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.audio; + +import android.media.MediaCodec; +import android.util.Log; + +import com.google.android.exoplayer.CodecCounters; +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.MediaFormat; +import com.google.android.exoplayer.SampleHolder; + +import java.nio.ByteBuffer; +import java.util.ArrayList; + +/** A decoder to use MediaCodec for decoding audio stream. */ +public class MediaCodecAudioDecoder extends AudioDecoder { + private static final String TAG = "MediaCodecAudioDecoder"; + + public static final int INDEX_INVALID = -1; + + private final CodecCounters mCodecCounters; + private final MediaCodecSelector mSelector; + + private MediaCodec mCodec; + private MediaCodec.BufferInfo mOutputBufferInfo; + private ByteBuffer mMediaCodecOutputBuffer; + private ArrayList<Long> mDecodeOnlyPresentationTimestamps; + private boolean mWaitingForFirstSyncFrame; + private boolean mIsNewIndex; + private int mInputIndex; + private int mOutputIndex; + + /** Creates a MediaCodec based audio decoder. */ + public MediaCodecAudioDecoder(MediaCodecSelector selector) { + mSelector = selector; + mOutputBufferInfo = new MediaCodec.BufferInfo(); + mCodecCounters = new CodecCounters(); + mDecodeOnlyPresentationTimestamps = new ArrayList<>(); + } + + /** Returns {@code true} if there is decoder for {@code mimeType}. */ + public static boolean supportMimeType(MediaCodecSelector selector, String mimeType) { + if (selector == null) { + return false; + } + return getDecoderInfo(selector, mimeType) != null; + } + + private static DecoderInfo getDecoderInfo(MediaCodecSelector selector, String mimeType) { + try { + return selector.getDecoderInfo(mimeType, false); + } catch (MediaCodecUtil.DecoderQueryException e) { + Log.e(TAG, "Select decoder error:" + e); + return null; + } + } + + private boolean shouldInitCodec(MediaFormat format) { + return format != null && mCodec == null; + } + + @Override + public void maybeInitDecoder(MediaFormat format) throws ExoPlaybackException { + if (!shouldInitCodec(format)) { + return; + } + + String mimeType = format.mimeType; + DecoderInfo decoderInfo = getDecoderInfo(mSelector, mimeType); + if (decoderInfo == null) { + Log.i(TAG, "There is not decoder found for " + mimeType); + return; + } + + String codecName = decoderInfo.name; + try { + mCodec = MediaCodec.createByCodecName(codecName); + mCodec.configure(format.getFrameworkMediaFormatV16(), null, null, 0); + mCodec.start(); + } catch (Exception e) { + Log.e(TAG, "Failed when configure or start codec:" + e); + throw new ExoPlaybackException(e); + } + mInputIndex = INDEX_INVALID; + mOutputIndex = INDEX_INVALID; + mWaitingForFirstSyncFrame = true; + mCodecCounters.codecInitCount++; + } + + @Override + public void resetDecoderState(String mimeType) { + if (mCodec == null) { + return; + } + mInputIndex = INDEX_INVALID; + mOutputIndex = INDEX_INVALID; + mDecodeOnlyPresentationTimestamps.clear(); + mCodec.flush(); + mWaitingForFirstSyncFrame = true; + } + + @Override + public void release() { + if (mCodec != null) { + mDecodeOnlyPresentationTimestamps.clear(); + mInputIndex = INDEX_INVALID; + mOutputIndex = INDEX_INVALID; + mCodecCounters.codecReleaseCount++; + try { + mCodec.stop(); + } finally { + try { + mCodec.release(); + } finally { + mCodec = null; + } + } + } + } + + /** Returns the index of input buffer which is ready for using. */ + public int getInputIndex() { + return mInputIndex; + } + + @Override + public ByteBuffer getInputBuffer() { + if (mInputIndex < 0) { + mInputIndex = mCodec.dequeueInputBuffer(0); + if (mInputIndex < 0) { + return null; + } + return mCodec.getInputBuffer(mInputIndex); + } + return mCodec.getInputBuffer(mInputIndex); + } + + @Override + public void decode(SampleHolder sampleHolder) { + if (mWaitingForFirstSyncFrame) { + if (!sampleHolder.isSyncFrame()) { + sampleHolder.clearData(); + return; + } + mWaitingForFirstSyncFrame = false; + } + long presentationTimeUs = sampleHolder.timeUs; + if (sampleHolder.isDecodeOnly()) { + mDecodeOnlyPresentationTimestamps.add(presentationTimeUs); + } + mCodec.queueInputBuffer(mInputIndex, 0, sampleHolder.data.limit(), presentationTimeUs, 0); + mInputIndex = INDEX_INVALID; + mCodecCounters.inputBufferCount++; + } + + private int getDecodeOnlyIndex(long presentationTimeUs) { + final int size = mDecodeOnlyPresentationTimestamps.size(); + for (int i = 0; i < size; i++) { + if (mDecodeOnlyPresentationTimestamps.get(i).longValue() == presentationTimeUs) { + return i; + } + } + return INDEX_INVALID; + } + + /** Returns the index of output buffer which is ready for using. */ + public int getOutputIndex() { + if (mOutputIndex < 0) { + mOutputIndex = mCodec.dequeueOutputBuffer(mOutputBufferInfo, 0); + mIsNewIndex = true; + } else { + mIsNewIndex = false; + } + return mOutputIndex; + } + + @Override + public android.media.MediaFormat getOutputFormat() { + return mCodec.getOutputFormat(); + } + + /** Returns {@code true} if the output is only for decoding but not for rendering. */ + public boolean maybeDecodeOnlyIndex() { + int decodeOnlyIndex = getDecodeOnlyIndex(mOutputBufferInfo.presentationTimeUs); + if (decodeOnlyIndex != INDEX_INVALID) { + mCodec.releaseOutputBuffer(mOutputIndex, false); + mCodecCounters.skippedOutputBufferCount++; + mDecodeOnlyPresentationTimestamps.remove(decodeOnlyIndex); + mOutputIndex = INDEX_INVALID; + return true; + } + return false; + } + + @Override + public ByteBuffer getDecodedSample() { + if (maybeDecodeOnlyIndex() || mOutputIndex < 0) { + return null; + } + if (mIsNewIndex) { + mMediaCodecOutputBuffer = mCodec.getOutputBuffer(mOutputIndex); + } + return mMediaCodecOutputBuffer; + } + + @Override + public long getDecodedTimeUs() { + return mOutputBufferInfo.presentationTimeUs; + } + + /** Releases the output buffer after rendering. */ + public void releaseOutputBuffer() { + mCodecCounters.renderedOutputBufferCount++; + mCodec.releaseOutputBuffer(mOutputIndex, false); + mOutputIndex = INDEX_INVALID; + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java index 9dae2e34..77170419 100644 --- a/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java +++ b/src/com/android/tv/tuner/exoplayer/audio/MpegTsDefaultAudioTrackRenderer.java @@ -14,8 +14,10 @@ * limitations under the License. */ -package com.android.tv.tuner.exoplayer.ac3; +package com.android.tv.tuner.exoplayer.audio; +import android.media.MediaCodec; +import android.os.Build; import android.os.Handler; import android.os.SystemClock; import android.util.Log; @@ -23,16 +25,16 @@ 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.MediaCodecSelector; 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.exoplayer.ffmpeg.FfmpegDecoderClient; import com.android.tv.tuner.tvinput.TunerDebug; import java.io.IOException; @@ -40,9 +42,10 @@ import java.nio.ByteBuffer; import java.util.ArrayList; /** - * Decodes and renders AC3 audio. + * Decodes and renders DTV audio. Supports MediaCodec based decoding, passthrough playback and + * ffmpeg based software decoding (AC3, MP2). */ -public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaClock { +public class MpegTsDefaultAudioTrackRenderer 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; @@ -51,7 +54,19 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC // 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"; + // TODO: Check whether DVB broadcasting uses sample rate other than 48Khz. + // MPEG-1 audio Layer II and III has 1152 frames per sample. + // 1152 frames duration is 24ms when sample rate is 48Khz. + static final long MP2_SAMPLE_DURATION_US = 24000; + + // This is around 150ms, 150ms is big enough not to under-run AudioTrack, + // and 150ms is also small enough to fill the buffer rapidly. + static int BUFFERED_SAMPLES_IN_AUDIOTRACK = 5; + public static final long INITIAL_AUDIO_BUFFERING_TIME_US = + BUFFERED_SAMPLES_IN_AUDIOTRACK * AC3_SAMPLE_DURATION_US; + + + private static final String TAG = "MpegTsDefaultAudioTrac"; private static final boolean DEBUG = false; /** @@ -67,6 +82,8 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC 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; + private static final int MP2_HEADER_BITRATE_OFFSET = 2; + private static final int MP2_HEADER_BITRATE_MASK = 0xfc; // Keep this as static in order to prevent new framework AudioTrack creation // while old AudioTrack is being released. @@ -83,17 +100,25 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC // PTS interpolated time should be delayed reasonably when AudioTrack is not used. private static final long ESTIMATED_TRACK_RENDERING_DELAY_US = 500000; + private final MediaCodecSelector mSelector; + 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 final boolean mAc3Passthrough; + private final boolean mSoftwareDecoderAvailable; private MediaFormat mFormat; + private SampleHolder mSampleHolder; + private String mDecodingMime; + private boolean mFormatConfigured; + private int mSampleSize; private final ByteBuffer mOutputBuffer; + private AudioDecoder mAudioDecoder; private boolean mOutputReady; private int mTrackIndex; private boolean mSourceStateReady; @@ -106,16 +131,23 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC private long mInterpolatedTimeUs; private long mPreviousPositionUs; private boolean mIsStopped; + private boolean mEnabled = true; + private boolean mIsMuted; private ArrayList<Integer> mTracksIndex; - - public Ac3PassthroughTrackRenderer(SampleSource source, Handler eventHandler, - EventListener listener) { + private boolean mUseFrameworkDecoder; + + public MpegTsDefaultAudioTrackRenderer( + SampleSource source, + MediaCodecSelector selector, + Handler eventHandler, + EventListener listener, + boolean hasSoftwareAudioDecoder, + boolean usePassthrough) { mSource = source.register(); + mSelector = selector; 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(); @@ -123,6 +155,8 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC mMonitor = new AudioTrackMonitor(); mAudioClock = new AudioClock(); mTracksIndex = new ArrayList<>(); + mAc3Passthrough = usePassthrough; + mSoftwareDecoderAvailable = hasSoftwareAudioDecoder && FfmpegDecoderClient.isAvailable(); } @Override @@ -130,8 +164,11 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC return this; } - private static boolean handlesMimeType(String mimeType) { - return mimeType.equals(MimeTypes.AUDIO_AC3) || mimeType.equals(MimeTypes.AUDIO_E_AC3); + private boolean handlesMimeType(String mimeType) { + return mimeType.equals(MimeTypes.AUDIO_AC3) + || mimeType.equals(MimeTypes.AUDIO_E_AC3) + || mimeType.equals(MimeTypes.AUDIO_MPEG_L2) + || MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType); } @Override @@ -141,7 +178,8 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC return false; } for (int i = 0; i < mSource.getTrackCount(); i++) { - if (handlesMimeType(mSource.getFormat(i).mimeType)) { + String mimeType = mSource.getFormat(i).mimeType; + if (MimeTypes.isAudio(mimeType) && handlesMimeType(mimeType)) { if (mTrackIndex < 0) { mTrackIndex = i; } @@ -174,7 +212,9 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC @Override protected void onDisabled() { - AUDIO_TRACK.resetSessionId(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + AUDIO_TRACK.resetSessionId(); + } clearDecodeState(); mFormat = null; mSource.disable(mTrackIndex); @@ -182,6 +222,7 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC @Override protected void onReleased() { + releaseDecoder(); AUDIO_TRACK.release(); mSource.release(); } @@ -213,9 +254,12 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC 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(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // resetSessionId() will create a new framework AudioTrack instead of reusing old one. + AUDIO_TRACK.resetSessionId(); + } seekToInternal(positionUs); + clearDecodeState(); } @Override @@ -274,7 +318,10 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC return; } - // Process only one sample at a time for doSomeWork() + if (mAudioDecoder != null) { + mAudioDecoder.maybeInitDecoder(mFormat); + } + // Process only one sample at a time for doSomeWork() when using FFmpeg decoder. if (processOutput()) { if (!mOutputReady) { while (feedInputBuffer()) { @@ -314,9 +361,18 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC private void clearDecodeState() { mOutputReady = false; + if (mAudioDecoder != null) { + mAudioDecoder.resetDecoderState(mDecodingMime); + } AUDIO_TRACK.reset(); } + private void releaseDecoder() { + if (mAudioDecoder != null) { + mAudioDecoder.release(); + } + } + private void readFormat() throws IOException, ExoPlaybackException { int result = mSource.readData(mTrackIndex, mCurrentPositionUs, mFormatHolder, mSampleHolder); @@ -325,14 +381,69 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC } } + private MediaFormat convertMediaFormatToRaw(MediaFormat format) { + return MediaFormat.createAudioFormat( + format.trackId, + MimeTypes.AUDIO_RAW, + format.bitrate, + format.maxInputSize, + format.durationUs, + format.channelCount, + format.sampleRate, + format.initializationData, + format.language); + } + private void onInputFormatChanged(MediaFormatHolder formatHolder) throws ExoPlaybackException { - mFormat = formatHolder.format; - if (DEBUG) { + String mimeType = formatHolder.format.mimeType; + mUseFrameworkDecoder = MediaCodecAudioDecoder.supportMimeType(mSelector, mimeType); + if (mUseFrameworkDecoder) { + mAudioDecoder = new MediaCodecAudioDecoder(mSelector); + mFormat = formatHolder.format; + mAudioDecoder.maybeInitDecoder(mFormat); + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DISABLED); + } else if (mSoftwareDecoderAvailable + && (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mimeType) + || MimeTypes.AUDIO_AC3.equalsIgnoreCase(mimeType) && !mAc3Passthrough)) { + releaseDecoder(); + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT); + mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE); + mAudioDecoder = FfmpegDecoderClient.getInstance(); + mDecodingMime = mimeType; + mFormat = convertMediaFormatToRaw(formatHolder.format); + } else { + mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT); + mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE); + mFormat = formatHolder.format; + releaseDecoder(); + } + mFormatConfigured = true; + mMonitor.setEncoding(mimeType); + if (DEBUG && !mUseFrameworkDecoder) { Log.d(TAG, "AudioTrack was configured to FORMAT: " + mFormat.toString()); } clearDecodeState(); - AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16()); + if (!mUseFrameworkDecoder) { + AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), 0); + } + } + + private void onSampleSizeChanged(int sampleSize) { + if (DEBUG) { + Log.d(TAG, "Sample size was changed to : " + sampleSize); + } + clearDecodeState(); + int audioBufferSize = sampleSize * BUFFERED_SAMPLES_IN_AUDIOTRACK; + mSampleSize = sampleSize; + AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16(), audioBufferSize); + } + + private void onOutputFormatChanged(android.media.MediaFormat format) { + if (DEBUG) { + Log.d(TAG, "AudioTrack was configured to FORMAT: " + format.toString()); + } + AUDIO_TRACK.reconfigure(format, 0); } private boolean feedInputBuffer() throws IOException, ExoPlaybackException { @@ -340,10 +451,24 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC return false; } - mSampleHolder.data.clear(); - mSampleHolder.size = 0; - int result = mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, - mSampleHolder); + if (mUseFrameworkDecoder) { + boolean indexChanged = + ((MediaCodecAudioDecoder) mAudioDecoder).getInputIndex() + == MediaCodecAudioDecoder.INDEX_INVALID; + if (indexChanged) { + mSampleHolder.data = mAudioDecoder.getInputBuffer(); + if (mSampleHolder.data != null) { + mSampleHolder.clearData(); + } else { + return false; + } + } + } else { + mSampleHolder.data.clear(); + mSampleHolder.size = 0; + } + int result = + mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder, mSampleHolder); switch (result) { case SampleSource.NOTHING_READ: { return false; @@ -359,8 +484,48 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC return false; } default: { + if (mSampleHolder.size != mSampleSize + && mFormatConfigured + && !mUseFrameworkDecoder) { + onSampleSizeChanged(mSampleHolder.size); + } mSampleHolder.data.flip(); - decodeDone(mSampleHolder.data, mSampleHolder.timeUs); + if (!mUseFrameworkDecoder) { + if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) { + mMonitor.addPts( + mSampleHolder.timeUs, + mOutputBuffer.position(), + mSampleHolder.data.get(MP2_HEADER_BITRATE_OFFSET) + & MP2_HEADER_BITRATE_MASK); + } else { + mMonitor.addPts( + mSampleHolder.timeUs, + mOutputBuffer.position(), + mSampleHolder.data.get(AC3_HEADER_BITRATE_OFFSET) & 0xff); + } + } + if (mAudioDecoder != null) { + mAudioDecoder.decode(mSampleHolder); + if (mUseFrameworkDecoder) { + int outputIndex = + ((MediaCodecAudioDecoder) mAudioDecoder).getOutputIndex(); + if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + onOutputFormatChanged(mAudioDecoder.getOutputFormat()); + return true; + } else if (outputIndex < 0) { + return true; + } + if (((MediaCodecAudioDecoder) mAudioDecoder).maybeDecodeOnlyIndex()) { + AUDIO_TRACK.handleDiscontinuity(); + return true; + } + } + ByteBuffer outputBuffer = mAudioDecoder.getDecodedSample(); + long presentationTimeUs = mAudioDecoder.getDecodedTimeUs(); + decodeDone(outputBuffer, presentationTimeUs); + } else { + decodeDone(mSampleHolder.data, mSampleHolder.timeUs); + } return true; } } @@ -383,15 +548,22 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC int handleBufferResult; try { // To reduce discontinuity, interpolate presentation time. - mInterpolatedTimeUs = mPresentationTimeUs + if (MimeTypes.AUDIO_MPEG_L2.equalsIgnoreCase(mDecodingMime)) { + mInterpolatedTimeUs = mPresentationTimeUs + + mPresentationCount * MP2_SAMPLE_DURATION_US; + } else if (!mUseFrameworkDecoder) { + mInterpolatedTimeUs = mPresentationTimeUs + mPresentationCount * AC3_SAMPLE_DURATION_US; - handleBufferResult = AUDIO_TRACK.handleBuffer(mOutputBuffer, - 0, mOutputBuffer.limit(), mInterpolatedTimeUs); + } else { + mInterpolatedTimeUs = mPresentationTimeUs; + } + 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; @@ -399,6 +571,9 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) { mCodecCounters.renderedOutputBufferCount++; mOutputReady = false; + if (mUseFrameworkDecoder) { + ((MediaCodecAudioDecoder) mAudioDecoder).releaseOutputBuffer(); + } return true; } return false; @@ -421,7 +596,7 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC if (!AUDIO_TRACK.isInitialized()) { return mAudioClock.getPositionUs(); } else if (!AUDIO_TRACK.isEnabled()) { - if (mInterpolatedTimeUs > 0) { + if (mInterpolatedTimeUs > 0 && !mUseFrameworkDecoder) { return mInterpolatedTimeUs - ESTIMATED_TRACK_RENDERING_DELAY_US; } return mPresentationTimeUs; @@ -471,8 +646,6 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC 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 { @@ -511,24 +684,29 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC public void handleMessage(int messageType, Object message) throws ExoPlaybackException { switch (messageType) { case MSG_SET_VOLUME: - AUDIO_TRACK.setVolume((Float) message); + float volume = (Float) message; + // Workaround: we cannot mute the audio track by setting the volume to 0, we need to + // disable the AUDIO_TRACK for this intent. However, enabling/disabling audio track + // whenever volume is being set might cause side effects, therefore we only handle + // "explicit mute operations", i.e., only after certain non-zero volume has been + // set, the subsequent volume setting operations will be consider as mute/un-mute + // operations and thus enable/disable the audio track. + if (mIsMuted && volume > 0) { + mIsMuted = false; + if (mEnabled) { + setStatus(true); + } + } else if (!mIsMuted && volume == 0) { + mIsMuted = true; + if (mEnabled) { + setStatus(false); + } + } + AUDIO_TRACK.setVolume(volume); 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()); - } + mEnabled = (Integer) message == 1; + setStatus(mEnabled); break; case MSG_SET_PLAYBACK_SPEED: mAudioClock.setPlaybackSpeed((Float) message); @@ -537,4 +715,21 @@ public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaC super.handleMessage(messageType, message); } } + + private void setStatus(boolean enabled) { + 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()); + } + } } diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java b/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java index 2bf86b5a..142aa9b2 100644 --- a/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java +++ b/src/com/android/tv/tuner/exoplayer/audio/MpegTsMediaCodecAudioTrackRenderer.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.tuner.exoplayer.ac3; +package com.android.tv.tuner.exoplayer.audio; import android.os.Handler; @@ -25,16 +25,13 @@ 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. + * + * <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; - +public class MpegTsMediaCodecAudioTrackRenderer extends MediaCodecAudioTrackRenderer { private final Ac3EventListener mListener; public interface Ac3EventListener extends EventListener { @@ -47,8 +44,11 @@ public class Ac3TrackRenderer extends MediaCodecAudioTrackRenderer { void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e); } - public Ac3TrackRenderer(SampleSource source, MediaCodecSelector mediaCodecSelector, - Handler eventHandler, EventListener eventListener) { + public MpegTsMediaCodecAudioTrackRenderer( + SampleSource source, + MediaCodecSelector mediaCodecSelector, + Handler eventHandler, + EventListener eventListener) { super(source, mediaCodecSelector, eventHandler, eventListener); mListener = (Ac3EventListener) eventListener; } diff --git a/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java index eb596e93..112e9dc4 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java @@ -25,13 +25,14 @@ import android.util.Log; import android.util.Pair; import com.google.android.exoplayer.SampleHolder; +import com.android.tv.common.SoftPreconditions; 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.ConcurrentModificationException; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -59,7 +60,8 @@ public class BufferManager { 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, SortedMap<Long, Pair<SampleChunk, Integer>>> mChunkMap = + new ArrayMap<>(); private final Map<String, Long> mStartPositionMap = new ArrayMap<>(); private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>(); private final StorageManager mStorageManager; @@ -77,13 +79,11 @@ public class BufferManager { } }; - 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); @@ -174,6 +174,66 @@ public class BufferManager { } /** + * A Track format which will be loaded and saved from the permanent storage for recordings. + */ + public static class TrackFormat { + + /** + * The track id for the specified track. The track id will be used as a track identifier + * for recordings. + */ + public final String trackId; + + /** + * The {@link MediaFormat} for the specified track. + */ + public final MediaFormat format; + + /** + * Creates TrackFormat. + * @param trackId + * @param format + */ + public TrackFormat(String trackId, MediaFormat format) { + this.trackId = trackId; + this.format = format; + } + } + + /** + * A Holder for a sample position which will be loaded from the index file for recordings. + */ + public static class PositionHolder { + + /** + * The current sample position in microseconds. + * The position is identical to the PTS(presentation time stamp) of the sample. + */ + public final long positionUs; + + /** + * Base sample position for the current {@link SampleChunk}. + */ + public final long basePositionUs; + + /** + * The file offset for the current sample in the current {@link SampleChunk}. + */ + public final int offset; + + /** + * Creates a holder for a specific position in the recording. + * @param positionUs + * @param offset + */ + public PositionHolder(long positionUs, long basePositionUs, int offset) { + this.positionUs = positionUs; + this.basePositionUs = basePositionUs; + this.offset = offset; + } + } + + /** * Storage configuration and policy manager for {@link BufferManager} */ public interface StorageManager { @@ -186,11 +246,6 @@ public class BufferManager { 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 @@ -220,29 +275,27 @@ public class BufferManager { * 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 + * @return {@link List} of TrackFormat */ - Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException; + List<TrackFormat> readTrackInfoFiles(boolean isAudio); /** - * Reads sample indexes for each written sample from storage. + * Reads key sample positions 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; + ArrayList<PositionHolder> readIndexFile(String trackId) throws IOException; /** * Writes track information to storage. * - * @param trackId track name - * @param format {@link android.media.MediaFormat} of the track + * @param formatList {@list List} of TrackFormat * @param isAudio {@code true} if it is for audio track * @throws IOException */ - void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) + void writeTrackInfoFiles(List<TrackFormat> formatList, boolean isAudio) throws IOException; /** @@ -252,7 +305,7 @@ public class BufferManager { * @param index {@link SampleChunk} container * @throws IOException */ - void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) + void writeIndexFile(String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index) throws IOException; } @@ -307,7 +360,6 @@ public class BufferManager { SampleChunk.SampleChunkCreator sampleChunkCreator) { mStorageManager = storageManager; mSampleChunkCreator = sampleChunkCreator; - clearBuffer(true); } public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) { @@ -318,44 +370,44 @@ public class BufferManager { 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. + * Creates a new {@link SampleChunk} for caching samples if it is needed. * * @param id the name of the track - * @param positionUs starting position of the {@link SampleChunk} in micro seconds. + * @param positionUs current position to write a sample in micro seconds. * @param samplePool {@link SamplePool} for the fast creation of samples. + * @param currentChunk the current {@link SampleChunk} to write, {@code null} when to create + * a new {@link SampleChunk}. + * @param currentOffset the current offset to write. * @return returns the created {@link SampleChunk}. * @throws IOException */ - public SampleChunk createNewWriteFile(String id, long positionUs, - SamplePool samplePool) throws IOException { + public SampleChunk createNewWriteFileIfNeeded(String id, long positionUs, SamplePool samplePool, + SampleChunk currentChunk, int currentOffset) throws IOException { if (!maybeEvictChunk()) { throw new IOException("Not enough storage space"); } - SortedMap<Long, SampleChunk> map = mChunkMap.get(id); + SortedMap<Long, Pair<SampleChunk, Integer>> 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; + if (currentChunk == null) { + File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs)); + SampleChunk sampleChunk = mSampleChunkCreator + .createSampleChunk(samplePool, file, positionUs, mChunkCallback); + map.put(positionUs, new Pair(sampleChunk, 0)); + return sampleChunk; + } else { + map.put(positionUs, new Pair(currentChunk, currentOffset)); + return null; + } } /** @@ -366,10 +418,10 @@ public class BufferManager { * @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; + ArrayList<PositionHolder> keyPositions = mStorageManager.readIndexFile(trackId); + long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0).positionUs : 0; - SortedMap<Long, SampleChunk> map = mChunkMap.get(trackId); + SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(trackId); if (map == null) { map = new TreeMap<>(); mChunkMap.put(trackId, map); @@ -377,11 +429,15 @@ public class BufferManager { 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); + long basePositionUs = -1; + for (PositionHolder position: keyPositions) { + if (position.basePositionUs != basePositionUs) { + chunk = mSampleChunkCreator.loadSampleChunkFromFile(samplePool, + mStorageManager.getBufferDir(), getFileName(trackId, position.positionUs), + position.positionUs, mChunkCallback, chunk); + basePositionUs = position.basePositionUs; + } + map.put(position.positionUs, new Pair(chunk, position.offset)); } } @@ -392,19 +448,19 @@ public class BufferManager { * @param positionUs the position. * @return returns the found {@link SampleChunk}. */ - public SampleChunk getReadFile(String id, long positionUs) { - SortedMap<Long, SampleChunk> map = mChunkMap.get(id); + public Pair<SampleChunk, Integer> getReadFile(String id, long positionUs) { + SortedMap<Long, Pair<SampleChunk, Integer>> map = mChunkMap.get(id); if (map == null) { return null; } - SampleChunk sampleChunk; - SortedMap<Long, SampleChunk> headMap = map.headMap(positionUs + 1); + Pair<SampleChunk, Integer> ret; + SortedMap<Long, Pair<SampleChunk, Integer>> headMap = map.headMap(positionUs + 1); if (!headMap.isEmpty()) { - sampleChunk = headMap.get(headMap.lastKey()); + ret = headMap.get(headMap.lastKey()); } else { - sampleChunk = map.get(map.firstKey()); + ret = map.get(map.firstKey()); } - return sampleChunk; + return ret; } /** @@ -439,15 +495,16 @@ public class BufferManager { // Since chunks are persistent, we cannot evict chunks. return false; } - SortedMap<Long, SampleChunk> earliestChunkMap = null; + SortedMap<Long, Pair<SampleChunk, Integer>> earliestChunkMap = null; SampleChunk earliestChunk = null; String earliestChunkId = null; - for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) { - SortedMap<Long, SampleChunk> map = entry.getValue(); + for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry : + mChunkMap.entrySet()) { + SortedMap<Long, Pair<SampleChunk, Integer>> map = entry.getValue(); if (map.isEmpty()) { continue; } - SampleChunk chunk = map.get(map.firstKey()); + SampleChunk chunk = map.get(map.firstKey()).first; if (earliestChunk == null || chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) { earliestChunkMap = map; @@ -473,8 +530,9 @@ public class BufferManager { } pendingDelete = mPendingDelete.getSize(); } - for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) { - SortedMap<Long, SampleChunk> map = entry.getValue(); + for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry : + mChunkMap.entrySet()) { + SortedMap<Long, Pair<SampleChunk, Integer>> map = entry.getValue(); if (map.isEmpty()) { continue; } @@ -489,70 +547,74 @@ public class BufferManager { * @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. + public List<TrackFormat> readTrackInfoFiles() throws IOException { + List<TrackFormat> trackFormatList = new ArrayList<>(); + trackFormatList.addAll(mStorageManager.readTrackInfoFiles(false)); + trackFormatList.addAll(mStorageManager.readTrackInfoFiles(true)); + if (trackFormatList.isEmpty()) { + throw new IOException("No track information to load"); } - try { - trackInfos.add(mStorageManager.readTrackInfoFile(true)); - } catch (FileNotFoundException e) { - // See above catch block. - } - return trackInfos; + return trackFormatList; } /** * Writes track information and index information for all tracks. * - * @param audio audio information. - * @param video video information. + * @param audios list of audio track information + * @param videos list of audio track information * @throws IOException */ - public void writeMetaFiles(Pair<String, MediaFormat> audio, Pair<String, MediaFormat> video) + public void writeMetaFiles(List<TrackFormat> audios, List<TrackFormat> videos) 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"); + if (audios.isEmpty() && videos.isEmpty()) { + throw new IOException("No track information to save"); + } + if (!audios.isEmpty()) { + mStorageManager.writeTrackInfoFiles(audios, true); + for (TrackFormat trackFormat : audios) { + SortedMap<Long, Pair<SampleChunk, Integer>> map = + mChunkMap.get(trackFormat.trackId); + if (map == null) { + throw new IOException("Audio track index missing"); + } + mStorageManager.writeIndexFile(trackFormat.trackId, map); } - 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"); + if (!videos.isEmpty()) { + mStorageManager.writeTrackInfoFiles(videos, false); + for (TrackFormat trackFormat : videos) { + SortedMap<Long, Pair<SampleChunk, Integer>> map = + mChunkMap.get(trackFormat.trackId); + if (map == null) { + throw new IOException("Video track index missing"); + } + mStorageManager.writeIndexFile(trackFormat.trackId, map); } - 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()); + try { + mPendingDelete.release(); + for (Map.Entry<String, SortedMap<Long, Pair<SampleChunk, Integer>>> entry : + mChunkMap.entrySet()) { + SampleChunk toRelease = null; + for (Pair<SampleChunk, Integer> positions : entry.getValue().values()) { + if (toRelease != positions.first) { + toRelease = positions.first; + SampleChunk.IoState.release(toRelease, !mStorageManager.isPersistent()); + } + } } - } - mChunkMap.clear(); - if (mClosed) { - clearBuffer(!mStorageManager.isPersistent()); + mChunkMap.clear(); + } catch (ConcurrentModificationException | NullPointerException e) { + // TODO: remove this after it it confirmed that race condition issues are resolved. + // b/32492258, b/32373376 + SoftPreconditions.checkState(false, "Exception on BufferManager#release: ", + e.toString()); } } @@ -611,20 +673,6 @@ public class BufferManager { } /** - * 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. */ diff --git a/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java index 6a0502a7..6a09016c 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java @@ -17,8 +17,12 @@ package com.android.tv.tuner.exoplayer.buffer; import android.media.MediaFormat; +import android.util.Log; import android.util.Pair; +import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; +import com.google.protobuf.nano.MessageNano; + import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; @@ -28,18 +32,25 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.SortedMap; /** * Manages DVR storage. */ public class DvrStorageManager implements BufferManager.StorageManager { + private static final String TAG = "DvrStorageManager"; // 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_TYPE_AUDIO = "audio"; + private static final String META_FILE_TYPE_VIDEO = "video"; + private static final String META_FILE_TYPE_CAPTION = "caption"; private static final String META_FILE_SUFFIX = ".meta"; private static final String IDX_FILE_SUFFIX = ".idx"; + private static final String IDX_FILE_SUFFIX_V2 = IDX_FILE_SUFFIX + "2"; // Size of minimum reserved storage buffer which will be used to save meta files // and index files after actual recording finished. @@ -59,18 +70,6 @@ public class DvrStorageManager implements BufferManager.StorageManager { } @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; } @@ -132,6 +131,17 @@ public class DvrStorageManager implements BufferManager.StorageManager { } } + private void readFormatStringOptional(DataInputStream in, MediaFormat format, String key) { + try { + String str = readString(in); + if (str != null) { + format.setString(key, str); + } + } catch (IOException e) { + // Since we are reading optional field, ignore the exception. + } + } + private ByteBuffer readByteBuffer(DataInputStream in) throws IOException { int len = in.readInt(); if (len <= 0) { @@ -155,39 +165,104 @@ public class DvrStorageManager implements BufferManager.StorageManager { } @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); + public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) { + List<BufferManager.TrackFormat> trackFormatList = new ArrayList<>(); + int index = 0; + boolean trackNotFound = false; + do { + String fileName = (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO) + + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX)); + File file = new File(getBufferDir(), fileName); + 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); + + // This is optional since language field is added later. + readFormatStringOptional(in, format, MediaFormat.KEY_LANGUAGE); + trackFormatList.add(new BufferManager.TrackFormat(name, format)); + } catch (IOException e) { + trackNotFound = true; } - readFormatLong(in, format, MediaFormat.KEY_DURATION); - return new Pair<>(name, format); + index++; + } while(!trackNotFound); + return trackFormatList; + } + + /** + * Reads caption information from files. + * + * @return a list of {@link AtscCaptionTrack} objects which store caption information. + */ + public List<AtscCaptionTrack> readCaptionInfoFiles() { + List<AtscCaptionTrack> tracks = new ArrayList<>(); + int index = 0; + boolean trackNotFound = false; + do { + String fileName = META_FILE_TYPE_CAPTION + + ((index == 0) ? META_FILE_SUFFIX : (index + META_FILE_SUFFIX)); + File file = new File(getBufferDir(), fileName); + try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { + byte[] data = new byte[(int) file.length()]; + in.read(data); + tracks.add(AtscCaptionTrack.parseFrom(data)); + } catch (IOException e) { + trackNotFound = true; + } + index++; + } while(!trackNotFound); + return tracks; + } + + private ArrayList<BufferManager.PositionHolder> readOldIndexFile(File indexFile) + throws IOException { + ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>(); + try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) { + long count = in.readLong(); + for (long i = 0; i < count; ++i) { + long positionUs = in.readLong(); + indices.add(new BufferManager.PositionHolder(positionUs, positionUs, 0)); + } + return indices; } } - @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))) { + private ArrayList<BufferManager.PositionHolder> readNewIndexFile(File indexFile) + throws IOException { + ArrayList<BufferManager.PositionHolder> indices = new ArrayList<>(); + try (DataInputStream in = new DataInputStream(new FileInputStream(indexFile))) { long count = in.readLong(); for (long i = 0; i < count; ++i) { - indices.add(in.readLong()); + long positionUs = in.readLong(); + long basePositionUs = in.readLong(); + int offset = in.readInt(); + indices.add(new BufferManager.PositionHolder(positionUs, basePositionUs, offset)); } return indices; } } + @Override + public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId) + throws IOException { + File file = new File(getBufferDir(), trackId + IDX_FILE_SUFFIX_V2); + if (file.exists()) { + return readNewIndexFile(file); + } else { + return readOldIndexFile(new File(getBufferDir(),trackId + IDX_FILE_SUFFIX)); + } + } + private void writeFormatInt(DataOutputStream out, MediaFormat format, String key) throws IOException { if (format.containsKey(key)) { @@ -254,33 +329,63 @@ public class DvrStorageManager implements BufferManager.StorageManager { } @Override - public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) + public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, 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); + for (int i = 0; i < formatList.size() ; ++i) { + BufferManager.TrackFormat trackFormat = formatList.get(i); + String fileName = (isAudio ? META_FILE_TYPE_AUDIO : META_FILE_TYPE_VIDEO) + + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX)); + File file = new File(getBufferDir(), fileName); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + writeString(out, trackFormat.trackId); + writeFormatString(out, trackFormat.format, MediaFormat.KEY_MIME); + writeFormatInt(out, trackFormat.format, MediaFormat.KEY_MAX_INPUT_SIZE); + writeFormatInt(out, trackFormat.format, MediaFormat.KEY_WIDTH); + writeFormatInt(out, trackFormat.format, MediaFormat.KEY_HEIGHT); + writeFormatInt(out, trackFormat.format, MediaFormat.KEY_CHANNEL_COUNT); + writeFormatInt(out, trackFormat.format, MediaFormat.KEY_SAMPLE_RATE); + writeFormatFloat(out, trackFormat.format, KEY_PIXEL_WIDTH_HEIGHT_RATIO); + for (int j = 0; j < 3; ++j) { + writeFormatByteBuffer(out, trackFormat.format, "csd-" + j); + } + writeFormatLong(out, trackFormat.format, MediaFormat.KEY_DURATION); + writeFormatString(out, trackFormat.format, MediaFormat.KEY_LANGUAGE); + } + } + } + + /** + * Writes caption information to files. + * + * @param tracks a list of {@link AtscCaptionTrack} objects which store caption information. + */ + public void writeCaptionInfoFiles(List<AtscCaptionTrack> tracks) { + if (tracks == null || tracks.isEmpty()) { + return; + } + for (int i = 0; i < tracks.size(); i++) { + AtscCaptionTrack track = tracks.get(i); + String fileName = META_FILE_TYPE_CAPTION + + ((i == 0) ? META_FILE_SUFFIX : (i + META_FILE_SUFFIX)); + File file = new File(getBufferDir(), fileName); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.write(MessageNano.toByteArray(track)); + } catch (Exception e) { + Log.e(TAG, "Fail to write caption info to files", e); } - writeFormatLong(out, format, MediaFormat.KEY_DURATION); } } @Override - public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) + public void writeIndexFile(String trackName, SortedMap<Long, Pair<SampleChunk, Integer>> index) throws IOException { - File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX); + File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX_V2); try (DataOutputStream out = new DataOutputStream(new FileOutputStream(indexFile))) { out.writeLong(index.size()); - for (Long key : index.keySet()) { - out.writeLong(key); + for (Map.Entry<Long, Pair<SampleChunk, Integer>> entry : index.entrySet()) { + out.writeLong(entry.getKey()); + out.writeLong(entry.getValue().first.getStartPositionUs()); + out.writeInt(entry.getValue().second); } } } diff --git a/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java index 4869b49f..af0c3f0d 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java @@ -66,9 +66,14 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, public static final int BUFFER_REASON_RECORDING = 2; /** - * The duration of a chunk of samples, {@link SampleChunk}. + * The minimum duration to support seek in Trickplay. */ - static final long CHUNK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500); + static final long MIN_SEEK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500); + + /** + * The duration of a {@link SampleChunk} for recordings. + */ + static final long RECORDING_CHUNK_DURATION_US = MIN_SEEK_DURATION_US * 1200; // 10 minutes 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); @@ -79,7 +84,6 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, 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; @@ -130,7 +134,6 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, 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, @@ -139,6 +142,9 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, mReadSampleQueues.add(i, new SampleQueue(mSamplePool)); } mSampleChunkIoHelper.init(); + for (int i = 0; i < mTrackCount; ++i) { + mBufferManager.registerChunkEvictedListener(ids.get(i), RecordingSampleBuffer.this); + } } @Override @@ -146,8 +152,6 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, if (!mTrackSelected[index]) { mTrackSelected[index] = true; mReadSampleQueues.get(index).clear(); - mBufferManager.registerChunkEvictedListener(mIds.get(index), - RecordingSampleBuffer.this); mSampleChunkIoHelper.openRead(index, mCurrentPlaybackPositionUs); } } @@ -157,7 +161,7 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, if (mTrackSelected[index]) { mTrackSelected[index] = false; mReadSampleQueues.get(index).clear(); - mBufferManager.unregisterChunkEvictedListener(mIds.get(index)); + mSampleChunkIoHelper.closeRead(index); } } @@ -193,7 +197,6 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, } // Disables buffering samples afterwards, and notifies the disk speed is slow. Log.w(TAG, "Disk is too slow for trickplay"); - mBufferManager.disable(); mBufferListener.onDiskTooSlow(); } @@ -205,7 +208,7 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, private boolean maybeReadSample(SampleQueue queue, int index) { if (queue.getLastQueuedPositionUs() != null && queue.getLastQueuedPositionUs() > mCurrentPlaybackPositionUs + BUFFER_NEEDED_US - && queue.isDurationGreaterThan(CHUNK_DURATION_US)) { + && queue.isDurationGreaterThan(MIN_SEEK_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. @@ -300,7 +303,7 @@ public class RecordingSampleBuffer implements BufferManager.SampleBuffer, public void onChunkEvicted(String id, long createdTimeMs) { if (mBufferListener != null) { mBufferListener.onBufferStartTimeChanged( - createdTimeMs + TimeUnit.MICROSECONDS.toMillis(CHUNK_DURATION_US)); + createdTimeMs + TimeUnit.MICROSECONDS.toMillis(MIN_SEEK_DURATION_US)); } } } diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java index 552caaef..04b5a071 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java @@ -78,7 +78,6 @@ public class SampleChunk { /** * A class for SampleChunk creation. */ - @VisibleForTesting public static class SampleChunkCreator { /** @@ -151,18 +150,23 @@ public class SampleChunk { mCurrentOffset = 0; } + private void reset(SampleChunk chunk, long offset) { + mChunk = chunk; + mCurrentOffset = offset; + } + /** * 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 { + void openRead(SampleChunk chunk, long offset) throws IOException { if (mChunk != null) { mChunk.closeRead(); } chunk.openRead(); - reset(chunk); + reset(chunk, offset); } /** @@ -241,6 +245,20 @@ public class SampleChunk { } /** + * Returns the current SampleChunk for subsequent I/O operation. + */ + SampleChunk getChunk() { + return mChunk; + } + + /** + * Returns the current offset of the current SampleChunk for subsequent I/O operation. + */ + long getOffset() { + return mCurrentOffset; + } + + /** * Releases SampleChunk. the SampleChunk will not be used anymore. * * @param chunk to release diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java index 37ae4022..ca97a91a 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java @@ -21,6 +21,7 @@ import android.os.ConditionVariable; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; +import android.util.ArraySet; import android.util.Log; import android.util.Pair; @@ -31,7 +32,9 @@ import com.android.tv.common.SoftPreconditions; import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer.BufferReason; import java.io.IOException; +import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; /** @@ -46,11 +49,13 @@ public class SampleChunkIoHelper implements Handler.Callback { 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 static final int MSG_CLOSE_READ = 3; + private static final int MSG_CLOSE_WRITE = 4; + private static final int MSG_READ = 5; + private static final int MSG_WRITE = 6; + private static final int MSG_RELEASE = 7; + private final long mSampleChunkDurationUs; private final int mTrackCount; private final List<String> mIds; private final List<MediaFormat> mMediaFormats; @@ -62,9 +67,11 @@ public class SampleChunkIoHelper implements Handler.Callback { private Handler mIoHandler; private final ConcurrentLinkedQueue<SampleHolder> mReadSampleBuffers[]; private final ConcurrentLinkedQueue<SampleHolder> mHandlerReadSampleBuffers[]; - private final long[] mWriteEndPositionUs; + private final long[] mWriteIndexEndPositionUs; + private final long[] mWriteChunkEndPositionUs; private final SampleChunk.IoState[] mReadIoStates; private final SampleChunk.IoState[] mWriteIoStates; + private final Set<Integer> mSelectedTracks = new ArraySet<>(); private long mBufferDurationUs = 0; private boolean mWriteEnded; private boolean mErrorNotified; @@ -129,11 +136,20 @@ public class SampleChunkIoHelper implements Handler.Callback { mReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount]; mHandlerReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount]; - mWriteEndPositionUs = new long[mTrackCount]; + mWriteIndexEndPositionUs = new long[mTrackCount]; + mWriteChunkEndPositionUs = new long[mTrackCount]; mReadIoStates = new SampleChunk.IoState[mTrackCount]; mWriteIoStates = new SampleChunk.IoState[mTrackCount]; + + // Small chunk duration for live playback will give more fine grained storage usage + // and eviction handling for trickplay. + mSampleChunkDurationUs = + bufferReason == RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK ? + RecordingSampleBuffer.MIN_SEEK_DURATION_US : + RecordingSampleBuffer.RECORDING_CHUNK_DURATION_US; for (int i = 0; i < mTrackCount; ++i) { - mWriteEndPositionUs[i] = RecordingSampleBuffer.CHUNK_DURATION_US; + mWriteIndexEndPositionUs[i] = RecordingSampleBuffer.MIN_SEEK_DURATION_US; + mWriteChunkEndPositionUs[i] = mSampleChunkDurationUs; mReadIoStates[i] = new SampleChunk.IoState(); mWriteIoStates[i] = new SampleChunk.IoState(); } @@ -204,6 +220,15 @@ public class SampleChunkIoHelper implements Handler.Callback { } /** + * Closes read from the specified track. + * + * @param index track index + */ + public void closeRead(int index) { + mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_CLOSE_READ, index)); + } + + /** * Notifies writes are finished. */ public void closeWrite() { @@ -229,21 +254,19 @@ public class SampleChunkIoHelper implements Handler.Callback { try { if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDING && mTrackCount > 0) { // Saves meta information for recording. - Pair<String, android.media.MediaFormat> audio = null, video = null; + List<BufferManager.TrackFormat> audios = new LinkedList<>(); + List<BufferManager.TrackFormat> videos = new LinkedList<>(); 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; + if (MimeTypes.isAudio(mMediaFormats.get(i).mimeType)) { + audios.add(new BufferManager.TrackFormat(mIds.get(i), format)); + } else if (MimeTypes.isVideo(mMediaFormats.get(i).mimeType)) { + videos.add(new BufferManager.TrackFormat(mIds.get(i), format)); } } - mBufferManager.writeMetaFiles(audio, video); + mBufferManager.writeMetaFiles(audios, videos); } } finally { mBufferManager.release(); @@ -265,6 +288,9 @@ public class SampleChunkIoHelper implements Handler.Callback { case MSG_OPEN_WRITE: doOpenWrite((int) message.obj); return true; + case MSG_CLOSE_READ: + doCloseRead((int) message.obj); + return true; case MSG_CLOSE_WRITE: doCloseWrite(); return true; @@ -291,14 +317,16 @@ public class SampleChunkIoHelper implements Handler.Callback { 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) { + Pair<SampleChunk, Integer> readPosition = + mBufferManager.getReadFile(mIds.get(index), params.positionUs); + if (readPosition == null) { String errorMessage = "Chunk ID:" + mIds.get(index) + " pos:" + params.positionUs + "is not found"; - SoftPreconditions.checkNotNull(chunk, TAG, errorMessage); + SoftPreconditions.checkNotNull(readPosition, TAG, errorMessage); throw new IOException(errorMessage); } - mReadIoStates[index].openRead(chunk); + mSelectedTracks.add(index); + mReadIoStates[index].openRead(readPosition.first, (long) readPosition.second); if (mHandlerReadSampleBuffers[index] != null) { SampleHolder sample; while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) { @@ -310,10 +338,22 @@ public class SampleChunkIoHelper implements Handler.Callback { } private void doOpenWrite(int index) throws IOException { - SampleChunk chunk = mBufferManager.createNewWriteFile(mIds.get(index), 0, mSamplePool); + SampleChunk chunk = mBufferManager.createNewWriteFileIfNeeded(mIds.get(index), 0, + mSamplePool, null, 0); mWriteIoStates[index].openWrite(chunk); } + private void doCloseRead(int index) { + mSelectedTracks.remove(index); + if (mHandlerReadSampleBuffers[index] != null) { + SampleHolder sample; + while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) { + mSamplePool.releaseSample(sample); + } + } + mIoHandler.removeMessages(MSG_READ, index); + } + private void doRead(int index) throws IOException { mIoHandler.removeMessages(MSG_READ, index); if (mHandlerReadSampleBuffers[index].size() >= MAX_READ_BUFFER_SAMPLES) { @@ -357,13 +397,21 @@ public class SampleChunkIoHelper implements Handler.Callback { 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; + if (sample.timeUs >= mWriteIndexEndPositionUs[index]) { + SampleChunk currentChunk = sample.timeUs >= mWriteChunkEndPositionUs[index] ? + null : mWriteIoStates[params.index].getChunk(); + int currentOffset = (int) mWriteIoStates[params.index].getOffset(); + nextChunk = mBufferManager.createNewWriteFileIfNeeded( + mIds.get(index), mWriteIndexEndPositionUs[index], mSamplePool, + currentChunk, currentOffset); + mWriteIndexEndPositionUs[index] = + ((sample.timeUs / RecordingSampleBuffer.MIN_SEEK_DURATION_US) + 1) * + RecordingSampleBuffer.MIN_SEEK_DURATION_US; + if (nextChunk != null) { + mWriteChunkEndPositionUs[index] = + ((sample.timeUs / mSampleChunkDurationUs) + 1) + * mSampleChunkDurationUs; + } } } mWriteIoStates[params.index].write(params.sample, nextChunk); @@ -391,15 +439,22 @@ public class SampleChunkIoHelper implements Handler.Callback { mIoHandler.removeCallbacksAndMessages(null); mFinished = true; conditionVariable.open(); + mSelectedTracks.clear(); } private void releaseEvictedChunks() { - if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK) { + if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK + || mSelectedTracks.isEmpty()) { return; } + long currentStartPositionUs = Long.MAX_VALUE; + for (int trackIndex : mSelectedTracks) { + currentStartPositionUs = Math.min(currentStartPositionUs, + mReadIoStates[trackIndex].getStartPositionUs()); + } for (int i = 0; i < mTrackCount; ++i) { long evictEndPositionUs = Math.min(mBufferManager.getStartPositionUs(mIds.get(i)), - mReadIoStates[i].getStartPositionUs()); + currentStartPositionUs); mBufferManager.evictChunks(mIds.get(i), evictEndPositionUs); } } diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java index 7b098f40..75eac5a2 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java @@ -43,6 +43,7 @@ public class SampleQueue { if (sampleFromQueue == null) { return SampleSource.NOTHING_READ; } + sample.ensureSpaceForWrite(sampleFromQueue.size); sample.size = sampleFromQueue.size; sample.flags = sampleFromQueue.flags; sample.timeUs = sampleFromQueue.timeUs; diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java index 40c4ef95..159fde18 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java @@ -19,18 +19,18 @@ 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.common.SoftPreconditions; 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. @@ -115,8 +115,8 @@ public class SimpleSampleBuffer implements BufferManager.SampleBuffer { @Override public synchronized int readSample(int track, SampleHolder sampleHolder) { SampleQueue queue = mPlayingSampleQueues[track]; - Assert.assertNotNull(queue); - int result = queue.dequeueSample(sampleHolder); + SoftPreconditions.checkNotNull(queue); + int result = queue == null ? SampleSource.NOTHING_READ : queue.dequeueSample(sampleHolder); if (result != SampleSource.SAMPLE_READ && reachedEos()) { return SampleSource.END_OF_STREAM; } diff --git a/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java index 258a5cd0..9fe921b8 100644 --- a/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java +++ b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java @@ -17,20 +17,23 @@ 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.support.annotation.NonNull; import android.util.Pair; +import com.android.tv.common.SoftPreconditions; + import java.io.File; import java.util.ArrayList; +import java.util.List; import java.util.SortedMap; /** * Manages Trickplay storage. */ public class TrickplayStorageManager implements BufferManager.StorageManager { + // TODO: Support multi-sessions. private static final String BUFFER_DIR = "timeshift"; // Copied from android.provider.Settings.Global (hidden fields) @@ -43,53 +46,68 @@ public class TrickplayStorageManager implements BufferManager.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 static AsyncTask<Void, Void, Void> sLastCacheCleanUpTask; + private static File sBufferDir; + private static long sStorageBufferBytes; + private final long mMaxBufferSize; - private final long mStorageBufferBytes; - private static long getStorageBufferBytes(Context context, File path) { + private static void initParamsIfNeeded(Context context, @NonNull File path) { + // TODO: Support multi-sessions. + SoftPreconditions.checkState( + sBufferDir == null || sBufferDir.equals(path)); + if (path.equals(sBufferDir)) { + return; + } + sBufferDir = path; long lowPercentage = Settings.Global.getInt(context.getContentResolver(), SYS_STORAGE_THRESHOLD_PERCENTAGE, DEFAULT_THRESHOLD_PERCENTAGE); - long lowBytes = path.getTotalSpace() * lowPercentage / 100; + long lowPercentageToBytes = 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); + sStorageBufferBytes = Math.min(lowPercentageToBytes, maxLowBytes); } - public TrickplayStorageManager(Context context, File baseDir, long maxBufferSize) { - mBufferDir = new File(baseDir, BUFFER_DIR); - mBufferDir.mkdirs(); + public TrickplayStorageManager(Context context, @NonNull File baseDir, long maxBufferSize) { + initParamsIfNeeded(context, new File(baseDir, BUFFER_DIR)); + sBufferDir.mkdirs(); mMaxBufferSize = maxBufferSize; clearStorage(); - mStorageBufferBytes = getStorageBufferBytes(context, mBufferDir); } - @Override - public void clearStorage() { - File files[] = mBufferDir.listFiles(); - if (files == null || files.length == 0) { - return; + private void clearStorage() { + long now = System.currentTimeMillis(); + if (sLastCacheCleanUpTask != null) { + sLastCacheCleanUpTask.cancel(true); } - if (Looper.myLooper() == Looper.getMainLooper()) { - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - for (File file : files) { + sLastCacheCleanUpTask = new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + if (isCancelled()) { + return null; + } + File files[] = sBufferDir.listFiles(); + if (files == null || files.length == 0) { + return null; + } + for (File file : files) { + if (isCancelled()) { + break; + } + long lastModified = file.lastModified(); + if (lastModified != 0 && lastModified < now) { file.delete(); } - return null; } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } else { - for (File file : files) { - file.delete(); + return null; } - } + }; + sLastCacheCleanUpTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } @Override public File getBufferDir() { - return mBufferDir; + return sBufferDir; } @Override @@ -104,25 +122,26 @@ public class TrickplayStorageManager implements BufferManager.StorageManager { @Override public boolean hasEnoughBuffer(long pendingDelete) { - return mBufferDir.getUsableSpace() + pendingDelete >= mStorageBufferBytes; + return sBufferDir.getUsableSpace() + pendingDelete >= sStorageBufferBytes; } @Override - public Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) { + public List<BufferManager.TrackFormat> readTrackInfoFiles(boolean isAudio) { return null; } @Override - public ArrayList<Long> readIndexFile(String trackId) { + public ArrayList<BufferManager.PositionHolder> readIndexFile(String trackId) { return null; } @Override - public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) { + public void writeTrackInfoFiles(List<BufferManager.TrackFormat> formatList, boolean isAudio) { } @Override - public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) { + public void writeIndexFile(String trackName, + SortedMap<Long, Pair<SampleChunk, Integer>> index) { } } diff --git a/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java new file mode 100644 index 00000000..356636cc --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderClient.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2017 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.ffmpeg; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.RemoteException; + +import android.support.annotation.MainThread; +import android.support.annotation.WorkerThread; +import android.support.annotation.VisibleForTesting; +import com.google.android.exoplayer.SampleHolder; +import com.android.tv.Features; +import com.android.tv.tuner.exoplayer.audio.AudioDecoder; + +import java.nio.ByteBuffer; + +/** + * The class connects {@link FfmpegDecoderService} to decode audio samples. + * In order to sandbox ffmpeg based decoder, {@link FfmpegDecoderService} is an isolated process + * without any permission and connected by binder. + */ +public class FfmpegDecoderClient extends AudioDecoder { + private static FfmpegDecoderClient sInstance; + + private IFfmpegDecoder mService; + private Boolean mIsAvailable; + + private static final String FFMPEG_DECODER_SERVICE_FILTER = + "com.android.tv.tuner.exoplayer.ffmpeg.IFfmpegDecoder"; + private static final long FFMPEG_SERVICE_CONNECT_TIMEOUT_MS = 500; + + private final ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + mService = IFfmpegDecoder.Stub.asInterface(service); + synchronized (FfmpegDecoderClient.this) { + try { + mIsAvailable = mService.isAvailable(); + } catch (RemoteException e) { + } + FfmpegDecoderClient.this.notify(); + } + } + + @Override + public void onServiceDisconnected(ComponentName className) { + synchronized (FfmpegDecoderClient.this) { + sInstance.releaseLocked(); + mIsAvailable = false; + mService = null; + } + } + }; + + /** + * Connects to the decoder service for future uses. + * @param context + * @return {@code true} when decoder service is connected. + */ + @MainThread + public synchronized static boolean connect(Context context) { + if (Features.AC3_SOFTWARE_DECODE.isEnabled(context)) { + if (sInstance == null) { + sInstance = new FfmpegDecoderClient(); + Intent intent = + new Intent(FFMPEG_DECODER_SERVICE_FILTER) + .setComponent( + new ComponentName(context, FfmpegDecoderService.class)); + if (context.bindService(intent, sInstance.mConnection, Context.BIND_AUTO_CREATE)) { + return true; + } else { + sInstance = null; + } + } + } + return false; + } + + /** + * Disconnects from the decoder service and release resources. + * @param context + */ + @MainThread + public synchronized static void disconnect(Context context) { + if (sInstance != null) { + synchronized (sInstance) { + sInstance.releaseLocked(); + if (sInstance.mIsAvailable != null && sInstance.mIsAvailable) { + context.unbindService(sInstance.mConnection); + } + sInstance.mIsAvailable = false; + sInstance.mService = null; + } + sInstance = null; + } + } + + /** + * Returns whether service is available or not. + * Before using client, this should be used to check availability. + */ + @WorkerThread + public synchronized static boolean isAvailable() { + if (sInstance != null) { + return sInstance.available(); + } + return false; + } + + /** + * Returns an client instance. + */ + public synchronized static FfmpegDecoderClient getInstance() { + if (sInstance != null) { + sInstance.createDecoder(); + } + return sInstance; + } + + private FfmpegDecoderClient() { + } + + private synchronized boolean available() { + if (mIsAvailable == null) { + try { + this.wait(FFMPEG_SERVICE_CONNECT_TIMEOUT_MS); + } catch (InterruptedException e) { + } + } + return mIsAvailable != null && mIsAvailable == true; + } + + private synchronized void createDecoder() { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.create(); + } catch (RemoteException e) { + } + } + + private void releaseLocked() { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.release(); + } catch (RemoteException e) { + } + } + + @Override + public synchronized void release() { + releaseLocked(); + } + + @Override + public synchronized void decode(SampleHolder sampleHolder) { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + byte[] sampleBytes = new byte [sampleHolder.data.limit()]; + sampleHolder.data.get(sampleBytes, 0, sampleBytes.length); + try { + mService.decode(sampleHolder.timeUs, sampleBytes); + } catch (RemoteException e) { + } + } + + @Override + public synchronized void resetDecoderState(String mimeType) { + if (mIsAvailable == null || mIsAvailable == false) { + return; + } + try { + mService.resetDecoderState(mimeType); + } catch (RemoteException e) { + } + } + + @Override + public synchronized ByteBuffer getDecodedSample() { + if (mIsAvailable == null || mIsAvailable == false) { + return null; + } + try { + byte[] outputBytes = mService.getDecodedSample(); + if (outputBytes != null && outputBytes.length > 0) { + return ByteBuffer.wrap(outputBytes); + } + } catch (RemoteException e) { + } + return null; + } + + @Override + public synchronized long getDecodedTimeUs() { + if (mIsAvailable == null || mIsAvailable == false) { + return 0; + } + try { + return mService.getDecodedTimeUs(); + } catch (RemoteException e) { + } + return 0; + } + + @VisibleForTesting + public boolean testSandboxIsolatedProcess() { + // When testing isolated process, we will check the permission in FfmpegDecoderService. + // If the service have any permission, an exception will be thrown. + try { + mService.testSandboxIsolatedProcess(); + } catch (RemoteException e) { + return false; + } + return true; + } + + @VisibleForTesting + public void testSandboxMinijail() { + // When testing minijail, we will call a system call which is blocked by minijail. In that + // case, the FfmpegDecoderService will be disconnected, we can check the connection status + // to make sure if the minijail works or not. + try { + mService.testSandboxMinijail(); + } catch (RemoteException e) { + } + } +} diff --git a/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java new file mode 100644 index 00000000..3ebdd381 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/FfmpegDecoderService.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2017 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.ffmpeg; + +import android.app.Service; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.os.AsyncTask; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioDecoder; + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Ffmpeg based audio decoder service. + * It should be isolatedProcess due to security reason. + */ +public class FfmpegDecoderService extends Service { + private static final String TAG = "FfmpegDecoderService"; + private static final boolean DEBUG = false; + + private static final String POLICY_FILE = "whitelist.policy"; + + private static final long MINIJAIL_SETUP_WAIT_TIMEOUT_MS = 5000; + + private static boolean sLibraryLoaded = true; + + static { + try { + System.loadLibrary("minijail_jni"); + } catch (Exception | Error e) { + Log.e(TAG, "Load minijail failed:", e); + sLibraryLoaded = false; + } + } + + private FfmpegDecoder mBinder = new FfmpegDecoder(); + private volatile Object mMinijailSetupMonitor = new Object(); + //@GuardedBy("mMinijailSetupMonitor") + private volatile Boolean mMinijailSetup; + + @Override + public void onCreate() { + if (sLibraryLoaded) { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + synchronized (mMinijailSetupMonitor) { + int pipeFd = getPolicyPipeFd(); + if (pipeFd <= 0) { + Log.e(TAG, "fail to open policy file"); + mMinijailSetup = false; + } else { + nativeSetupMinijail(pipeFd); + mMinijailSetup = true; + if (DEBUG) Log.d(TAG, "Minijail setup successfully"); + } + mMinijailSetupMonitor.notify(); + } + return null; + } + }.execute(); + } else { + synchronized (mMinijailSetupMonitor) { + mMinijailSetup = false; + mMinijailSetupMonitor.notify(); + } + } + super.onCreate(); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + private int getPolicyPipeFd() { + try { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor.AutoCloseOutputStream outputStream = + new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]); + final AssetFileDescriptor policyFile = getAssets().openFd("whitelist.policy"); + final byte[] buffer = new byte[2048]; + final FileInputStream policyStream = policyFile.createInputStream(); + while (true) { + int bytesRead = policyStream.read(buffer); + if (bytesRead == -1) break; + outputStream.write(buffer, 0, bytesRead); + } + policyStream.close(); + outputStream.close(); + return pipe[0].detachFd(); + } catch (IOException e) { + Log.e(TAG, "Policy file not found:" + e); + } + return -1; + } + + private final class FfmpegDecoder extends IFfmpegDecoder.Stub { + FfmpegAudioDecoder mDecoder; + @Override + public boolean isAvailable() { + return isMinijailSetupDone() && FfmpegAudioDecoder.isAvailable(); + } + + @Override + public void create() { + mDecoder = new FfmpegAudioDecoder(FfmpegDecoderService.this); + } + + @Override + public void release() { + if (mDecoder != null) { + mDecoder.release(); + mDecoder = null; + } + } + + @Override + public void decode(long timeUs, byte[] sample) { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we don't run decode for better security. + return; + } + mDecoder.decode(timeUs, sample); + } + + @Override + public void resetDecoderState(String mimetype) { + mDecoder.resetDecoderState(mimetype); + } + + @Override + public byte[] getDecodedSample() { + ByteBuffer decodedBuffer = mDecoder.getDecodedSample(); + byte[] ret = new byte[decodedBuffer.limit()]; + decodedBuffer.get(ret, 0, ret.length); + return ret; + } + + @Override + public long getDecodedTimeUs() { + return mDecoder.getDecodedTimeUs(); + } + + private boolean isMinijailSetupDone() { + synchronized (mMinijailSetupMonitor) { + if (DEBUG) Log.d(TAG, "mMinijailSetup in isAvailable(): " + mMinijailSetup); + if (mMinijailSetup == null) { + try { + if (DEBUG) Log.d(TAG, "Wait till Minijail setup is done"); + mMinijailSetupMonitor.wait(MINIJAIL_SETUP_WAIT_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return mMinijailSetup != null && mMinijailSetup; + } + } + + @Override + public void testSandboxIsolatedProcess() { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we return directly to make the test fail. + return; + } + if (FfmpegDecoderService.this.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED) { + throw new SecurityException("Shouldn't have the permission of internet"); + } + } + + @Override + public void testSandboxMinijail() { + if (!isMinijailSetupDone()) { + // If minijail is not setup, we return directly to make the test fail. + return; + } + nativeTestMinijail(); + } + } + + private native void nativeSetupMinijail(int policyFd); + private native void nativeTestMinijail(); +} diff --git a/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl b/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl new file mode 100644 index 00000000..ed053790 --- /dev/null +++ b/src/com/android/tv/tuner/exoplayer/ffmpeg/IFfmpegDecoder.aidl @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017 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.ffmpeg; + +interface IFfmpegDecoder { + boolean isAvailable(); + void create(); + void release(); + void resetDecoderState(String mimetype); + void decode(long timeUs, in byte[] sample); + byte[] getDecodedSample(); + long getDecodedTimeUs(); + void testSandboxIsolatedProcess(); + void testSandboxMinijail(); +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java index 97d9ece3..e0e21a20 100644 --- a/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java +++ b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java @@ -21,6 +21,7 @@ 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.BuildConfig; import com.android.tv.common.ui.setup.SetupGuidedStepFragment; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; import com.android.tv.tuner.R; @@ -36,6 +37,24 @@ public class ConnectionTypeFragment extends SetupMultiPaneFragment { "com.android.tv.tuner.setup.ConnectionTypeFragment"; @Override + public void onCreate(Bundle savedInstanceState) { + ((TunerSetupActivity) getActivity()).generateTunerHal(); + super.onCreate(savedInstanceState); + } + + @Override + public void onResume() { + ((TunerSetupActivity) getActivity()).generateTunerHal(); + super.onResume(); + } + + @Override + public void onDestroy() { + ((TunerSetupActivity) getActivity()).clearTunerHal(); + super.onDestroy(); + } + + @Override protected SetupGuidedStepFragment onCreateContentFragment() { return new ContentFragment(); } diff --git a/src/com/android/tv/tuner/setup/PostalCodeFragment.java b/src/com/android/tv/tuner/setup/PostalCodeFragment.java new file mode 100644 index 00000000..025b9193 --- /dev/null +++ b/src/com/android/tv/tuner/setup/PostalCodeFragment.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2017 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.support.v17.leanback.widget.GuidedActionsStylist; +import android.text.InputFilter; +import android.text.InputFilter.AllCaps; +import android.view.View; +import android.widget.TextView; +import com.android.tv.R; +import com.android.tv.common.ui.setup.SetupGuidedStepFragment; +import com.android.tv.common.ui.setup.SetupMultiPaneFragment; +import com.android.tv.tuner.util.PostalCodeUtils; +import com.android.tv.util.LocationUtils; +import java.util.List; + +/** + * A fragment for initial screen. + */ +public class PostalCodeFragment extends SetupMultiPaneFragment { + public static final String ACTION_CATEGORY = + "com.android.tv.tuner.setup.PostalCodeFragment"; + private static final int VIEW_TYPE_EDITABLE = 1; + + @Override + protected SetupGuidedStepFragment onCreateContentFragment() { + ContentFragment fragment = new ContentFragment(); + Bundle arguments = new Bundle(); + arguments.putBoolean(SetupGuidedStepFragment.KEY_THREE_PANE, true); + fragment.setArguments(arguments); + return fragment; + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + protected boolean needsDoneButton() { + return true; + } + + @Override + protected boolean needsSkipButton() { + return true; + } + + @Override + protected void setOnClickAction(View view, final String category, final int actionId) { + if (actionId == ACTION_DONE) { + view.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + CharSequence postalCode = + ((ContentFragment) getContentFragment()).mEditAction.getTitle(); + String region = LocationUtils.getCurrentCountry(getContext()); + if (postalCode != null && PostalCodeUtils.matches(postalCode, region)) { + PostalCodeUtils.setLastPostalCode( + getContext(), postalCode.toString()); + onActionClick(category, actionId); + } else { + ContentFragment contentFragment = + (ContentFragment) getContentFragment(); + contentFragment.mEditAction.setDescription( + getString(R.string.postal_code_invalid_warning)); + contentFragment.notifyActionChanged(0); + contentFragment.mEditedActionView.performClick(); + } + } + }); + } else if (actionId == ACTION_SKIP) { + super.setOnClickAction(view, category, ACTION_SKIP); + } + } + + public static class ContentFragment extends SetupGuidedStepFragment { + private GuidedAction mEditAction; + private View mEditedActionView; + private View mDoneActionView; + private boolean mProceed; + + @Override + public void onGuidedActionFocused(GuidedAction action) { + if (action.equals(mEditAction)) { + if (mProceed) { + // "NEXT" in IME was just clicked, moves focus to Done button. + if (mDoneActionView == null) { + mDoneActionView = getActivity().findViewById(R.id.button_done); + } + mDoneActionView.requestFocus(); + mProceed = false; + } else { + // Directly opens IME to input postal/zip code. + if (mEditedActionView == null) { + int maxLength = PostalCodeUtils.getRegionMaxLength(getContext()); + mEditedActionView = getView().findViewById(R.id.guidedactions_editable); + ((TextView) mEditedActionView.findViewById(R.id.guidedactions_item_title)) + .setFilters( + new InputFilter[] { + new InputFilter.LengthFilter(maxLength), new AllCaps() + }); + } + mEditedActionView.performClick(); + } + } + } + + @Override + public long onGuidedActionEditedAndProceed(GuidedAction action) { + mProceed = true; + return 0; + } + + @NonNull + @Override + public Guidance onCreateGuidance(Bundle savedInstanceState) { + String title = getString(R.string.postal_code_guidance_title); + String description = getString(R.string.postal_code_guidance_description); + String 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 description = getString(R.string.postal_code_action_description); + mEditAction = new GuidedAction.Builder(getActivity()).id(0).editable(true) + .description(description).build(); + actions.add(mEditAction); + } + + @Override + protected String getActionCategory() { + return ACTION_CATEGORY; + } + + @Override + public GuidedActionsStylist onCreateActionsStylist() { + return new GuidedActionsStylist() { + @Override + public int getItemViewType(GuidedAction action) { + if (action.isEditable()) { + return VIEW_TYPE_EDITABLE; + } + return super.getItemViewType(action); + } + + @Override + public int onProvideItemLayoutId(int viewType) { + if (viewType == VIEW_TYPE_EDITABLE) { + return R.layout.guided_action_editable; + } + return super.onProvideItemLayoutId(viewType); + } + }; + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/setup/ScanFragment.java b/src/com/android/tv/tuner/setup/ScanFragment.java index 3b61debb..b6936e38 100644 --- a/src/com/android/tv/tuner/setup/ScanFragment.java +++ b/src/com/android/tv/tuner/setup/ScanFragment.java @@ -21,6 +21,7 @@ import android.app.Activity; import android.app.ProgressDialog; import android.content.Context; import android.os.AsyncTask; +import android.os.Build; import android.os.Bundle; import android.os.ConditionVariable; import android.os.Handler; @@ -35,25 +36,21 @@ 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.TunerHal; import com.android.tv.tuner.TunerPreferences; -import com.android.tv.tuner.data.nano.Channel; import com.android.tv.tuner.data.PsipData; import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.data.nano.Channel; 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; @@ -67,6 +64,7 @@ import java.util.concurrent.TimeUnit; 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; @@ -98,6 +96,7 @@ public class ScanFragment extends SetupFragment { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreateView"); View view = super.onCreateView(inflater, container, savedInstanceState); mChannelDataManager = new ChannelDataManager(getActivity()); mChannelDataManager.checkDataVersion(getActivity()); @@ -120,13 +119,19 @@ public class ScanFragment extends SetupFragment { } }); Bundle args = getArguments(); + int tunerType = (args == null ? 0 : args.getInt(TunerSetupActivity.KEY_TUNER_TYPE, 0)); // 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); + switch (tunerType) { + case TunerHal.TUNER_TYPE_USB: + scanTitleView.setText(R.string.ut_channel_scan); + break; + case TunerHal.TUNER_TYPE_NETWORK: + scanTitleView.setText(R.string.nt_channel_scan); + break; + default: + scanTitleView.setText(R.string.bt_channel_scan); } return view; } @@ -147,12 +152,14 @@ public class ScanFragment extends SetupFragment { } @Override - public void onDetach() { + public void onPause() { + Log.d(TAG, "onPause"); if (mChannelScanTask != null) { // Ensure scan task will stop. + Log.w(TAG, "The activity went to the background. Stopping channel scan."); mChannelScanTask.stopScan(); } - super.onDetach(); + super.onPause(); } /** @@ -168,7 +175,9 @@ public class ScanFragment extends SetupFragment { new Handler().postDelayed(new Runnable() { @Override public void run() { - mChannelScanTask.showFinishingProgressDialog(); + if (mChannelScanTask != null) { + mChannelScanTask.showFinishingProgressDialog(); + } } }, SHOW_PROGRESS_DIALOG_DELAY_MS); @@ -255,13 +264,13 @@ public class ScanFragment extends SetupFragment { if (FAKE_MODE) { mScanTsStreamer = new FakeTsStreamer(this); } else { - TunerHal hal = TunerHal.createInstance(mActivity.getApplicationContext()); + TunerHal hal = ((TunerSetupActivity) mActivity).getTunerHal(); 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; + mFileTsStreamer = SCAN_LOCAL_STREAMS ? new FileTsStreamer(this, mActivity) : null; mConditionStopped = new ConditionVariable(); mChannelDataManager.setChannelScanListener(this, new Handler()); } @@ -316,10 +325,17 @@ public class ScanFragment extends SetupFragment { @Override protected void onProgressUpdate(Integer... values) { - mProgressBar.setProgress(values[0]); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mProgressBar.setProgress(values[0], true); + } else { + mProgressBar.setProgress(values[0]); + } } private void stopScan() { + if (mLatch != null) { + mLatch.countDown(); + } mConditionStopped.open(); } @@ -340,8 +356,8 @@ public class ScanFragment extends SetupFragment { Log.i(TAG, "Tuning to " + frequency + " " + modulation); TsStreamer streamer = getStreamer(scanChannel.type); - Assert.assertNotNull(streamer); - if (streamer.startStream(scanChannel)) { + SoftPreconditions.checkNotNull(streamer); + if (streamer != null && streamer.startStream(scanChannel)) { mLatch = new CountDownLatch(1); try { mLatch.await(CHANNEL_SCAN_PERIOD_MS, TimeUnit.MILLISECONDS); @@ -360,11 +376,7 @@ public class ScanFragment extends SetupFragment { if (mConditionStopped.block(-1)) { break; } - onProgressUpdate(MAX_PROGRESS * i++ / mScanChannelList.size()); - } - if (mScanTsStreamer instanceof TunerTsStreamer) { - AutoCloseableUtils.closeQuietly( - ((TunerTsStreamer) mScanTsStreamer).getTunerHal()); + publishProgress(MAX_PROGRESS * i++ / mScanChannelList.size()); } mChannelDataManager.notifyScanCompleted(); if (!mConditionStopped.block(-1)) { @@ -427,8 +439,8 @@ public class ScanFragment extends SetupFragment { // Playbacks with video-only stream have not been tested yet. // No video-only channel has been found. addChannel(channel); + mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); } - mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime); } public void showFinishingProgressDialog() { @@ -446,15 +458,21 @@ public class ScanFragment extends SetupFragment { mIsFinished = true; TunerPreferences.setScannedChannelCount(mActivity.getApplicationContext(), mChannelDataManager.getScannedChannelCount()); - // Cancel a previously shown recommendation card. - TunerSetupActivity.cancelRecommendationCard(mActivity.getApplicationContext()); + // Cancel a previously shown notification. + TunerSetupActivity.cancelNotification(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); + // If the fragment is not resumed, the next fragment (scan result page) can't be + // displayed. In that case, just close the activity. + if (isResumed()) { + onActionClick(ACTION_CATEGORY, mIsCanceled ? ACTION_CANCEL : ACTION_FINISH); + } else if (getActivity() != null) { + getActivity().finish(); + } mChannelScanTask = null; } } diff --git a/src/com/android/tv/tuner/setup/ScanResultFragment.java b/src/com/android/tv/tuner/setup/ScanResultFragment.java index 068543cd..3b8cd823 100644 --- a/src/com/android/tv/tuner/setup/ScanResultFragment.java +++ b/src/com/android/tv/tuner/setup/ScanResultFragment.java @@ -26,6 +26,7 @@ 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.TunerHal; import com.android.tv.tuner.TunerPreferences; import com.android.tv.tuner.util.TunerInputInfoUtils; @@ -76,11 +77,19 @@ public class ScanResultFragment extends SetupMultiPaneFragment { mChannelCountOnPreference, mChannelCountOnPreference); breadcrumb = null; } else { + Bundle args = getArguments(); + int tunerType = + (args == null ? 0 : args.getInt(TunerSetupActivity.KEY_TUNER_TYPE, 0)); 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); + switch (tunerType) { + case TunerHal.TUNER_TYPE_USB: + description = getString(R.string.ut_result_not_found_description); + break; + case TunerHal.TUNER_TYPE_NETWORK: + description = getString(R.string.nt_result_not_found_description); + break; + default: + description = getString(R.string.bt_result_not_found_description); } breadcrumb = getString(R.string.ut_setup_breadcrumb); } diff --git a/src/com/android/tv/tuner/setup/TunerSetupActivity.java b/src/com/android/tv/tuner/setup/TunerSetupActivity.java index 78121bc5..e9f3baa7 100644 --- a/src/com/android/tv/tuner/setup/TunerSetupActivity.java +++ b/src/com/android/tv/tuner/setup/TunerSetupActivity.java @@ -19,6 +19,7 @@ package com.android.tv.tuner.setup; import android.app.Fragment; import android.app.FragmentManager; import android.app.Notification; +import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ComponentName; @@ -29,49 +30,98 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.tv.TvContract; +import android.os.AsyncTask; +import android.os.Build; import android.os.Bundle; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; import android.support.v4.app.NotificationCompat; +import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; import android.widget.Toast; +import com.android.tv.Features; import com.android.tv.TvApplication; +import com.android.tv.common.AutoCloseableUtils; +import com.android.tv.common.SoftPreconditions; 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.experiments.Experiments; 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; +import com.android.tv.tuner.util.PostalCodeUtils; + +import java.util.concurrent.Executor; /** * 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 TAG = "TunerSetupActivity"; + private static final boolean DEBUG = false; + + /** + * Key for passing tuner type to sub-fragments. + */ + public static final String KEY_TUNER_TYPE = "TunerSetupActivity.tunerType"; + + // For the notification. private static final String TV_ACTIVITY_CLASS_NAME = "com.android.tv.TvActivity"; + private static final String TUNER_SET_UP_NOTIFICATION_CHANNEL_ID = "tuner_setup_channel"; 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 PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION = 1; 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}; + 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, + R.raw.ut_euro_dvbt_all, + R.raw.ut_euro_dvbt_all, + R.raw.ut_euro_dvbt_all + }; private ScanFragment mLastScanFragment; + private Integer mTunerType; + private TunerHalFactory mTunerHalFactory; + private boolean mNeedToShowPostalCodeFragment; + private String mPreviousPostalCode; @Override protected void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate"); + new AsyncTask<Void, Void, Integer>() { + @Override + protected Integer doInBackground(Void... arg0) { + return TunerHal.getTunerTypeAndCount(TunerSetupActivity.this).first; + } + + @Override + protected void onPostExecute(Integer result) { + if (!TunerSetupActivity.this.isDestroyed()) { + mTunerType = result; + if (result == null) { + finish(); + } else { + showInitialFragment(); + } + } + } + }.execute(); TvApplication.setCurrentRunningProcess(this, false); super.onCreate(savedInstanceState); // TODO: check {@link shouldShowRequestPermissionRationale}. @@ -79,16 +129,52 @@ public class TunerSetupActivity extends SetupActivity { != PackageManager.PERMISSION_GRANTED) { // No need to check the request result. requestPermissions(new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION}, - 0); + PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION); + } + mTunerHalFactory = new TunerHalFactory(getApplicationContext()); + try { + // Updating postal code takes time, therefore we called it here for "warm-up". + mPreviousPostalCode = PostalCodeUtils.getLastPostalCode(this); + PostalCodeUtils.setLastPostalCode(this, null); + PostalCodeUtils.updatePostalCode(this); + } catch (Exception e) { + // Do nothing. If the last known postal code is null, we'll show guided fragment to + // prompt users to input postal code before ConnectionTypeFragment is shown. + Log.i(TAG, "Can't get postal code:" + e); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED + && Experiments.CLOUD_EPG.get()) { + try { + // Updating postal code takes time, therefore we should update postal code + // right after the permission is granted, so that the subsequent operations, + // especially EPG fetcher, could get the newly updated postal code. + PostalCodeUtils.updatePostalCode(this); + } catch (Exception e) { + // Do nothing + } + } } } @Override protected Fragment onCreateInitialFragment() { - SetupFragment fragment = new WelcomeFragment(); - fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION - | SetupFragment.FRAGMENT_REENTER_TRANSITION); - return fragment; + if (mTunerType != null) { + SetupFragment fragment = new WelcomeFragment(); + Bundle args = new Bundle(); + args.putInt(KEY_TUNER_TYPE, mTunerType); + fragment.setArguments(args); + fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION + | SetupFragment.FRAGMENT_REENTER_TRANSITION); + return fragment; + } else { + return null; + } } @Override @@ -101,34 +187,42 @@ public class TunerSetupActivity extends SetupActivity { 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); + default: + if (mNeedToShowPostalCodeFragment + || Features.ENABLE_CLOUD_EPG_REGION.isEnabled( + getApplicationContext()) + && TextUtils.isEmpty( + PostalCodeUtils.getLastPostalCode(this))) { + // We cannot get postal code automatically. Postal code input fragment + // should always be shown even if users have input some valid postal + // code in this activity before. + mNeedToShowPostalCodeFragment = true; + showPostalCodeFragment(); + } else { + showConnectionTypeFragment(); + } break; - } + } + return true; + case PostalCodeFragment.ACTION_CATEGORY: + if (actionId == SetupMultiPaneFragment.ACTION_DONE + || actionId == SetupMultiPaneFragment.ACTION_SKIP) { + showConnectionTypeFragment(); } return true; case ConnectionTypeFragment.ACTION_CATEGORY: - TunerHal hal = TunerHal.createInstance(getApplicationContext()); - if (hal == null) { + if (mTunerHalFactory.getOrCreate() == 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, + Bundle args1 = new Bundle(); + args1.putInt(ScanFragment.EXTRA_FOR_CHANNEL_SCAN_FILE, CHANNEL_MAP_SCAN_FILE[actionId]); - mLastScanFragment.setArguments(args); + args1.putInt(KEY_TUNER_TYPE, mTunerType); + mLastScanFragment.setArguments(args1); showFragment(mLastScanFragment, true); return true; case ScanFragment.ACTION_CATEGORY: @@ -137,7 +231,11 @@ public class TunerSetupActivity extends SetupActivity { getFragmentManager().popBackStack(); return true; case ScanFragment.ACTION_FINISH: + mTunerHalFactory.clear(); SetupFragment fragment = new ScanResultFragment(); + Bundle args2 = new Bundle(); + args2.putInt(KEY_TUNER_TYPE, mTunerType); + fragment.setArguments(args2); fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION | SetupFragment.FRAGMENT_REENTER_TRANSITION); showFragment(fragment, true); @@ -183,6 +281,14 @@ public class TunerSetupActivity extends SetupActivity { return super.onKeyUp(keyCode, event); } + @Override + public void onDestroy() { + if (mPreviousPostalCode != null && PostalCodeUtils.getLastPostalCode(this) == null) { + PostalCodeUtils.setLastPostalCode(this, mPreviousPostalCode); + } + super.onDestroy(); + } + /** * A callback to be invoked when the TvInputService is enabled or disabled. * @@ -190,17 +296,17 @@ public class TunerSetupActivity extends SetupActivity { * @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 + public static void onTvInputEnabled(Context context, boolean enabled, Integer tunerType) { + // Send a notification 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); + sendNotification(context, tunerType); } else { TunerPreferences.setShouldShowSetupActivity(context, false); - cancelRecommendationCard(context); + cancelNotification(context); } } @@ -213,7 +319,7 @@ public class TunerSetupActivity extends SetupActivity { String inputId = TvContract.buildInputId(new ComponentName(context.getPackageName(), TunerTvInputService.class.getName())); - // Make an intent to launch the setup activity of USB tuner TV input. + // Make an intent to launch the setup activity of TV tuner input. Intent intent = TvCommonUtils.createSetupIntent( new Intent(context, TunerSetupActivity.class), inputId); intent.putExtra(TvCommonConstants.EXTRA_INPUT_ID, inputId); @@ -224,6 +330,27 @@ public class TunerSetupActivity extends SetupActivity { } /** + * Gets the currently used tuner HAL. + */ + TunerHal getTunerHal() { + return mTunerHalFactory.getOrCreate(); + } + + /** + * Generates tuner HAL. + */ + void generateTunerHal() { + mTunerHalFactory.generate(); + } + + /** + * Clears the currently used tuner HAL. + */ + void clearTunerHal() { + mTunerHalFactory.clear(); + } + + /** * Returns a {@link PendingIntent} to launch the tuner TV input service. * * @param context a {@link Context} instance @@ -233,34 +360,53 @@ public class TunerSetupActivity extends SetupActivity { PendingIntent.FLAG_UPDATE_CURRENT); } + private static void sendNotification(Context context, Integer tunerType) { + SoftPreconditions.checkState(tunerType != null, TAG, + "tunerType is null when send notification"); + if (tunerType == null) { + return; + } + Resources resources = context.getResources(); + String contentTitle = resources.getString(R.string.ut_setup_notification_content_title); + int contentTextId = 0; + switch (tunerType) { + case TunerHal.TUNER_TYPE_BUILT_IN: + contentTextId = R.string.bt_setup_notification_content_text; + break; + case TunerHal.TUNER_TYPE_USB: + contentTextId = R.string.ut_setup_notification_content_text; + break; + case TunerHal.TUNER_TYPE_NETWORK: + contentTextId = R.string.nt_setup_notification_content_text; + break; + } + String contentText = resources.getString(contentTextId); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + sendNotificationInternal(context, contentTitle, contentText); + } else { + Bitmap largeIcon = BitmapFactory.decodeResource(resources, + R.drawable.recommendation_antenna); + sendRecommendationCard(context, contentTitle, contentText, largeIcon); + } + } + /** * 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); - + private static void sendRecommendationCard(Context context, String contentTitle, + String contentText, Bitmap largeIcon) { // Build and send the notification. Notification notification = new NotificationCompat.BigPictureStyle( new NotificationCompat.Builder(context) .setAutoCancel(false) - .setContentTitle(focusedTitle) - .setContentText(title) - .setContentInfo(title) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentInfo(contentText) .setCategory(Notification.CATEGORY_RECOMMENDATION) .setLargeIcon(largeIcon) - .setSmallIcon(resources.getIdentifier( + .setSmallIcon(context.getResources().getIdentifier( TAG_ICON, TAG_DRAWABLE, context.getPackageName())) .setContentIntent(createPendingIntentForSetupActivity(context))) .build(); @@ -269,14 +415,129 @@ public class TunerSetupActivity extends SetupActivity { notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification); } + private static void sendNotificationInternal(Context context, String contentTitle, + String contentText) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService( + Context.NOTIFICATION_SERVICE); + notificationManager.createNotificationChannel(new NotificationChannel( + TUNER_SET_UP_NOTIFICATION_CHANNEL_ID, + context.getResources().getString(R.string.ut_setup_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH)); + Notification notification = new Notification.Builder( + context, TUNER_SET_UP_NOTIFICATION_CHANNEL_ID) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setSmallIcon(context.getResources().getIdentifier( + TAG_ICON, TAG_DRAWABLE, context.getPackageName())) + .setContentIntent(createPendingIntentForSetupActivity(context)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .extend(new Notification.TvExtender()) + .build(); + notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification); + } + + private void showPostalCodeFragment() { + SetupFragment fragment = new PostalCodeFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION + | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + } + + private void showConnectionTypeFragment() { + SetupFragment fragment = new ConnectionTypeFragment(); + fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION + | SetupFragment.FRAGMENT_RETURN_TRANSITION); + showFragment(fragment, true); + } + /** - * Cancels the previously shown recommendation card. + * Cancels the previously shown notification. * * @param context a {@link Context} instance */ - public static void cancelRecommendationCard(Context context) { + public static void cancelNotification(Context context) { NotificationManager notificationManager = (NotificationManager) context .getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancel(NOTIFY_TAG, NOTIFY_ID); } -} + + @VisibleForTesting + static class TunerHalFactory { + private Context mContext; + @VisibleForTesting + TunerHal mTunerHal; + private GenerateTunerHalTask mGenerateTunerHalTask; + private final Executor mExecutor; + + TunerHalFactory(Context context) { + this(context, AsyncTask.SERIAL_EXECUTOR); + } + + TunerHalFactory(Context context, Executor executor) { + mContext = context; + mExecutor = executor; + } + + /** + * Returns tuner HAL currently used. If it's {@code null} and tuner HAL is not generated + * before, tries to generate it synchronously. + */ + @WorkerThread + TunerHal getOrCreate() { + if (mGenerateTunerHalTask != null + && mGenerateTunerHalTask.getStatus() != AsyncTask.Status.FINISHED) { + try { + return mGenerateTunerHalTask.get(); + } catch (Exception e) { + Log.e(TAG, "Cannot get Tuner HAL: " + e); + } + } else if (mGenerateTunerHalTask == null && mTunerHal == null) { + mTunerHal = createInstance(); + } + return mTunerHal; + } + + /** + * Generates tuner hal for scanning with asynchronous tasks. + */ + @MainThread + void generate() { + if (mGenerateTunerHalTask == null && mTunerHal == null) { + mGenerateTunerHalTask = new GenerateTunerHalTask(); + mGenerateTunerHalTask.executeOnExecutor(mExecutor); + } + } + + /** + * Clears the currently used tuner hal. + */ + @MainThread + void clear() { + if (mGenerateTunerHalTask != null) { + mGenerateTunerHalTask.cancel(true); + mGenerateTunerHalTask = null; + } + if (mTunerHal != null) { + AutoCloseableUtils.closeQuietly(mTunerHal); + mTunerHal = null; + } + } + + @WorkerThread + protected TunerHal createInstance() { + return TunerHal.createInstance(mContext); + } + + class GenerateTunerHalTask extends AsyncTask<Void, Void, TunerHal> { + @Override + protected TunerHal doInBackground(Void... args) { + return createInstance(); + } + + @Override + protected void onPostExecute(TunerHal tunerHal) { + mTunerHal = tunerHal; + } + } + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/setup/WelcomeFragment.java b/src/com/android/tv/tuner/setup/WelcomeFragment.java index 7e809411..feae1ec9 100644 --- a/src/com/android/tv/tuner/setup/WelcomeFragment.java +++ b/src/com/android/tv/tuner/setup/WelcomeFragment.java @@ -18,18 +18,14 @@ package com.android.tv.tuner.setup; 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.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.TunerHal; import com.android.tv.tuner.TunerPreferences; -import com.android.tv.tuner.util.TunerInputInfoUtils; - import java.util.List; /** @@ -41,7 +37,9 @@ public class WelcomeFragment extends SetupMultiPaneFragment { @Override protected SetupGuidedStepFragment onCreateContentFragment() { - return new ContentFragment(); + ContentFragment fragment = new ContentFragment(); + fragment.setArguments(getArguments()); + return fragment; } @Override @@ -58,11 +56,10 @@ public class WelcomeFragment extends SetupMultiPaneFragment { private int mChannelCountOnPreference; @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - mChannelCountOnPreference = TunerPreferences - .getScannedChannelCount(getActivity().getApplicationContext()); - return super.onCreateView(inflater, container, savedInstanceState); + public void onCreate(@Nullable Bundle savedInstanceState) { + mChannelCountOnPreference = + TunerPreferences.getScannedChannelCount(getActivity().getApplicationContext()); + super.onCreate(savedInstanceState); } @NonNull @@ -70,20 +67,33 @@ public class WelcomeFragment extends SetupMultiPaneFragment { public Guidance onCreateGuidance(Bundle savedInstanceState) { String title; String description; + int tunerType = getArguments().getInt(TunerSetupActivity.KEY_TUNER_TYPE, + TunerHal.TUNER_TYPE_BUILT_IN); 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); + switch (tunerType) { + case TunerHal.TUNER_TYPE_USB: + title = getString(R.string.ut_setup_new_title); + description = getString(R.string.ut_setup_new_description); + break; + case TunerHal.TUNER_TYPE_NETWORK: + title = getString(R.string.nt_setup_new_title); + description = getString(R.string.nt_setup_new_description); + break; + default: + title = getString(R.string.bt_setup_new_title); + description = getString(R.string.bt_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); + switch (tunerType) { + case TunerHal.TUNER_TYPE_USB: + description = getString(R.string.ut_setup_again_description); + break; + case TunerHal.TUNER_TYPE_NETWORK: + description = getString(R.string.nt_setup_again_description); + break; + default: + description = getString(R.string.bt_setup_again_description); } } return new Guidance(title, description, null, null); diff --git a/src/com/android/tv/tuner/source/FileTsStreamer.java b/src/com/android/tv/tuner/source/FileTsStreamer.java index 14997ee4..f17dd46b 100644 --- a/src/com/android/tv/tuner/source/FileTsStreamer.java +++ b/src/com/android/tv/tuner/source/FileTsStreamer.java @@ -16,12 +16,14 @@ package com.android.tv.tuner.source; +import android.content.Context; 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.Features; import com.android.tv.common.SoftPreconditions; import com.android.tv.tuner.ChannelScanFileParser.ScanChannel; import com.android.tv.tuner.data.TunerChannel; @@ -60,6 +62,7 @@ public class FileTsStreamer implements TsStreamer { private final Object mCircularBufferMonitor = new Object(); private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE]; private final FileSourceEventDetector mEventDetector; + private final Context mContext; private long mBytesFetched; private long mLastReadPosition; @@ -120,8 +123,11 @@ public class FileTsStreamer implements TsStreamer { * 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); + public FileTsStreamer(EventDetector.EventListener eventListener, Context context) { + mEventDetector = + new FileSourceEventDetector( + eventListener, Features.ENABLE_FILE_DVB.isEnabled(context)); + mContext = context; } @Override @@ -132,8 +138,12 @@ public class FileTsStreamer implements TsStreamer { return false; } mEventDetector.start(mSource, FileSourceEventDetector.ALL_PROGRAM_NUMBERS); - mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); mSource.addPidFilter(TsParser.PAT_PID); + mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); + if (Features.ENABLE_FILE_DVB.isEnabled(mContext)) { + mSource.addPidFilter(TsParser.DVB_EIT_PID); + mSource.addPidFilter(TsParser.DVB_SDT_PID); + } synchronized (mCircularBufferMonitor) { if (mStreaming) { return true; @@ -160,8 +170,12 @@ public class FileTsStreamer implements TsStreamer { mSource.addPidFilter(i); } mSource.addPidFilter(channel.getPcrPid()); - mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); mSource.addPidFilter(TsParser.PAT_PID); + mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID); + if (Features.ENABLE_FILE_DVB.isEnabled(mContext)) { + mSource.addPidFilter(TsParser.DVB_EIT_PID); + mSource.addPidFilter(TsParser.DVB_SDT_PID); + } synchronized (mCircularBufferMonitor) { if (mStreaming) { return true; @@ -256,7 +270,7 @@ public class FileTsStreamer implements TsStreamer { * Returns whether the current pid filter is empty or not. */ public boolean isFilterEmpty() { - return mPids.size() > 0; + return mPids.size() == 0; } /** diff --git a/src/com/android/tv/tuner/source/TsDataSourceManager.java b/src/com/android/tv/tuner/source/TsDataSourceManager.java index ccbb75ba..16be7582 100644 --- a/src/com/android/tv/tuner/source/TsDataSourceManager.java +++ b/src/com/android/tv/tuner/source/TsDataSourceManager.java @@ -17,9 +17,11 @@ package com.android.tv.tuner.source; import android.content.Context; +import android.support.annotation.VisibleForTesting; -import com.android.tv.tuner.data.nano.Channel; +import com.android.tv.tuner.TunerHal; import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.data.nano.Channel; import com.android.tv.tuner.tvinput.EventDetector; import java.util.Map; @@ -31,8 +33,6 @@ import java.util.concurrent.ConcurrentHashMap; * 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<>(); @@ -80,7 +80,7 @@ public class TsDataSourceManager { if (mIsRecording) { return null; } - FileTsStreamer streamer = new FileTsStreamer(eventListener); + FileTsStreamer streamer = new FileTsStreamer(eventListener, context); if (streamer.startStream(channel)) { TsDataSource source = streamer.createDataSource(); sTsStreamers.put(source, streamer); @@ -127,6 +127,14 @@ public class TsDataSourceManager { } /** + * Add tuner hal into TunerTsStreamerManager for test. + */ + @VisibleForTesting + public void addTunerHalForTest(TunerHal tunerHal) { + mTunerStreamerManager.addTunerHal(tunerHal, mId); + } + + /** * Releases persistent resources. */ public void release() { diff --git a/src/com/android/tv/tuner/source/TunerTsStreamer.java b/src/com/android/tv/tuner/source/TunerTsStreamer.java index b24048e6..843cbdb7 100644 --- a/src/com/android/tv/tuner/source/TunerTsStreamer.java +++ b/src/com/android/tv/tuner/source/TunerTsStreamer.java @@ -18,6 +18,7 @@ package com.android.tv.tuner.source; import android.content.Context; import android.util.Log; +import android.util.Pair; import com.google.android.exoplayer.C; import com.google.android.exoplayer.upstream.DataSpec; @@ -30,6 +31,7 @@ import com.android.tv.tuner.tvinput.EventDetector; import com.android.tv.tuner.tvinput.EventDetector.EventListener; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicLong; @@ -42,23 +44,27 @@ public class TunerTsStreamer implements TsStreamer { 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 TS_PACKET_SIZE = 188; private static final int READ_TIMEOUT_MS = 5000; // 5 secs. private static final int BUFFER_UNDERRUN_SLEEP_MS = 10; + private static final int READ_ERROR_STREAMING_ENDED = -1; + private static final int READ_ERROR_BUFFER_OVERWRITTEN = -2; 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 List<Pair<EventListener, Boolean>> mEventListenerActions = new ArrayList<>(); private final TsStreamWriter mTsStreamWriter; + private String mChannelNumber; public static class TunerDataSource extends TsDataSource { private final TunerTsStreamer mTsStreamer; @@ -103,6 +109,15 @@ public class TunerTsStreamer implements TsStreamer { offset, readLength); if (ret > 0) { mLastReadPosition.addAndGet(ret); + } else if (ret == READ_ERROR_BUFFER_OVERWRITTEN) { + long currentPosition = mStartBufferedPosition + mLastReadPosition.get(); + long endPosition = mTsStreamer.getBufferedPosition(); + long diff = ((endPosition - currentPosition + TS_PACKET_SIZE - 1) / TS_PACKET_SIZE) + * TS_PACKET_SIZE; + Log.w(TAG, "Demux position jump by overwritten buffer: " + diff); + mStartBufferedPosition = currentPosition + diff; + mLastReadPosition.set(0); + return 0; } return ret; } @@ -114,7 +129,10 @@ public class TunerTsStreamer implements TsStreamer { */ public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener, Context context) { mTunerHal = tunerHal; - mEventDetector = new EventDetector(mTunerHal, eventListener); + mEventDetector = new EventDetector(mTunerHal); + if (eventListener != null) { + mEventDetector.registerListener(eventListener); + } mTsStreamWriter = context != null && TunerPreferences.getStoreTsStream(context) ? new TsStreamWriter(context) : null; } @@ -125,7 +143,8 @@ public class TunerTsStreamer implements TsStreamer { @Override public boolean startStream(TunerChannel channel) { - if (mTunerHal.tune(channel.getFrequency(), channel.getModulation())) { + if (mTunerHal.tune(channel.getFrequency(), channel.getModulation(), + channel.getDisplayNumber(false))) { if (channel.hasVideo()) { mTunerHal.addPidFilter(channel.getVideoPid(), TunerHal.FILTER_TYPE_VIDEO); @@ -148,6 +167,7 @@ public class TunerTsStreamer implements TsStreamer { channel.getProgramNumber()); } mChannel = channel; + mChannelNumber = channel.getDisplayNumber(); synchronized (mCircularBufferMonitor) { if (mStreaming) { Log.w(TAG, "Streaming should be stopped before start streaming"); @@ -156,7 +176,6 @@ public class TunerTsStreamer implements TsStreamer { mStreaming = true; mBytesFetched = 0; mLastReadPosition.set(0L); - mEndOfStreamSent = false; } if (mTsStreamWriter != null) { mTsStreamWriter.setChannel(mChannel); @@ -172,7 +191,7 @@ public class TunerTsStreamer implements TsStreamer { @Override public boolean startStream(ChannelScanFileParser.ScanChannel channel) { - if (mTunerHal.tune(channel.frequency, channel.modulation)) { + if (mTunerHal.tune(channel.frequency, channel.modulation, null)) { mEventDetector.startDetecting( channel.frequency, channel.modulation, EventDetector.ALL_PROGRAM_NUMBERS); synchronized (mCircularBufferMonitor) { @@ -183,7 +202,6 @@ public class TunerTsStreamer implements TsStreamer { mStreaming = true; mBytesFetched = 0; mLastReadPosition.set(0L); - mEndOfStreamSent = false; } mStreamingThread = new StreamingThread(); mStreamingThread.start(); @@ -258,6 +276,26 @@ public class TunerTsStreamer implements TsStreamer { } } + public String getStreamerInfo() { + return "Channel: " + mChannelNumber + ", Streaming: " + mStreaming; + } + + public void registerListener(EventListener listener) { + if (mEventDetector != null && listener != null) { + synchronized (mEventListenerActions) { + mEventListenerActions.add(new Pair<>(listener, true)); + } + } + } + + public void unregisterListener(EventListener listener) { + if (mEventDetector != null) { + synchronized (mEventListenerActions) { + mEventListenerActions.add(new Pair(listener, false)); + } + } + } + private class StreamingThread extends Thread { @Override public void run() { @@ -271,6 +309,20 @@ public class TunerTsStreamer implements TsStreamer { } } + if (mEventDetector != null) { + synchronized (mEventListenerActions) { + for (Pair listenerAction : mEventListenerActions) { + EventListener listener = (EventListener) listenerAction.first; + if ((boolean) listenerAction.second) { + mEventDetector.registerListener(listener); + } else { + mEventDetector.unregisterListener(listener); + } + } + mEventListenerActions.clear(); + } + } + int bytesWritten = mTunerHal.readTsStream(dataBuffer, dataBuffer.length); if (bytesWritten <= 0) { try { @@ -321,21 +373,14 @@ public class TunerTsStreamer implements TsStreamer { * @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 (!mStreaming) { + return READ_ERROR_STREAMING_ENDED; } 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; + Log.w(TAG, "Demux is requesting the data which is already overwritten."); + return READ_ERROR_BUFFER_OVERWRITTEN; } if (mBytesFetched < pos + amount) { try { diff --git a/src/com/android/tv/tuner/source/TunerTsStreamerManager.java b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java index cf1f6dcf..258a4d86 100644 --- a/src/com/android/tv/tuner/source/TunerTsStreamerManager.java +++ b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java @@ -42,6 +42,7 @@ class TunerTsStreamerManager { private final Object mCancelLock = new Object(); private final StreamerFinder mStreamerFinder = new StreamerFinder(); private final Map<Integer, TsStreamerCreator> mCreators = new HashMap<>(); + private final Map<Integer, EventDetector.EventListener> mListeners = new HashMap<>(); private final Map<TsDataSource, TunerTsStreamer> mSourceToStreamerMap = new HashMap<>(); private final TunerHalManager mTunerHalManager = new TunerHalManager(); private static TunerTsStreamerManager sInstance; @@ -68,6 +69,8 @@ class TunerTsStreamerManager { mStreamerFinder.appendSessionLocked(channel, sessionId); TunerTsStreamer streamer = mStreamerFinder.getStreamerLocked(channel); TsDataSource source = streamer.createDataSource(); + mListeners.put(sessionId, listener); + streamer.registerListener(listener); mSourceToStreamerMap.put(source, streamer); return source; } @@ -83,6 +86,7 @@ class TunerTsStreamerManager { if (!creator.isCancelledLocked()) { mStreamerFinder.putLocked(channel, sessionId, streamer); TsDataSource source = streamer.createDataSource(); + mListeners.put(sessionId, listener); mSourceToStreamerMap.put(source, streamer); return source; } @@ -104,6 +108,8 @@ class TunerTsStreamerManager { if (streamer == null) { return; } + EventDetector.EventListener listener = mListeners.remove(sessionId); + streamer.unregisterListener(listener); TunerChannel channel = streamer.getChannel(); SoftPreconditions.checkState(channel != null); mStreamerFinder.removeSessionLocked(channel, sessionId); @@ -125,6 +131,13 @@ class TunerTsStreamerManager { } } + /** + * Add tuner hal into TunerHalManager for test. + */ + void addTunerHal(TunerHal tunerHal, int sessionId) { + mTunerHalManager.addTunerHal(tunerHal, sessionId); + } + synchronized void release(int sessionId) { mTunerHalManager.releaseCachedHal(sessionId); } @@ -261,16 +274,16 @@ class TunerTsStreamerManager { } private void releaseTunerHal(TunerHal hal, int sessionId, boolean reuse) { - if (!reuse) { + if (!reuse || !hal.isReusable()) { AutoCloseableUtils.closeQuietly(hal); return; } TunerHal cachedHal = mTunerHals.get(sessionId); if (cachedHal != hal) { mTunerHals.put(sessionId, hal); - } - if (cachedHal != null && cachedHal != hal) { - AutoCloseableUtils.closeQuietly(cachedHal); + if (cachedHal != null) { + AutoCloseableUtils.closeQuietly(cachedHal); + } } } @@ -283,5 +296,9 @@ class TunerTsStreamerManager { AutoCloseableUtils.closeQuietly(hal); } } + + private void addTunerHal(TunerHal tunerHal, int sessionId) { + mTunerHals.put(sessionId, tunerHal); + } } }
\ 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 index 8c1f6a1b..e1f890f3 100644 --- a/src/com/android/tv/tuner/ts/SectionParser.java +++ b/src/com/android/tv/tuner/ts/SectionParser.java @@ -18,11 +18,12 @@ package com.android.tv.tuner.ts; import android.media.tv.TvContentRating; import android.media.tv.TvContract.Programs.Genres; +import android.support.annotation.VisibleForTesting; import android.text.TextUtils; +import android.util.ArraySet; import android.util.Log; import android.util.SparseArray; -import com.android.tv.tuner.data.nano.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; @@ -34,24 +35,32 @@ 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.ParentalRatingDescriptor; 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.SdtItem; +import com.android.tv.tuner.data.PsipData.ServiceDescriptor; +import com.android.tv.tuner.data.PsipData.ShortEventDescriptor; import com.android.tv.tuner.data.PsipData.TsDescriptor; import com.android.tv.tuner.data.PsipData.VctItem; +import com.android.tv.tuner.data.nano.Channel; import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import com.android.tv.tuner.util.ByteArrayBuffer; +import com.android.tv.tuner.util.ConvertUtils; import com.ibm.icu.text.UnicodeDecompressor; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; +import java.util.Calendar; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Set; /** * Parses ATSC PSIP sections. @@ -68,6 +77,13 @@ public class SectionParser { private static final byte TABLE_ID_EIT = (byte) 0xcb; private static final byte TABLE_ID_ETT = (byte) 0xcc; + // Table id for DVB + private static final byte TABLE_ID_SDT = (byte) 0x42; + private static final byte TABLE_ID_DVB_ACTUAL_P_F_EIT = (byte) 0x4e; + private static final byte TABLE_ID_DVB_OTHER_P_F_EIT = (byte) 0x4f; + private static final byte TABLE_ID_DVB_ACTUAL_SCHEDULE_EIT = (byte) 0x50; + private static final byte TABLE_ID_DVB_OTHER_SCHEDULE_EIT = (byte) 0x60; + // 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; @@ -76,6 +92,12 @@ public class SectionParser { public static final int DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME = 0xa0; public static final int DESCRIPTOR_TAG_GENRE = 0xab; + // For details of the structure for the tags of DVB descriptors, see DVB Document A038 Table 12. + public static final int DVB_DESCRIPTOR_TAG_SERVICE = 0x48; + public static final int DVB_DESCRIPTOR_TAG_SHORT_EVENT = 0X4d; + public static final int DVB_DESCRIPTOR_TAG_CONTENT = 0x54; + public static final int DVB_DESCRIPTOR_TAG_PARENTAL_RATING = 0x55; + 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; @@ -88,17 +110,57 @@ public class SectionParser { // 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_DOMAIN = "com.android.tv"; private static final String RATING_REGION_RATING_SYSTEM_US_TV = "US_TV"; + private static final String RATING_REGION_RATING_SYSTEM_US_MV = "US_MV"; 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_US_MV = { + "US_MV_G", "US_MV_PG", "US_MV_PG13", "US_MV_R", "US_MV_NC17" + }; + private static final String[] RATING_REGION_TABLE_KR_TV = { "KR_TV_ALL", "KR_TV_7", "KR_TV_12", "KR_TV_15", "KR_TV_19" }; + private static final String[] RATING_REGION_TABLE_US_TV_SUBRATING = { + "US_TV_D", "US_TV_L", "US_TV_S", "US_TV_V", "US_TV_FV" + }; + + // According to ANSI-CEA-766-D + private static final int VALUE_US_TV_Y = 1; + private static final int VALUE_US_TV_Y7 = 2; + private static final int VALUE_US_TV_NONE = 1; + private static final int VALUE_US_TV_G = 2; + private static final int VALUE_US_TV_PG = 3; + private static final int VALUE_US_TV_14 = 4; + private static final int VALUE_US_TV_MA = 5; + + private static final int DIMENSION_US_TV_RATING = 0; + private static final int DIMENSION_US_TV_D = 1; + private static final int DIMENSION_US_TV_L = 2; + private static final int DIMENSION_US_TV_S = 3; + private static final int DIMENSION_US_TV_V = 4; + private static final int DIMENSION_US_TV_Y = 5; + private static final int DIMENSION_US_TV_FV = 6; + private static final int DIMENSION_US_MV_RATING = 7; + + private static final int VALUE_US_MV_G = 2; + private static final int VALUE_US_MV_PG = 3; + private static final int VALUE_US_MV_PG13 = 4; + private static final int VALUE_US_MV_R = 5; + private static final int VALUE_US_MV_NC17 = 6; + private static final int VALUE_US_MV_X = 7; + + private static final String STRING_US_TV_Y = "US_TV_Y"; + private static final String STRING_US_TV_Y7 = "US_TV_Y7"; + private static final String STRING_US_TV_FV = "US_TV_FV"; + + /* * 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 @@ -330,6 +392,7 @@ public class SectionParser { void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber); void onEitParsed(int sourceId, List<EitItem> items); void onEttParsed(int sourceId, List<EttItem> descriptions); + void onSdtParsed(List<SdtItem> items); } private final OutputListener mListener; @@ -367,6 +430,10 @@ public class SectionParser { mParsedEttItems.clear(); } + public void resetVersionNumbers() { + mSectionVersionMap.clear(); + } + private void parseSection(byte[] data) { if (!checkSanity(data)) { Log.d(TAG, "Bad CRC!"); @@ -410,6 +477,13 @@ public class SectionParser { case TABLE_ID_ETT: result = parseETT(data); break; + case TABLE_ID_SDT: + result = parseSDT(data); + break; + case TABLE_ID_DVB_ACTUAL_P_F_EIT: + case TABLE_ID_DVB_ACTUAL_SCHEDULE_EIT: + result = parseDVBEIT(data); + break; default: break; } @@ -510,10 +584,8 @@ public class SectionParser { pos += 11 + descriptorsLength; results.add(new MgtItem(tableType, tableTypePid)); } - if ((data[pos] & 0xf0) != 0xf0) { - Log.e(TAG, "Broken MGT."); - return false; - } + // Skip the remaining descriptor part which we don't use. + if (mListener != null) { mListener.onMgtParsed(results); } @@ -704,6 +776,127 @@ public class SectionParser { return true; } + private boolean parseSDT(byte[] data) { + // For details of the structure for SDT, see DVB Document A038 Table 5. + if (DEBUG) { + Log.d(TAG, "SDT id discovered"); + } + if (data.length <= 11) { + Log.e(TAG, "Broken SDT."); + return false; + } + if ((data[1] & 0x80) >> 7 != 1) { + Log.e(TAG, "Broken SDT, section syntax indicator error."); + return false; + } + int sectionLength = ((data[1] & 0x0f) << 8) | (data[2] & 0xff); + int transportStreamId = ((data[3] & 0xff) << 8) | (data[4] & 0xff); + int originalNetworkId = ((data[8] & 0xff) << 8) | (data[9] & 0xff); + int pos = 11; + if (sectionLength + 3 > data.length) { + Log.e(TAG, "Broken SDT."); + } + List<SdtItem> sdtItems = new ArrayList<>(); + while (pos + 9 < data.length) { + int serviceId = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff); + int descriptorsLength = ((data[pos + 3] & 0x0f) << 8) | (data[pos + 4] & 0xff); + pos += 5; + List<TsDescriptor> descriptors = parseDescriptors(data, pos, pos + descriptorsLength); + List<ServiceDescriptor> serviceDescriptors = generateServiceDescriptors(descriptors); + String serviceName = ""; + String serviceProviderName = ""; + int serviceType = 0; + for (ServiceDescriptor serviceDescriptor : serviceDescriptors) { + serviceName = serviceDescriptor.getServiceName(); + serviceProviderName = serviceDescriptor.getServiceProviderName(); + serviceType = serviceDescriptor.getServiceType(); + } + if (serviceDescriptors.size() > 0) { + sdtItems.add(new SdtItem(serviceName, serviceProviderName, serviceType, serviceId, + originalNetworkId)); + } + pos += descriptorsLength; + } + if (mListener != null) { + mListener.onSdtParsed(sdtItems); + } + return true; + } + + private boolean parseDVBEIT(byte[] data) { + // For details of the structure for DVB ETT, see DVB Document A038 Table 7. + if (DEBUG) { + Log.d(TAG, "DVB EIT is discovered."); + } + if (data.length < 18) { + Log.e(TAG, "Broken DVB EIT."); + return false; + } + int sectionLength = ((data[1] & 0x0f) << 8) | (data[2] & 0xff); + int sourceId = ((data[3] & 0xff) << 8) | (data[4] & 0xff); + int transportStreamId = ((data[8] & 0xff) << 8) | (data[9] & 0xff); + int originalNetworkId = ((data[10] & 0xff) << 8) | (data[11] & 0xff); + + int pos = 14; + List<EitItem> results = new ArrayList<>(); + while (pos + 12 < data.length) { + int eventId = ((data[pos] & 0xff) << 8) + (data[pos + 1] & 0xff); + float modifiedJulianDate = ((data[pos + 2] & 0xff) << 8) | (data[pos + 3] & 0xff); + int startYear = (int) ((modifiedJulianDate - 15078.2f) / 365.25f); + int mjdMonth = (int) ((modifiedJulianDate - 14956.1f + - (int) (startYear * 365.25f)) / 30.6001f); + int startDay = (int) modifiedJulianDate - 14956 - (int) (startYear * 365.25f) + - (int) (mjdMonth * 30.6001f); + int startMonth = mjdMonth - 1; + if (mjdMonth == 14 || mjdMonth == 15) { + startYear += 1; + startMonth -= 12; + } + int startHour = ((data[pos + 4] & 0xf0) >> 4) * 10 + (data[pos + 4] & 0x0f); + int startMinute = ((data[pos + 5] & 0xf0) >> 4) * 10 + (data[pos + 5] & 0x0f); + int startSecond = ((data[pos + 6] & 0xf0) >> 4) * 10 + (data[pos + 6] & 0x0f); + Calendar calendar = Calendar.getInstance(); + startYear += 1900; + calendar.set(startYear, startMonth, startDay, startHour, startMinute, startSecond); + long startTime = ConvertUtils.convertUnixEpochToGPSTime( + calendar.getTimeInMillis() / 1000); + int durationInSecond = (((data[pos + 7] & 0xf0) >> 4) * 10 + + (data[pos + 7] & 0x0f)) * 3600 + + (((data[pos + 8] & 0xf0) >> 4) * 10 + (data[pos + 8] & 0x0f)) * 60 + + (((data[pos + 9] & 0xf0) >> 4) * 10 + (data[pos + 9] & 0x0f)); + int descriptorsLength = ((data[pos + 10] & 0x0f) << 8) + | (data[pos + 10 + 1] & 0xff); + int descriptorsPos = pos + 10 + 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("DVB EIT descriptors size: %d", descriptors.size())); + } + // TODO: Add logic to generating content rating for dvb. See DVB document 6.2.28 for + // details. Content rating here will be null + String contentRating = generateContentRating(descriptors); + // TODO: Add logic for generating genre for dvb. See DVB document 6.2.9 for details. + // Genre here will be null here. + String broadcastGenre = generateBroadcastGenre(descriptors); + String canonicalGenre = generateCanonicalGenre(descriptors); + String titleText = generateShortEventName(descriptors); + List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors); + List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors); + pos += 12 + descriptorsLength; + results.add(new EitItem(EitItem.INVALID_PROGRAM_ID, eventId, titleText, + startTime, durationInSecond, contentRating, audioTracks, captionTracks, + broadcastGenre, canonicalGenre, null)); + } + if (mListener != null) { + mListener.onEitParsed(sourceId, results); + } + 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. @@ -717,6 +910,9 @@ public class SectionParser { if (audioDescriptor.getLanguage() != null) { audioTrack.language = audioDescriptor.getLanguage(); } + if (audioTrack.language == null) { + audioTrack.language = ""; + } audioTrack.audioType = AtscAudioTrack.AUDIOTYPE_UNDEFINED; audioTrack.channelCount = audioDescriptor.getNumChannels(); audioTrack.sampleRate = audioDescriptor.getSampleRate(); @@ -787,44 +983,179 @@ public class SectionParser { return services; } - private static String generateContentRating(List<TsDescriptor> descriptors) { - List<String> contentRatings = new ArrayList<>(); + @VisibleForTesting + static String generateContentRating(List<TsDescriptor> descriptors) { + Set<String> contentRatings = new ArraySet<>(); + List<RatingRegion> usRatingRegions = getRatingRegions(descriptors, RATING_REGION_US_TV); + List<RatingRegion> krRatingRegions = getRatingRegions(descriptors, RATING_REGION_KR_TV); + for (RatingRegion region : usRatingRegions) { + String contentRating = getUsRating(region); + if (contentRating != null) { + contentRatings.add(contentRating); + } + } + for (RatingRegion region : krRatingRegions) { + String contentRating = getKrRating(region); + if (contentRating != null) { + contentRatings.add(contentRating); + } + } + return TextUtils.join(",", contentRatings); + } + + /** + * Gets a list of {@link RatingRegion} in the specific region. + * + * @param descriptors {@link TsDescriptor} list which may contains rating information + * @param region the specific region + * @return a list of {@link RatingRegion} in the specific region + */ + private static List<RatingRegion> getRatingRegions(List<TsDescriptor> descriptors, int region) { + List<RatingRegion> ratingRegions = 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 (!(descriptor instanceof ContentAdvisoryDescriptor)) { + continue; + } + ContentAdvisoryDescriptor contentAdvisoryDescriptor = + (ContentAdvisoryDescriptor) descriptor; + for (RatingRegion ratingRegion : contentAdvisoryDescriptor.getRatingRegions()) { + if (ratingRegion.getName() == region) { + ratingRegions.add(ratingRegion); + } + } + } + return ratingRegions; + } + + /** + * Gets US content rating and subratings (if any). + * + * @param ratingRegion a {@link RatingRegion} instance which may contain rating information. + * @return A string representing the US content rating and subratings. The format of the string + * is defined in {@link TvContentRating}. null, if no such a string exists. + */ + private static String getUsRating(RatingRegion ratingRegion) { + if (ratingRegion.getName() != RATING_REGION_US_TV) { + return null; + } + List<RegionalRating> regionalRatings = ratingRegion.getRegionalRatings(); + String rating = null; + int ratingIndex = VALUE_US_TV_NONE; + List<String> subratings = new ArrayList<>(); + for (RegionalRating index : regionalRatings) { + // See Table 3 of ANSI-CEA-766-D + int dimension = index.getDimension(); + int value = index.getRating(); + switch (dimension) { + // According to Table 6.27 of ATSC A65, + // the dimensions shall be in increasing order. + // Therefore, rating and ratingIndex are assigned before any corresponding + // subrating. + case DIMENSION_US_TV_RATING: + if (value >= VALUE_US_TV_G && value < RATING_REGION_TABLE_US_TV.length) { + rating = RATING_REGION_TABLE_US_TV[value]; + ratingIndex = value; + } + break; + case DIMENSION_US_TV_D: + if (value == 1 + && (ratingIndex == VALUE_US_TV_PG || ratingIndex == VALUE_US_TV_14)) { + // US_TV_D is applicable to US_TV_PG and US_TV_14 + subratings.add(RATING_REGION_TABLE_US_TV_SUBRATING[dimension - 1]); + } + break; + case DIMENSION_US_TV_L: + case DIMENSION_US_TV_S: + case DIMENSION_US_TV_V: + if (value == 1 + && ratingIndex >= VALUE_US_TV_PG + && ratingIndex <= VALUE_US_TV_MA) { + // US_TV_L, US_TV_S, and US_TV_V are applicable to + // US_TV_PG, US_TV_14 and US_TV_MA + subratings.add(RATING_REGION_TABLE_US_TV_SUBRATING[dimension - 1]); + } + break; + case DIMENSION_US_TV_Y: + if (rating == null) { + if (value == VALUE_US_TV_Y) { + rating = STRING_US_TV_Y; + } else if (value == VALUE_US_TV_Y7) { + rating = STRING_US_TV_Y7; } - if (ratingSystem != null && rating != null) { - contentRatings.add(TvContentRating - .createRating("com.android.tv", ratingSystem, rating) - .flattenToString()); + } + break; + case DIMENSION_US_TV_FV: + if (STRING_US_TV_Y7.equals(rating) && value == 1) { + // US_TV_FV is applicable to US_TV_Y7 + subratings.add(STRING_US_TV_FV); + } + break; + case DIMENSION_US_MV_RATING: + if (value >= VALUE_US_MV_G && value <= VALUE_US_MV_X) { + if (value == VALUE_US_MV_X) { + // US_MV_X was replaced by US_MV_NC17 in 1990, + // and it's not supported by TvContentRating + value = VALUE_US_MV_NC17; + } + if (rating != null) { + // According to Table 3 of ANSI-CEA-766-D, + // DIMENSION_US_TV_RATING and DIMENSION_US_MV_RATING shall not be + // present in the same descriptor. + Log.w( + TAG, + "DIMENSION_US_TV_RATING and DIMENSION_US_MV_RATING are " + + "present in the same descriptor"); + } else { + return TvContentRating.createRating( + RATING_DOMAIN, + RATING_REGION_RATING_SYSTEM_US_MV, + RATING_REGION_TABLE_US_MV[value - 2]) + .flattenToString(); } } - } + break; + + default: + break; } } - return TextUtils.join(",", contentRatings); + if (rating == null) { + return null; + } + + String[] subratingArray = subratings.toArray(new String[subratings.size()]); + return TvContentRating.createRating( + RATING_DOMAIN, RATING_REGION_RATING_SYSTEM_US_TV, rating, subratingArray) + .flattenToString(); + } + + /** + * Gets KR(South Korea) content rating. + * + * @param ratingRegion a {@link RatingRegion} instance which may contain rating information. + * @return A string representing the KR content rating. The format of the string is defined in + * {@link TvContentRating}. null, if no such a string exists. + */ + private static String getKrRating(RatingRegion ratingRegion) { + if (ratingRegion.getName() != RATING_REGION_KR_TV) { + return null; + } + List<RegionalRating> regionalRatings = ratingRegion.getRegionalRatings(); + String rating = null; + for (RegionalRating index : regionalRatings) { + if (index.getDimension() == 0 + && index.getRating() >= 0 + && index.getRating() < RATING_REGION_TABLE_KR_TV.length) { + rating = RATING_REGION_TABLE_KR_TV[index.getRating()]; + break; + } + } + if (rating == null) { + return null; + } + return TvContentRating.createRating( + RATING_DOMAIN, RATING_REGION_RATING_SYSTEM_KR_TV, rating) + .flattenToString(); } private static String generateBroadcastGenre(List<TsDescriptor> descriptors) { @@ -849,6 +1180,28 @@ public class SectionParser { return null; } + private static List<ServiceDescriptor> generateServiceDescriptors( + List<TsDescriptor> descriptors) { + List<ServiceDescriptor> serviceDescriptors = new ArrayList<>(); + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof ServiceDescriptor) { + ServiceDescriptor serviceDescriptor = (ServiceDescriptor) descriptor; + serviceDescriptors.add(serviceDescriptor); + } + } + return serviceDescriptors; + } + + private static String generateShortEventName(List<TsDescriptor> descriptors) { + for (TsDescriptor descriptor : descriptors) { + if (descriptor instanceof ShortEventDescriptor) { + ShortEventDescriptor shortEventDescriptor = (ShortEventDescriptor) descriptor; + return shortEventDescriptor.getEventName(); + } + } + return ""; + } + 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<>(); @@ -894,6 +1247,22 @@ public class SectionParser { descriptor = parseIso639Language(data, pos, pos + length + 2); break; + case DVB_DESCRIPTOR_TAG_SERVICE: + descriptor = parseDvbService(data, pos, pos + length + 2); + break; + + case DVB_DESCRIPTOR_TAG_SHORT_EVENT: + descriptor = parseDvbShortEvent(data, pos, pos + length + 2); + break; + + case DVB_DESCRIPTOR_TAG_CONTENT: + descriptor = parseDvbContent(data, pos, pos + length + 2); + break; + + case DVB_DESCRIPTOR_TAG_PARENTAL_RATING: + descriptor = parseDvbParentalRating(data, pos, pos + length + 2); + break; + default: } if (descriptor != null) { @@ -948,6 +1317,7 @@ public class SectionParser { pos += 3; boolean ccType = (data[pos] & 0x80) != 0; if (!ccType) { + pos +=3; continue; } int captionServiceNumber = data[pos] & 0x3f; @@ -987,6 +1357,7 @@ public class SectionParser { int ratingRegion = data[pos] & 0xff; int dimensionCount = data[pos + 1] & 0xff; pos += 2; + int previousDimension = -1; for (int j = 0; j < dimensionCount; ++j) { if (limit <= pos + 1) { Log.e(TAG, "Broken ContentAdvisory"); @@ -994,6 +1365,13 @@ public class SectionParser { } int dimensionIndex = data[pos] & 0xff; int ratingValue = data[pos + 1] & 0x0f; + if (dimensionIndex <= previousDimension) { + // According to Table 6.27 of ATSC A65, + // the indices shall be in increasing order. + Log.e(TAG, "Broken ContentAdvisory"); + return null; + } + previousDimension = dimensionIndex; pos += 2; indices.add(new RegionalRating(dimensionIndex, ratingValue)); } @@ -1189,6 +1567,74 @@ public class SectionParser { language, language2); } + private static TsDescriptor parseDvbService(byte[] data, int pos, int limit) { + // For details of DVB service descriptors, see DVB Document A038 Table 86. + if (limit < pos + 5) { + Log.e(TAG, "Broken service descriptor."); + return null; + } + pos += 2; + int serviceType = data[pos] & 0xff; + pos++; + int serviceProviderNameLength = data[pos] & 0xff; + pos++; + String serviceProviderName = extractTextFromDvb(data, pos, serviceProviderNameLength); + pos += serviceProviderNameLength; + int serviceNameLength = data[pos] & 0xff; + pos++; + String serviceName = extractTextFromDvb(data, pos, serviceNameLength); + return new ServiceDescriptor(serviceType, serviceProviderName, serviceName); + } + + private static TsDescriptor parseDvbShortEvent(byte[] data, int pos, int limit) { + // For details of DVB service descriptors, see DVB Document A038 Table 91. + if (limit < pos + 7) { + Log.e(TAG, "Broken short event descriptor."); + return null; + } + pos += 2; + String language = new String(data, pos, 3); + int eventNameLength = data[pos + 3] & 0xff; + pos += 4; + if (pos + eventNameLength > limit) { + Log.e(TAG, "Broken short event descriptor."); + return null; + } + String eventName = new String(data, pos, eventNameLength); + pos += eventNameLength; + int textLength = data[pos] & 0xff; + if (pos + textLength > limit) { + Log.e(TAG, "Broken short event descriptor."); + return null; + } + pos++; + String text = new String(data, pos, textLength); + return new ShortEventDescriptor(language, eventName, text); + } + + private static TsDescriptor parseDvbContent(byte[] data, int pos, int limit) { + // TODO: According to DVB Document A038 Table 27 to add a parser for content descriptor to + // get content genre. + return null; + } + + private static TsDescriptor parseDvbParentalRating(byte[] data, int pos, int limit) { + // For details of DVB service descriptors, see DVB Document A038 Table 81. + HashMap<String, Integer> ratings = new HashMap<>(); + pos += 2; + while (pos + 4 <= limit) { + String countryCode = new String(data, pos, 3); + int rating = data[pos + 3] & 0xff; + pos += 4; + if (rating > 15) { + // Rating > 15 means that the ratings is defined by broadcaster. + continue; + } + ratings.put(countryCode, rating + 3); + } + return new ParentalRatingDescriptor(ratings); + } + 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) { @@ -1244,6 +1690,55 @@ public class SectionParser { return null; } + private static String extractTextFromDvb(byte[] data, int pos, int length) { + // For details of DVB character set selection, see DVB Document A038 Annex A. + if (data.length < pos + length) { + return null; + } + try { + String charsetPrefix = "ISO-8859-"; + switch (data[0]) { + case 0x01: + case 0x02: + case 0x03: + case 0x04: + case 0x05: + case 0x06: + case 0x07: + case 0x09: + case 0x0A: + case 0x0B: + String charset = charsetPrefix + String.valueOf(data[0] & 0xff + 4); + return new String(data, pos, length, charset); + case 0x10: + if (length < 3) { + Log.e(TAG, "Broken DVB text"); + return null; + } + int codeTable = data[pos + 2] & 0xff; + if (data[pos + 1] == 0 && codeTable > 0 && codeTable < 15) { + return new String( + data, pos, length, charsetPrefix + String.valueOf(codeTable)); + } else { + return new String(data, pos, length, "ISO-8859-1"); + } + case 0x11: + case 0x14: + case 0x15: + return new String(data, pos, length, "UTF-16BE"); + case 0x12: + return new String(data, pos, length, "EUC-KR"); + case 0x13: + return new String(data, pos, length, "GB2312"); + default: + return new String(data, pos, length, "ISO-8859-1"); + } + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Unsupported text format.", e); + } + return new String(data, pos, length); + } + private static boolean checkSanity(byte[] data) { if (data.length <= 1) { return false; diff --git a/src/com/android/tv/tuner/ts/TsParser.java b/src/com/android/tv/tuner/ts/TsParser.java index c24c2a21..7cdb534e 100644 --- a/src/com/android/tv/tuner/ts/TsParser.java +++ b/src/com/android/tv/tuner/ts/TsParser.java @@ -25,6 +25,7 @@ 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.SdtItem; import com.android.tv.tuner.data.PsipData.VctItem; import com.android.tv.tuner.data.TunerChannel; import com.android.tv.tuner.ts.SectionParser.OutputListener; @@ -46,6 +47,8 @@ public class TsParser { public static final int ATSC_SI_BASE_PID = 0x1ffb; public static final int PAT_PID = 0x0000; + public static final int DVB_SDT_PID = 0x0011; + public static final int DVB_EIT_PID = 0x0012; 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; @@ -64,6 +67,7 @@ public class TsParser { 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<Integer, SdtItem> mProgramNumberToSdtItemMap = 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<>(); @@ -71,6 +75,7 @@ public class TsParser { private final SparseBooleanArray mProgramNumberHandledStatus = new SparseBooleanArray(); private final SparseBooleanArray mVctItemHandledStatus = new SparseBooleanArray(); private final TsOutputListener mListener; + private final boolean mIsDvbSignal; private int mVctItemCount; private int mHandledVctItemCount; @@ -84,6 +89,7 @@ public class TsParser { void onEitItemParsed(VctItem channel, List<EitItem> items); void onEttPidDetected(int pid); void onAllVctItemsParsed(); + void onSdtItemParsed(SdtItem channel, List<PmtItem> pmtItems); } private abstract class Stream { @@ -102,6 +108,7 @@ public class TsParser { } protected abstract void handleData(byte[] data, boolean startIndicator); + protected abstract void resetDataVersions(); } private class SectionStream extends Stream { @@ -138,6 +145,11 @@ public class TsParser { mSectionParser.parseSections(mPacket); } + @Override + protected void resetDataVersions() { + mSectionParser.resetVersionNumbers(); + } + private final OutputListener mSectionListener = new OutputListener() { @Override public void onPatParsed(List<PatItem> items) { @@ -173,6 +185,12 @@ public class TsParser { mListener.onAllVctItemsParsed(); } } + SdtItem sdtItem = mProgramNumberToSdtItemMap.get(programNumber); + if (sdtItem != null) { + // When PMT is parsed later than SDT. + mProgramNumberHandledStatus.put(programNumber, true); + handleSdtItem(sdtItem, items); + } } } @@ -276,6 +294,24 @@ public class TsParser { mETTMap.put(entry, descriptions); handleEvents(sourceId); } + + @Override + public void onSdtParsed(List<SdtItem> sdtItems) { + for (SdtItem sdtItem : sdtItems) { + if (DEBUG) Log.d(TAG, "onSdtParsed " + sdtItem); + int programNumber = sdtItem.getServiceId(); + mProgramNumberToSdtItemMap.put(programNumber, sdtItem); + List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber); + if (pmtList != null) { + mProgramNumberHandledStatus.put(programNumber, true); + handleSdtItem(sdtItem, pmtList); + } else { + mProgramNumberHandledStatus.put(programNumber, false); + Log.i(TAG, "onSdtParsed, but PMT for programNo " + programNumber + + " is not found yet."); + } + } + } }; } @@ -335,6 +371,15 @@ public class TsParser { } } + private void handleSdtItem(SdtItem channel, List<PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "handleSdtItem " + channel); + } + if (mListener != null) { + mListener.onSdtItemParsed(channel, pmtItems); + } + } + private void handleEvents(int sourceId) { Map<Integer, EitItem> itemSet = new HashMap<>(); for (int pid : mEITPids) { @@ -367,17 +412,26 @@ public class TsParser { handleEitItems(channel, items); } else { mVctItemHandledStatus.put(sourceId, false); - Log.i(TAG, "onEITParsed, but VCT for sourceId " + sourceId + " is not found yet."); + if (!mIsDvbSignal) { + // Log only when zapping to non-DVB channels, since there is not VCT in DVB signal. + 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); + public TsParser(TsOutputListener listener, boolean isDvbSignal) { startListening(PAT_PID); + startListening(ATSC_SI_BASE_PID); + mIsDvbSignal = isDvbSignal; + if (isDvbSignal) { + startListening(DVB_EIT_PID); + startListening(DVB_SDT_PID); + } mListener = listener; } @@ -412,7 +466,7 @@ public class TsParser { // We are not interested in this packet. return false; } - if (payloadPos > pos + TS_PACKET_SIZE) { + if (payloadPos >= pos + TS_PACKET_SIZE) { if (DEBUG) Log.d(TAG, "Payload should be included in a single TS packet."); return false; } @@ -451,4 +505,16 @@ public class TsParser { } return incompleteChannels; } + + /** + * Reset the versions so that data with old version number can be handled. + */ + public void resetDataVersions() { + for (int eitPid : mEITPids) { + Stream stream = mStreamMap.get(eitPid); + if (stream != null) { + stream.resetDataVersions(); + } + } + } } diff --git a/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java index a16bc522..d2b4998a 100644 --- a/src/com/android/tv/tuner/tvinput/ChannelDataManager.java +++ b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java @@ -25,6 +25,7 @@ import android.content.OperationApplicationException; import android.database.Cursor; import android.media.tv.TvContract; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; @@ -37,6 +38,7 @@ 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 com.android.tv.util.PermissionUtils; import java.util.ArrayList; import java.util.Collections; @@ -192,11 +194,14 @@ public class ChannelDataManager implements Handler.Callback { public void release() { mHandler.removeCallbacksAndMessages(null); - mHandlerThread.quitSafely(); + releaseSafely(); } public void releaseSafely() { mHandlerThread.quitSafely(); + mListener = null; + mChannelScanListener = null; + mChannelScanHandler = null; } public TunerChannel getChannel(long channelId) { @@ -435,7 +440,7 @@ public class ChannelDataManager implements Handler.Callback { } } ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert( - TvContract.Programs.CONTENT_URI), newItem, channel.getChannelId())); + TvContract.Programs.CONTENT_URI), newItem, channel)); if (ops.size() >= BATCH_OPERATION_COUNT) { applyBatch(channel.getName(), ops); ops.clear(); @@ -505,7 +510,7 @@ public class ChannelDataManager implements Handler.Callback { continue; } ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert( - TvContract.Programs.CONTENT_URI), item, channel.getChannelId())); + TvContract.Programs.CONTENT_URI), item, channel)); if (ops.size() >= BATCH_OPERATION_COUNT) { applyBatch(channel.getName(), ops); ops.clear(); @@ -516,9 +521,13 @@ public class ChannelDataManager implements Handler.Callback { } private ContentProviderOperation buildContentProviderOperation( - ContentProviderOperation.Builder builder, EitItem item, Long channelId) { - if (channelId != null) { - builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channelId); + ContentProviderOperation.Builder builder, EitItem item, TunerChannel channel) { + if (channel != null) { + builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channel.getChannelId()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.withValue(TvContract.Programs.COLUMN_RECORDING_PROHIBITED, + channel.isRecordingProhibited() ? 1 : 0); + } } if (item != null) { builder.withValue(TvContract.Programs.COLUMN_TITLE, item.getTitleText()) @@ -556,7 +565,10 @@ public class ChannelDataManager implements Handler.Callback { 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_VIDEO_FORMAT, channel.getVideoFormat()); values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, VERSION); + values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2, + channel.isRecordingProhibited() ? 1 : 0); if (channelId <= 0) { values.put(TvContract.Channels.COLUMN_INPUT_ID, mInputId); @@ -598,13 +610,29 @@ public class ChannelDataManager implements Handler.Callback { } 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(); + if (PermissionUtils.hasAccessAllEpg(mContext)) { + 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(); + } + } + } else { + try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri, + new String[] { TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1 }, + null, null, null)) { + if (cursor != null) { + while (cursor.moveToNext()) { + int version = cursor.getInt(0); + if (version != VERSION) { + clearChannels(); + break; + } + } + } } } } diff --git a/src/com/android/tv/tuner/tvinput/EventDetector.java b/src/com/android/tv/tuner/tvinput/EventDetector.java index a132398f..dc99118a 100644 --- a/src/com/android/tv/tuner/tvinput/EventDetector.java +++ b/src/com/android/tv/tuner/tvinput/EventDetector.java @@ -21,12 +21,12 @@ import android.util.SparseArray; import android.util.SparseBooleanArray; import com.android.tv.tuner.TunerHal; -import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; -import com.android.tv.tuner.data.nano.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 com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; +import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import java.util.ArrayList; import java.util.HashSet; @@ -48,10 +48,11 @@ public class EventDetector { // To prevent channel duplication private final Set<Integer> mVctProgramNumberSet = new HashSet<>(); + private final Set<Integer> mSdtProgramNumberSet = 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 final List<EventListener> mEventListeners = new ArrayList<>(); private int mFrequency; private String mModulation; private int mProgramNumber = ALL_PROGRAM_NUMBERS; @@ -105,8 +106,10 @@ public class EventDetector { item.setHasCaptionTrack(); } } - if (tunerChannel != null && mEventListener != null) { - mEventListener.onEventDetected(tunerChannel, items); + if (tunerChannel != null && !mEventListeners.isEmpty()) { + for (EventListener eventListener : mEventListeners) { + eventListener.onEventDetected(tunerChannel, items); + } } } @@ -117,8 +120,10 @@ public class EventDetector { @Override public void onAllVctItemsParsed() { - if (mEventListener != null) { - mEventListener.onChannelScanDone(); + if (!mEventListeners.isEmpty()) { + for (EventListener eventListener : mEventListeners) { + eventListener.onChannelScanDone(); + } } } @@ -161,8 +166,47 @@ public class EventDetector { if (!found) { mVctProgramNumberSet.add(channelProgramNumber); } - if (mEventListener != null) { - mEventListener.onChannelDetected(tunerChannel, !found); + if (!mEventListeners.isEmpty()) { + for (EventListener eventListener : mEventListeners) { + eventListener.onChannelDetected(tunerChannel, !found); + } + } + } + + @Override + public void onSdtItemParsed(PsipData.SdtItem channel, List<PsiData.PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "onSdtItemParsed SDT " + 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.getServiceId(); + tunerChannel.setAudioTracks(audioTracks); + tunerChannel.setCaptionTracks(captionTracks); + tunerChannel.setFrequency(mFrequency); + tunerChannel.setModulation(mModulation); + mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel); + boolean found = mSdtProgramNumberSet.contains(channelProgramNumber); + if (!found) { + mSdtProgramNumberSet.add(channelProgramNumber); + } + if (!mEventListeners.isEmpty()) { + for (EventListener eventListener : mEventListeners) { + eventListener.onChannelDetected(tunerChannel, !found); + } } } }; @@ -196,18 +240,23 @@ public class EventDetector { /** * 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) { + public EventDetector(TunerHal usbTunerInteface) { mTunerHal = usbTunerInteface; - mEventListener = listener; } private void reset() { - mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset() + // TODO: Use TsParser.reset() + int deliverySystemType = mTunerHal.getDeliverySystemType(); + mTsParser = + new TsParser( + mTsOutputListener, + TunerHal.isDvbDeliverySystem(mTunerHal.getDeliverySystemType())); mPidSet.clear(); mVctProgramNumberSet.clear(); + mSdtProgramNumberSet.clear(); mVctCaptionTracksFound.clear(); mEitCaptionTracksFound.clear(); mChannelMap.clear(); @@ -258,4 +307,28 @@ public class EventDetector { public List<TunerChannel> getMalFormedChannels() { return mTsParser.getMalFormedChannels(); } + + /** + * Registers an EventListener. + * @param eventListener the listener to be registered + */ + public void registerListener(EventListener eventListener) { + if (mTsParser != null) { + // Resets the version numbers so that the new listener can receive the EIT items. + // Otherwise, each EIT session is handled only once unless there is a new version. + mTsParser.resetDataVersions(); + } + mEventListeners.add(eventListener); + } + + /** + * Unregisters an EventListener. + * @param eventListener the listener to be unregistered + */ + public void unregisterListener(EventListener eventListener) { + boolean removed = mEventListeners.remove(eventListener); + if (!removed && DEBUG) { + Log.d(TAG, "Cannot unregister a non-registered listener!"); + } + } } diff --git a/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java index 61de24f4..99222bf8 100644 --- a/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java +++ b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java @@ -23,10 +23,11 @@ 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.SdtItem; import com.android.tv.tuner.data.PsipData.VctItem; +import com.android.tv.tuner.data.TunerChannel; import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; import com.android.tv.tuner.data.nano.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; @@ -49,15 +50,18 @@ public class FileSourceEventDetector { private TsParser mTsParser; private final Set<Integer> mVctProgramNumberSet = new HashSet<>(); + private final Set<Integer> mSdtProgramNumberSet = 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 final boolean mEnableDvbSignal; private FileTsStreamer.StreamProvider mStreamProvider; private int mProgramNumber = ALL_PROGRAM_NUMBERS; - public FileSourceEventDetector(EventDetector.EventListener listener) { + public FileSourceEventDetector(EventDetector.EventListener listener, boolean enableDvbSignal) { mEventListener = listener; + mEnableDvbSignal = enableDvbSignal; } /** @@ -74,9 +78,10 @@ public class FileSourceEventDetector { } private void reset() { - mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset() + mTsParser = new TsParser(mTsOutputListener, mEnableDvbSignal); // TODO: Use TsParser.reset() mStreamProvider.clearPidFilter(); mVctProgramNumberSet.clear(); + mSdtProgramNumberSet.clear(); mVctCaptionTracksFound.clear(); mEitCaptionTracksFound.clear(); mChannelMap.clear(); @@ -206,5 +211,39 @@ public class FileSourceEventDetector { mEventListener.onChannelDetected(tunerChannel, !found); } } + + @Override + public void onSdtItemParsed(SdtItem channel, List<PmtItem> pmtItems) { + if (DEBUG) { + Log.d(TAG, "onSdtItemParsed SDT " + 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.forDvbFile(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.getServiceId(); + tunerChannel.setFilepath(mStreamProvider.getFilepath()); + tunerChannel.setAudioTracks(audioTracks); + tunerChannel.setCaptionTracks(captionTracks); + mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel); + boolean found = mSdtProgramNumberSet.contains(channelProgramNumber); + if (!found) { + mSdtProgramNumberSet.add(channelProgramNumber); + } + if (mEventListener != null) { + mEventListener.onChannelDetected(tunerChannel, !found); + } + } }; } diff --git a/src/com/android/tv/tuner/tvinput/TunerDebug.java b/src/com/android/tv/tuner/tvinput/TunerDebug.java index a7a41ea7..2ddc946a 100644 --- a/src/com/android/tv/tuner/tvinput/TunerDebug.java +++ b/src/com/android/tv/tuner/tvinput/TunerDebug.java @@ -55,10 +55,10 @@ public class TunerDebug { return LazyHolder.INSTANCE; } - public static void notifyVideoFrameDrop(long delta) { + public static void notifyVideoFrameDrop(int count, long delta) { // TODO: provide timestamp mismatch information using delta TunerDebug sTunerDebug = getInstance(); - sTunerDebug.mVideoFrameDrop++; + sTunerDebug.mVideoFrameDrop += count; } public static int getVideoFrameDrop() { diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java index 6ec55e4f..34013bf1 100644 --- a/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java +++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java @@ -33,14 +33,18 @@ import android.support.annotation.MainThread; import android.support.annotation.Nullable; import android.util.Log; +import android.util.Pair; +import com.google.android.exoplayer.C; 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.dvr.data.RecordedProgram; import com.android.tv.tuner.DvbDeviceAccessor; import com.android.tv.tuner.data.PsipData; +import com.android.tv.tuner.data.PsipData.EitItem; import com.android.tv.tuner.data.TunerChannel; +import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import com.android.tv.tuner.exoplayer.ExoPlayerSampleExtractor; import com.android.tv.tuner.exoplayer.SampleExtractor; import com.android.tv.tuner.exoplayer.buffer.BufferManager; @@ -53,10 +57,10 @@ 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.Locale; import java.util.Random; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; /** @@ -71,6 +75,7 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, 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 TUNING_RETRY_INTERVAL_MS = TimeUnit.SECONDS.toMillis(4); 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; @@ -80,20 +85,23 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, 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 static final int MSG_UPDATE_CC_INFO = 7; private final RecordingCapability mCapabilities; public RecordingCapability getCapabilities() { return mCapabilities; } - @IntDef({STATE_IDLE, STATE_TUNED, STATE_RECORDING}) + @IntDef({STATE_IDLE, STATE_TUNING, 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 int STATE_TUNING = 2; + private static final int STATE_TUNED = 3; + private static final int STATE_RECORDING = 4; private static final long CHANNEL_ID_NONE = -1; + private static final int MAX_TUNING_RETRY = 6; private final Context mContext; private final ChannelDataManager mChannelDataManager; @@ -108,13 +116,16 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, 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; + private PsipData.EitItem mCurrenProgram; + private List<AtscCaptionTrack> mCaptionTracks; + private DvrStorageManager mDvrStorageManager; + public TunerRecordingSessionWorker(Context context, String inputId, ChannelDataManager dataManager, TunerRecordingSession session) { mRandom.setSeed(System.nanoTime()); @@ -157,6 +168,7 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, if (mChannel == null || mChannel.compareTo(channel) != 0) { return; } + mHandler.obtainMessage(MSG_UPDATE_CC_INFO, new Pair<>(channel, items)).sendToTarget(); mChannelDataManager.notifyEventDetected(channel, items); } @@ -178,7 +190,7 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, @MainThread public void tune(Uri channelUri) { mHandler.removeCallbacksAndMessages(null); - mHandler.obtainMessage(MSG_TUNE, channelUri).sendToTarget(); + mHandler.obtainMessage(MSG_TUNE, 0, 0, channelUri).sendToTarget(); } /** @@ -211,11 +223,22 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, switch (msg.what) { case MSG_TUNE: { Uri channelUri = (Uri) msg.obj; + int retryCount = msg.arg1; if (DEBUG) Log.d(TAG, "Tune to " + channelUri); if (doTune(channelUri)) { - mSession.onTuned(channelUri); - } else { - reset(); + if (mSessionState == STATE_TUNED) { + mSession.onTuned(channelUri); + } else { + Log.w(TAG, "Tuner stream cannot be created due to resource shortage."); + if (retryCount < MAX_TUNING_RETRY) { + Message tuneMsg = + mHandler.obtainMessage(MSG_TUNE, retryCount + 1, 0, channelUri); + mHandler.sendMessageDelayed(tuneMsg, TUNING_RETRY_INTERVAL_MS); + } else { + mSession.onError(TvInputManager.RECORDING_ERROR_RESOURCE_BUSY); + reset(); + } + } } return true; } @@ -281,6 +304,12 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, mHandler.getLooper().quitSafely(); return true; } + case MSG_UPDATE_CC_INFO: { + Pair<TunerChannel, List<EitItem>> pair = + (Pair<TunerChannel, List<EitItem>>) msg.obj; + updateCaptionTracks(pair.first, pair.second); + return true; + } } return false; } @@ -310,20 +339,17 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, mRecorder.release(); mRecorder = null; } - if (mBufferManager != null) { - mBufferManager.close(); - mBufferManager = null; - } if (mTunerSource != null) { mSourceManager.releaseDataSource(mTunerSource); mTunerSource = null; } + mDvrStorageManager = null; mSessionState = STATE_IDLE; mRecorderRunning = false; } private boolean doTune(Uri channelUri) { - if (mSessionState != STATE_IDLE) { + if (mSessionState != STATE_IDLE && mSessionState != STATE_TUNING) { mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); Log.e(TAG, "Tuning was requested from wrong status."); return false; @@ -333,6 +359,10 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel); return false; + } else if (mChannel.isRecordingProhibited()) { + mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); + Log.w(TAG, "Failed to start recording. Not a recordable channel: " + mChannel); + return false; } if (!mDvrStorageStatusManager.isStorageSufficient()) { mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE); @@ -341,9 +371,9 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, } 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; + // Retry tuning in this case. + mSessionState = STATE_TUNING; + return true; } mSessionState = STATE_TUNED; return true; @@ -365,10 +395,10 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, } // 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); + mDvrStorageManager = new DvrStorageManager(mStorageDir, true); + mRecorder = new ExoPlayerSampleExtractor(Uri.EMPTY, mTunerSource, + new BufferManager(mDvrStorageManager), this, true); mRecorder.setOnCompletionListener(this, mHandler); mProgramUri = programUri; mSessionState = STATE_RECORDING; @@ -392,6 +422,34 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, Log.i(TAG, "Recording stopped"); } + private void updateCaptionTracks(TunerChannel channel, List<PsipData.EitItem> items) { + if (mChannel == null || channel == null || mChannel.compareTo(channel) != 0 + || items == null || items.isEmpty()) { + return; + } + PsipData.EitItem currentProgram = getCurrentProgram(items); + if (currentProgram == null || !currentProgram.hasCaptionTrack() + || mCurrenProgram != null && mCurrenProgram.compareTo(currentProgram) == 0) { + return; + } + mCurrenProgram = currentProgram; + mCaptionTracks = new ArrayList<>(currentProgram.getCaptionTracks()); + if (DEBUG) { + Log.d(TAG, "updated " + mCaptionTracks.size() + " caption tracks for " + + currentProgram); + } + } + + private PsipData.EitItem getCurrentProgram(List<PsipData.EitItem> items) { + for (PsipData.EitItem item : items) { + if (mRecordStartTime >= item.getStartTimeUtcMillis() + && mRecordStartTime < item.getEndTimeUtcMillis()) { + return item; + } + } + return null; + } + private static class Program { private final long mChannelId; private final String mTitle; @@ -566,15 +624,25 @@ public class TunerRecordingSessionWorker implements PlaybackBufferListener, 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)); + long recordEndTime = + (lastExtractedPositionUs == C.UNKNOWN_TIME_US) + ? System.currentTimeMillis() + : mRecordStartTime + lastExtractedPositionUs / 1000; + Uri uri = + insertRecordedProgram( + getRecordedProgram(), + mChannel.getChannelId(), + Uri.fromFile(mStorageDir).toString(), + 1024 * 1024, + mRecordStartTime, + recordEndTime); if (uri == null) { new DeleteRecordingTask().execute(mStorageDir); mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN); Log.e(TAG, "Inserting a recording to DB failed"); return; } + mDvrStorageManager.writeCaptionInfoFiles(mCaptionTracks); mSession.onRecordFinished(uri); } diff --git a/src/com/android/tv/tuner/tvinput/TunerSession.java b/src/com/android/tv/tuner/tvinput/TunerSession.java index 5c61402e..44bae908 100644 --- a/src/com/android/tv/tuner/tvinput/TunerSession.java +++ b/src/com/android/tv/tuner/tvinput/TunerSession.java @@ -38,12 +38,12 @@ import android.widget.Toast; import com.google.android.exoplayer.audio.AudioCapabilities; import com.android.tv.tuner.R; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.TunerPreferences.TunerPreferencesChangedListener; 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.nano.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; @@ -52,7 +52,8 @@ 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 { +public class TunerSession extends TvInputService.Session implements + Handler.Callback, TunerPreferencesChangedListener { private static final String TAG = "TunerSession"; private static final boolean DEBUG = false; private static final String USBTUNER_SHOW_DEBUG = "persist.tv.tuner.show_debug"; @@ -65,8 +66,9 @@ public class TunerSession extends TvInputService.Session implements Handler.Call 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; + public static final int MSG_UI_CLEAR_CAPTION_RENDERER = 9; + public static final int MSG_UI_SET_STATUS_TEXT = 10; + public static final int MSG_UI_TOAST_RESCAN_NEEDED = 11; private final Context mContext; private final Handler mUiHandler; @@ -81,8 +83,7 @@ public class TunerSession extends TvInputService.Session implements Handler.Call private boolean mPlayPaused; private long mTuneStartTimestamp; - public TunerSession(Context context, ChannelDataManager channelDataManager, - BufferManager bufferManager) { + public TunerSession(Context context, ChannelDataManager channelDataManager) { super(context); mContext = context; mUiHandler = new Handler(this); @@ -97,12 +98,10 @@ public class TunerSession extends TvInputService.Session implements Handler.Call 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); + mSessionWorker = new TunerSessionWorker(context, channelDataManager, this); + TunerPreferences.setTunerPreferencesChangedListener(this); } public boolean isReleased() { @@ -214,6 +213,7 @@ public class TunerSession extends TvInputService.Session implements Handler.Call mReleased = true; mSessionWorker.release(); mUiHandler.removeCallbacksAndMessages(null); + TunerPreferences.setTunerPreferencesChangedListener(null); } /** @@ -272,10 +272,13 @@ public class TunerSession extends TvInputService.Session implements Handler.Call // setting is "never". final int value = GlobalSettingsUtils.getEncodedSurroundOutputSettings(mContext); if (value == GlobalSettingsUtils.ENCODED_SURROUND_OUTPUT_NEVER) { - mAudioStatusView.setVisibility(View.VISIBLE); + mAudioStatusView.setText(Html.fromHtml(StatusTextUtils.getAudioWarningInHTML( + mContext.getString(R.string.ut_surround_sound_disabled)))); } else { - Log.e(TAG, "Audio is unavailable, surround sound setting is " + value); + mAudioStatusView.setText(Html.fromHtml(StatusTextUtils.getAudioWarningInHTML( + mContext.getString(R.string.audio_passthrough_not_supported)))); } + mAudioStatusView.setVisibility(View.VISIBLE); return true; } case MSG_UI_HIDE_AUDIO_UNPLAYABLE: { @@ -298,6 +301,10 @@ public class TunerSession extends TvInputService.Session implements Handler.Call mCaptionTrackRenderer.reset(); return true; } + case MSG_UI_CLEAR_CAPTION_RENDERER: { + mCaptionTrackRenderer.clear(); + return true; + } case MSG_UI_SET_STATUS_TEXT: { mStatusView.setText((CharSequence) msg.obj); return true; @@ -309,4 +316,9 @@ public class TunerSession extends TvInputService.Session implements Handler.Call } return false; } + + @Override + public void onTunerPreferencesChanged() { + mSessionWorker.sendMessage(TunerSessionWorker.MSG_TUNER_PREFERENCES_CHANGED); + } } diff --git a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java index 5230298e..e7eb017e 100644 --- a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java +++ b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java @@ -27,6 +27,7 @@ import android.media.tv.TvContract; import android.media.tv.TvInputManager; import android.media.tv.TvTrackInfo; import android.net.Uri; +import android.os.Environment; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; @@ -35,6 +36,7 @@ import android.support.annotation.AnyThread; import android.support.annotation.MainThread; import android.support.annotation.WorkerThread; import android.text.Html; +import android.text.TextUtils; import android.util.Log; import android.util.Pair; import android.util.SparseArray; @@ -45,7 +47,10 @@ 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.customization.TvCustomizationManager; +import com.android.tv.customization.TvCustomizationManager.TRICKPLAY_MODE; import com.android.tv.tuner.TunerPreferences; +import com.android.tv.tuner.TunerPreferences.TrickplaySetting; import com.android.tv.tuner.data.Cea708Data; import com.android.tv.tuner.data.PsipData.EitItem; import com.android.tv.tuner.data.PsipData.TvTracksInterface; @@ -55,20 +60,23 @@ import com.android.tv.tuner.data.nano.Track.AtscAudioTrack; import com.android.tv.tuner.data.nano.Track.AtscCaptionTrack; import com.android.tv.tuner.exoplayer.MpegTsRendererBuilder; import com.android.tv.tuner.exoplayer.buffer.BufferManager; +import com.android.tv.tuner.exoplayer.buffer.BufferManager.StorageManager; import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager; import com.android.tv.tuner.exoplayer.MpegTsPlayer; +import com.android.tv.tuner.exoplayer.buffer.TrickplayStorageManager; +import com.android.tv.tuner.exoplayer.ffmpeg.FfmpegDecoderClient; import com.android.tv.tuner.source.TsDataSource; import com.android.tv.tuner.source.TsDataSourceManager; import com.android.tv.tuner.util.StatusTextUtils; +import com.android.tv.tuner.util.SystemPropertiesProxy; 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; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; /** * {@link TunerSessionWorker} implements a handler thread which processes TV input jobs @@ -82,6 +90,9 @@ public class TunerSessionWorker implements PlaybackBufferListener, private static final boolean DEBUG = false; private static final boolean ENABLE_PROFILER = true; private static final String PLAY_FROM_CHANNEL = "channel"; + 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 // Public messages public static final int MSG_SELECT_TRACK = 1; @@ -93,6 +104,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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; + public static final int MSG_TUNER_PREFERENCES_CHANGED = 10; // Private messages private static final int MSG_TUNE = 1000; @@ -147,10 +159,20 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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 static final int RELEASE_WAIT_INTERVAL_MS = 50; + private static final long TRICKPLAY_OFF_DURATION_MS = TimeUnit.DAYS.toMillis(14); + + // Since release() is done asynchronously, synchronization between multiple TunerSessionWorker + // creation/release is required. + // This is used to guarantee that at most one active TunerSessionWorker exists at any give time. + private static Semaphore sActiveSessionSemaphore = new Semaphore(1); private final Context mContext; private final ChannelDataManager mChannelDataManager; private final TsDataSourceManager mSourceManager; + private final int mMaxTrickplayBufferSizeMb; + private final File mTrickplayBufferDir; + private final @TRICKPLAY_MODE int mTrickplayModeCustomization; private volatile Surface mSurface; private volatile float mVolume = 1.0f; private volatile boolean mCaptionEnabled; @@ -159,6 +181,9 @@ public class TunerSessionWorker implements PlaybackBufferListener, private volatile Long mRecordingDuration; private volatile long mRecordStartTimeMs; private volatile long mBufferStartTimeMs; + private volatile boolean mTrickplayDisabledByStorageIssue; + private @TrickplaySetting int mTrickplaySetting; + private long mTrickplayExpiredMs; private String mRecordingId; private final Handler mHandler; private int mRetryCount; @@ -177,19 +202,20 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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 final boolean mHasSoftwareAudioDecoder; private int mPlayerState = ExoPlayer.STATE_IDLE; private long mPreparingStartTimeMs; private long mBufferingStartTimeMs; private long mReadyStartTimeMs; + private boolean mIsActiveSession; + private boolean mReleaseRequested; // Guarded by mReleaseLock + private final Object mReleaseLock = new Object(); public TunerSessionWorker(Context context, ChannelDataManager channelDataManager, - BufferManager bufferManager, TunerSession tunerSession) { + TunerSession tunerSession) { if (DEBUG) Log.d(TAG, "TunerSessionWorker created"); mContext = context; @@ -211,10 +237,39 @@ public class TunerSessionWorker implements PlaybackBufferListener, (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); mCaptionEnabled = captioningManager.isEnabled(); mPlaybackParams.setSpeed(1.0f); - mBufferManager = bufferManager; + mMaxTrickplayBufferSizeMb = + SystemPropertiesProxy.getInt(MAX_BUFFER_SIZE_KEY, MAX_BUFFER_SIZE_DEF); + mTrickplayModeCustomization = TvCustomizationManager.getTrickplayMode(context); + if (mTrickplayModeCustomization == + TvCustomizationManager.TRICKPLAY_MODE_USE_EXTERNAL_STORAGE) { + boolean useExternalStorage = + Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) && + Environment.isExternalStorageRemovable(); + mTrickplayBufferDir = useExternalStorage ? context.getExternalCacheDir() : null; + } else if (mTrickplayModeCustomization == TvCustomizationManager.TRICKPLAY_MODE_ENABLED) { + mTrickplayBufferDir = context.getCacheDir(); + } else { + mTrickplayBufferDir = null; + } + mTrickplayDisabledByStorageIssue = mTrickplayBufferDir == null; + mTrickplaySetting = TunerPreferences.getTrickplaySetting(context); + if (mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_NOT_SET + && mTrickplayModeCustomization + == TvCustomizationManager.TRICKPLAY_MODE_USE_EXTERNAL_STORAGE) { + // Consider the case of Customization package updates the value of trickplay mode + // to TRICKPLAY_MODE_USE_EXTERNAL_STORAGE after install. + mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_NOT_SET; + TunerPreferences.setTrickplaySetting(context, mTrickplaySetting); + TunerPreferences.setTrickplayExpiredMs(context, 0); + } + mTrickplayExpiredMs = TunerPreferences.getTrickplayExpiredMs(context); mPreparingStartTimeMs = INVALID_TIME; mBufferingStartTimeMs = INVALID_TIME; mReadyStartTimeMs = INVALID_TIME; + // NOTE: We assume that TunerSessionWorker instance will be at most one. + // Only one TunerSessionWorker can be connected to FfmpegDecoderClient at any given time. + // connect() will return false, if there is a connected TunerSessionWorker already. + mHasSoftwareAudioDecoder = FfmpegDecoderClient.connect(context); } // Public methods @@ -285,24 +340,21 @@ public class TunerSessionWorker implements PlaybackBufferListener, } private Long getDurationForRecording(String recordingId) { - try { - DvrStorageManager storageManager = + 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); + List<BufferManager.TrackFormat> trackFormatList = + storageManager.readTrackInfoFiles(false); + if (trackFormatList.isEmpty()) { + trackFormatList = storageManager.readTrackInfoFiles(true); + } + if (!trackFormatList.isEmpty()) { + BufferManager.TrackFormat trackFormat = trackFormatList.get(0); + Long durationUs = trackFormat.format.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; } + Log.e(TAG, "meta file for recording was not found: " + recordingId); + return null; } @MainThread @@ -341,16 +393,15 @@ public class TunerSessionWorker implements PlaybackBufferListener, @MainThread public void release() { if (DEBUG) Log.d(TAG, "release()"); + synchronized (mReleaseLock) { + mReleaseRequested = true; + } + if (mHasSoftwareAudioDecoder) { + FfmpegDecoderClient.disconnect(mContext); + } 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 @@ -367,7 +418,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (playbackState == ExoPlayer.STATE_READY) { if (DEBUG) Log.d(TAG, "ExoPlayer ready"); if (!mPlayerStarted) { - sendMessage(MSG_START_PLAYBACK, mPlayer); + sendMessage(MSG_START_PLAYBACK, System.identityHashCode(mPlayer)); } mReadyStartTimeMs = SystemClock.elapsedRealtime(); } else if (playbackState == ExoPlayer.STATE_PREPARING) { @@ -379,7 +430,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, // 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); + sendMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(mPlayer)); } } mPlayerState = playbackState; @@ -397,7 +448,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, // 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(); + mHandler.obtainMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(mPlayer)) + .sendToTarget(); } } @@ -415,8 +467,12 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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(); + if (mRecordingId != null) { + // Workaround of b/33298048: set it to 1 instead of 0. + mBufferStartTimeMs = mRecordStartTimeMs = 1; + } else { + mBufferStartTimeMs = mRecordStartTimeMs = System.currentTimeMillis(); + } notifyVideoAvailable(); mReportedDrawnToSurface = true; @@ -461,6 +517,11 @@ public class TunerSessionWorker implements PlaybackBufferListener, } @Override + public void onClearCaptionEvent() { + mSession.sendUiMessage(TunerSession.MSG_UI_CLEAR_CAPTION_RENDERER); + } + + @Override public void onDiscoverCaptionServiceNumber(int serviceNumber) { sendMessage(MSG_DISCOVER_CAPTION_SERVICE_NUMBER, serviceNumber); } @@ -499,7 +560,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, @Override public void onDiskTooSlow() { - sendMessage(MSG_RETRY_PLAYBACK, mPlayer); + mTrickplayDisabledByStorageIssue = true; + sendMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(mPlayer)); } // EventDetector.EventListener @@ -602,6 +664,28 @@ public class TunerSessionWorker implements PlaybackBufferListener, return true; } notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + if (!mIsActiveSession) { + // Wait until release is finished if there is a pending release. + try { + while (!sActiveSessionSemaphore.tryAcquire( + RELEASE_WAIT_INTERVAL_MS, TimeUnit.MILLISECONDS)) { + synchronized (mReleaseLock) { + if (mReleaseRequested) { + return true; + } + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + synchronized (mReleaseLock) { + if (mReleaseRequested) { + sActiveSessionSemaphore.release(); + return true; + } + } + mIsActiveSession = true; + } Uri channelUri = (Uri) msg.obj; String recording = null; long channelId = parseChannel(channelUri); @@ -616,7 +700,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN); return true; } - mHandler.removeCallbacksAndMessages(null); + clearCallbacksAndMessagesSafely(); + mChannelDataManager.removeAllCallbacksAndMessages(); if (channel != null) { mChannelDataManager.requestProgramsData(channel); } @@ -624,8 +709,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, // TODO: Need to refactor. notifyContentAllowed() should not be called if parental // control is turned on. mSession.notifyContentAllowed(); - resetPlayback(); resetTvTracks(); + resetPlayback(); mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS, RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS); return true; @@ -633,7 +718,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, case MSG_STOP_TUNE: { if (DEBUG) Log.d(TAG, "MSG_STOP_TUNE"); mChannel = null; - stopPlayback(); + stopPlayback(true); stopCaptionTrack(); resetTvTracks(); notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN); @@ -642,14 +727,17 @@ public class TunerSessionWorker implements PlaybackBufferListener, case MSG_RELEASE: { if (DEBUG) Log.d(TAG, "MSG_RELEASE"); mHandler.removeCallbacksAndMessages(null); - stopPlayback(); + stopPlayback(true); stopCaptionTrack(); mSourceManager.release(); - mReleaseLatch.countDown(); + mHandler.getLooper().quitSafely(); + if (mIsActiveSession) { + sActiveSessionSemaphore.release(); + } return true; } case MSG_RETRY_PLAYBACK: { - if (mPlayer == msg.obj) { + if (System.identityHashCode(mPlayer) == (int) 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. @@ -658,16 +746,18 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (DEBUG) { Log.d(TAG, "MSG_RETRY_PLAYBACK " + mRetryCount); } + mChannelDataManager.removeAllCallbacksAndMessages(); 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(); + stopPlayback(false); stopCaptionTrack(); notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + Log.i(TAG, "Notify weak signal since fail to retry playback"); // After MAX_IMMEDIATE_RETRY_COUNT, give some delay of an empirically chosen // value before recovering the playback. @@ -679,13 +769,14 @@ public class TunerSessionWorker implements PlaybackBufferListener, } case MSG_RESET_PLAYBACK: { if (DEBUG) Log.d(TAG, "MSG_RESET_PLAYBACK"); + mChannelDataManager.removeAllCallbacksAndMessages(); resetPlayback(); return true; } case MSG_START_PLAYBACK: { if (DEBUG) Log.d(TAG, "MSG_START_PLAYBACK"); if (mChannel != null || mRecordingId != null) { - startPlayback(msg.obj); + startPlayback((int) msg.obj); } return true; } @@ -790,7 +881,11 @@ public class TunerSessionWorker implements PlaybackBufferListener, return true; } case MSG_RESCHEDULE_PROGRAMS: { - doReschedulePrograms(); + if (mHandler.hasMessages(MSG_SCHEDULE_OF_PROGRAMS)) { + mHandler.sendEmptyMessage(MSG_RESCHEDULE_PROGRAMS); + } else { + doReschedulePrograms(); + } return true; } case MSG_PARENTAL_CONTROLS: { @@ -814,11 +909,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, return true; } case MSG_SELECT_TRACK: { - if (mChannel != null) { + if (mChannel != null || mRecordingId != 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; } @@ -835,6 +927,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (mPlayer == null) { return true; } + setTrickplayEnabledIfNeeded(); doTimeShiftPause(); return true; } @@ -843,6 +936,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (mPlayer == null) { return true; } + setTrickplayEnabledIfNeeded(); doTimeShiftResume(); return true; } @@ -852,6 +946,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (mPlayer == null) { return true; } + setTrickplayEnabledIfNeeded(); doTimeShiftSeekTo(position); return true; } @@ -859,6 +954,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (mPlayer == null) { return true; } + setTrickplayEnabledIfNeeded(); doTimeShiftSetPlaybackParams((PlaybackParams) msg.obj); return true; } @@ -883,6 +979,22 @@ public class TunerSessionWorker implements PlaybackBufferListener, } return true; } + case MSG_TUNER_PREFERENCES_CHANGED: { + mHandler.removeMessages(MSG_TUNER_PREFERENCES_CHANGED); + @TrickplaySetting int trickplaySetting = + TunerPreferences.getTrickplaySetting(mContext); + if (trickplaySetting != mTrickplaySetting) { + boolean wasTrcikplayEnabled = + mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED; + boolean isTrickplayEnabled = + trickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED; + mTrickplaySetting = trickplaySetting; + if (isTrickplayEnabled != wasTrcikplayEnabled) { + sendMessage(MSG_RESET_PLAYBACK, System.identityHashCode(mPlayer)); + } + } + return true; + } case MSG_BUFFER_START_TIME_CHANGED: { if (mPlayer == null) { return true; @@ -891,7 +1003,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, if (!hasEnoughBackwardBuffer() && (!mPlayer.isPlaying() || mPlaybackParams.getSpeed() < 1.0f)) { mPlayer.setPlayWhenReady(true); - mPlayer.setAudioTrack(true); + mPlayer.setAudioTrackAndClosedCaption(true); mPlaybackParams.setSpeed(1.0f); } return true; @@ -909,7 +1021,6 @@ public class TunerSessionWorker implements PlaybackBufferListener, } 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, @@ -927,32 +1038,36 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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; + long bufferingTimeMs = mBufferingStartTimeMs != INVALID_TIME + ? currentTime - mBufferingStartTimeMs : mBufferingStartTimeMs; + long preparingTimeMs = mPreparingStartTimeMs != INVALID_TIME + ? currentTime - mPreparingStartTimeMs : mPreparingStartTimeMs; + boolean isBufferingTooLong = + bufferingTimeMs > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS; + boolean isPreparingTooLong = + preparingTimeMs > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS; boolean isWeakSignal = source != null - && mChannel.getType() == Channel.TYPE_TUNER - && (noBufferRead || isBufferingTooLong || isPreparingTooLong); + && mChannel.getType() != Channel.TYPE_FILE + && (isBufferingTooLong || isPreparingTooLong); if (isWeakSignal && !mReportedWeakSignal) { if (!mHandler.hasMessages(MSG_RETRY_PLAYBACK)) { - mHandler.sendMessageDelayed(mHandler.obtainMessage( - MSG_RETRY_PLAYBACK, mPlayer), PLAYBACK_RETRY_DELAY_MS); + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RETRY_PLAYBACK, + System.identityHashCode(mPlayer)), PLAYBACK_RETRY_DELAY_MS); } if (mPlayer != null) { - mPlayer.setAudioTrack(false); + mPlayer.setAudioTrackAndClosedCaption(false); } notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL); + Log.i(TAG, "Notify weak signal due to signal check, " + String.format( + "packetsPerSec:%d, bufferingTimeMs:%d, preparingTimeMs:%d, " + + "videoFrameDrop:%d", + (limitInBytes - mLastLimitInBytes) / TS_PACKET_SIZE, + bufferingTimeMs, + preparingTimeMs, + TunerDebug.getVideoFrameDrop() + )); } else if (!isWeakSignal && mReportedWeakSignal) { boolean isPlaybackStable = mReadyStartTimeMs != INVALID_TIME && currentTime - mReadyStartTimeMs @@ -962,11 +1077,10 @@ public class TunerSessionWorker implements PlaybackBufferListener, } else if (mReportedDrawnToSurface) { mHandler.removeMessages(MSG_RETRY_PLAYBACK); notifyVideoAvailable(); - mPlayer.setAudioTrack(true); + mPlayer.setAudioTrackAndClosedCaption(true); } } mLastLimitInBytes = limitInBytes; - mLastPositionInBytes = positionInBytes; mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_PERIOD_MS); return true; } @@ -999,15 +1113,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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); + if (numTrackId != mPlayer.getSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO)) { + mPlayer.setSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO, numTrackId); } mSession.notifyTrackSelected(type, trackId); } else if (type == TvTrackInfo.TYPE_SUBTITLE) { @@ -1030,11 +1137,49 @@ public class TunerSessionWorker implements PlaybackBufferListener, } } - private MpegTsPlayer createPlayer(AudioCapabilities capabilities, BufferManager bufferManager) { + private void setTrickplayEnabledIfNeeded() { + if (mChannel == null || + mTrickplayModeCustomization != TvCustomizationManager.TRICKPLAY_MODE_ENABLED) { + return; + } + if (mTrickplaySetting == TunerPreferences.TRICKPLAY_SETTING_NOT_SET) { + mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_ENABLED; + TunerPreferences.setTrickplaySetting( + mContext, mTrickplaySetting); + } + } + + private MpegTsPlayer createPlayer(AudioCapabilities capabilities) { if (capabilities == null) { Log.w(TAG, "No Audio Capabilities"); } - + long now = System.currentTimeMillis(); + if (mTrickplayModeCustomization == TvCustomizationManager.TRICKPLAY_MODE_ENABLED + && mTrickplaySetting == TunerPreferences.TRICKPLAY_SETTING_NOT_SET) { + if (mTrickplayExpiredMs == 0) { + mTrickplayExpiredMs = now + TRICKPLAY_OFF_DURATION_MS; + TunerPreferences.setTrickplayExpiredMs(mContext, mTrickplayExpiredMs); + } else { + if (mTrickplayExpiredMs < now) { + mTrickplaySetting = TunerPreferences.TRICKPLAY_SETTING_DISABLED; + TunerPreferences.setTrickplaySetting(mContext, mTrickplaySetting); + } + } + } + BufferManager bufferManager = null; + if (mRecordingId != null) { + StorageManager storageManager = + new DvrStorageManager(new File(getRecordingPath()), false); + bufferManager = new BufferManager(storageManager); + updateCaptionTracks(((DvrStorageManager)storageManager).readCaptionInfoFiles()); + } else if (!mTrickplayDisabledByStorageIssue + && mTrickplaySetting != TunerPreferences.TRICKPLAY_SETTING_DISABLED + && mMaxTrickplayBufferSizeMb >= MIN_BUFFER_SIZE_DEF) { + bufferManager = new BufferManager(new TrickplayStorageManager(mContext, + mTrickplayBufferDir, 1024L * 1024 * mMaxTrickplayBufferSizeMb)); + } else { + Log.w(TAG, "Trickplay is disabled."); + } MpegTsPlayer player = new MpegTsPlayer( new MpegTsRendererBuilder(mContext, bufferManager, this), mHandler, mSourceManager, capabilities, this); @@ -1069,24 +1214,26 @@ public class TunerSessionWorker implements PlaybackBufferListener, } 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()) { + synchronized (tvTracksInterface) { + 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 == null || mChannel.getAudioTracks() == null || fromPmt)) { + updateAudioTracks(audioTracks); + } + if (captionTracks == null || captionTracks.isEmpty()) { + if (tvTracksInterface.hasCaptionTrack()) { + updateCaptionTracks(captionTracks); + } + } else { updateCaptionTracks(captionTracks); } - } else { - updateCaptionTracks(captionTracks); } } @@ -1132,25 +1279,24 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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; + // We use language information from EIT/VCT only when the player does not provide + // languages. + com.google.android.exoplayer.MediaFormat infoFromPlayer = + mPlayer.getTrackFormat(MpegTsPlayer.TRACK_TYPE_AUDIO, i); + AtscAudioTrack infoFromEit = mAudioTrackMap.get(i); + AtscAudioTrack infoFromVct = (mChannel != null + && mChannel.getAudioTracks().size() == mAudioTrackMap.size() + && i < mChannel.getAudioTracks().size()) + ? mChannel.getAudioTracks().get(i) : null; + String language = !TextUtils.isEmpty(infoFromPlayer.language) ? infoFromPlayer.language + : (infoFromEit != null && infoFromEit.language != null) ? infoFromEit.language + : (infoFromVct != null && infoFromVct.language != null) + ? infoFromVct.language : null; TvTrackInfo.Builder builder = new TvTrackInfo.Builder( TvTrackInfo.TYPE_AUDIO, AUDIO_TRACK_PREFIX + i); builder.setLanguage(language); - builder.setAudioChannelCount(audioTrack.channelCount); - builder.setAudioSampleRate(audioTrack.sampleRate); + builder.setAudioChannelCount(infoFromPlayer.channelCount); + builder.setAudioSampleRate(infoFromPlayer.sampleRate); TvTrackInfo track = builder.build(); mTvTracks.add(track); } @@ -1226,8 +1372,10 @@ public class TunerSessionWorker implements PlaybackBufferListener, } } - private void stopPlayback() { - mChannelDataManager.removeAllCallbacksAndMessages(); + private void stopPlayback(boolean removeChannelDataCallbacks) { + if (removeChannelDataCallbacks) { + mChannelDataManager.removeAllCallbacksAndMessages(); + } if (mPlayer != null) { mPlayer.setPlayWhenReady(false); mPlayer.release(); @@ -1239,14 +1387,15 @@ public class TunerSessionWorker implements PlaybackBufferListener, mPreparingStartTimeMs = INVALID_TIME; mBufferingStartTimeMs = INVALID_TIME; mReadyStartTimeMs = INVALID_TIME; + mLastLimitInBytes = 0L; mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_AUDIO_UNPLAYABLE); mSession.notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE); } } - private void startPlayback(Object playerObj) { + private void startPlayback(int playerHashCode) { // TODO: provide hasAudio()/hasVideo() for play recordings. - if (mPlayer == null || mPlayer != playerObj) { + if (mPlayer == null || System.identityHashCode(mPlayer) != playerHashCode) { return; } if (mChannel != null && !mChannel.hasAudio()) { @@ -1257,9 +1406,12 @@ public class TunerSessionWorker implements PlaybackBufferListener, 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); + || (mChannel.hasVideo() && !mPlayer.hasVideo())) + && mChannel.getType() != Channel.TYPE_NETWORK) { + // If the channel is from network, skip this part since the video and audio tracks + // information for channels from network are more reliable in the extractor. Otherwise, + // tracks haven't been detected in the extractor. Try again. + sendMessage(MSG_RETRY_PLAYBACK, System.identityHashCode(mPlayer)); return; } // Since mSurface is volatile, we define a local variable surface to keep the same value @@ -1269,7 +1421,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, mPlayer.setSurface(surface); mPlayer.setPlayWhenReady(true); mPlayer.setVolume(mVolume); - if (mChannel != null && !mChannel.hasVideo() && mChannel.hasAudio()) { + if (mChannel != null && mPlayer.hasAudio() && !mPlayer.hasVideo()) { notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY); } else if (!mReportedWeakSignal) { // Doesn't show buffering during weak signal. @@ -1286,22 +1438,21 @@ public class TunerSessionWorker implements PlaybackBufferListener, return; } mSourceManager.setKeepTuneStatus(true); - BufferManager bufferManager = mChannel != null ? mBufferManager : new BufferManager( - new DvrStorageManager(new File(getRecordingPath()), false)); - MpegTsPlayer player = createPlayer(mAudioCapabilities, bufferManager); + MpegTsPlayer player = createPlayer(mAudioCapabilities); 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)) { + if (!player.prepare(mContext, mChannel, mHasSoftwareAudioDecoder, 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); + Log.i(TAG, "Notify weak signal due to player preparation failure"); + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RETRY_PLAYBACK, + System.identityHashCode(mPlayer)), PLAYBACK_RETRY_DELAY_MS); } } else { mPlayer = player; @@ -1314,7 +1465,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, private void resetPlayback() { long timestamp, oldTimestamp; timestamp = SystemClock.elapsedRealtime(); - stopPlayback(); + stopPlayback(false); stopCaptionTrack(); if (ENABLE_PROFILER) { oldTimestamp = timestamp; @@ -1336,8 +1487,12 @@ public class TunerSessionWorker implements PlaybackBufferListener, mRecordingDuration = recording != null ? getDurationForRecording(recording) : null; mProgram = null; mPrograms = null; - mBufferStartTimeMs = mRecordStartTimeMs = - (mRecordingId != null) ? 0 : System.currentTimeMillis(); + if (mRecordingId != null) { + // Workaround of b/33298048: set it to 1 instead of 0. + mBufferStartTimeMs = mRecordStartTimeMs = 1; + } else { + mBufferStartTimeMs = mRecordStartTimeMs = System.currentTimeMillis(); + } mLastPositionMs = 0; mCaptionTrack = null; mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS); @@ -1385,14 +1540,19 @@ public class TunerSessionWorker implements PlaybackBufferListener, } else { mPlayer.seekTo(mBufferStartTimeMs - mRecordStartTimeMs); mPlaybackParams.setSpeed(1.0f); - mPlayer.setAudioTrack(true); + mPlayer.setAudioTrackAndClosedCaption(true); return; } } else if (seekPositionMs > System.currentTimeMillis() - mRecordStartTimeMs) { - mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs); - mPlaybackParams.setSpeed(1.0f); - mPlayer.setAudioTrack(true); - return; + // Stops trickplay when FF requested the position later than current position. + // If RW trickplay requested the position later than current position, + // continue trickplay. + if (mPlaybackParams.getSpeed() > 0.0f) { + mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs); + mPlaybackParams.setSpeed(1.0f); + mPlayer.setAudioTrackAndClosedCaption(true); + return; + } } long delayForNextSeek = getTrickPlaySeekIntervalMs(); @@ -1414,7 +1574,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, } mPlaybackParams.setSpeed(1.0f); mPlayer.setPlayWhenReady(false); - mPlayer.setAudioTrack(true); + mPlayer.setAudioTrackAndClosedCaption(true); } private void doTimeShiftResume() { @@ -1422,7 +1582,7 @@ public class TunerSessionWorker implements PlaybackBufferListener, mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); mPlaybackParams.setSpeed(1.0f); mPlayer.setPlayWhenReady(true); - mPlayer.setAudioTrack(true); + mPlayer.setAudioTrackAndClosedCaption(true); } private void doTimeShiftSeekTo(long timeMs) { @@ -1443,14 +1603,14 @@ public class TunerSessionWorker implements PlaybackBufferListener, doTimeShiftResume(); } else if (mPlayer.supportSmoothTrickPlay(speed)) { mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK); - mPlayer.setAudioTrack(false); + mPlayer.setAudioTrackAndClosedCaption(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.setAudioTrackAndClosedCaption(false); mPlayer.setPlayWhenReady(false); // Initiate trickplay mHandler.sendMessage(mHandler.obtainMessage(MSG_TRICKPLAY_BY_SEEK, @@ -1525,8 +1685,8 @@ public class TunerSessionWorker implements PlaybackBufferListener, } TvContentRating[] ratings = mTvContentRatingCache .getRatings(currentProgram.getContentRating()); - if (ratings == null) { - return null; + if (ratings == null || ratings.length == 0) { + ratings = new TvContentRating[] {TvContentRating.UNRATED}; } for (TvContentRating rating : ratings) { if (!Objects.equals(mUnblockedContentRating, rating) && mTvInputManager @@ -1544,15 +1704,15 @@ public class TunerSessionWorker implements PlaybackBufferListener, } mChannelBlocked = channelBlocked; if (mChannelBlocked) { - mHandler.removeCallbacksAndMessages(null); - stopPlayback(); + clearCallbacksAndMessagesSafely(); + stopPlayback(true); resetTvTracks(); if (contentRating != null) { mSession.notifyContentBlocked(contentRating); } mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, PARENTAL_CONTROLS_INTERVAL_MS); } else { - mHandler.removeCallbacksAndMessages(null); + clearCallbacksAndMessagesSafely(); resetPlayback(); mSession.notifyContentAllowed(); mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS, @@ -1562,6 +1722,17 @@ public class TunerSessionWorker implements PlaybackBufferListener, } } + @WorkerThread + private void clearCallbacksAndMessagesSafely() { + // If MSG_RELEASE is removed, TunerSessionWorker will hang forever. + // Do not remove messages, after release is requested from MainThread. + synchronized (mReleaseLock) { + if (!mReleaseRequested) { + mHandler.removeCallbacksAndMessages(null); + } + } + } + private boolean hasEnoughBackwardBuffer() { return mPlayer.getCurrentPosition() + BUFFER_UNDERFLOW_BUFFER_MS >= mBufferStartTimeMs - mRecordStartTimeMs; diff --git a/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java index e734b779..6ad00daa 100644 --- a/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java +++ b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java @@ -24,6 +24,7 @@ import android.database.Cursor; import android.media.tv.TvContract; import android.net.Uri; import android.os.AsyncTask; +import android.util.Log; import com.android.tv.TvApplication; import com.android.tv.dvr.DvrStorageStatusManager; @@ -40,10 +41,17 @@ import java.util.concurrent.TimeUnit; * from database. */ public class TunerStorageCleanUpService extends JobService { + private static final String TAG = "TunerStorageCleanUpService"; + private CleanUpStorageTask mTask; @Override public void onCreate() { + if (!TvApplication.getSingletons(this).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + this.stopSelf(); + return; + } TvApplication.setCurrentRunningProcess(this, false); super.onCreate(); mTask = new CleanUpStorageTask(this, this); diff --git a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java index 684ebdbd..2725ddfc 100644 --- a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java +++ b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java @@ -28,9 +28,6 @@ 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; @@ -45,9 +42,6 @@ public class TunerTvInputService extends TvInputService 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} @@ -55,17 +49,20 @@ public class TunerTvInputService extends TvInputService private ChannelDataManager mChannelDataManager; private AudioCapabilitiesReceiver mAudioCapabilitiesReceiver; private AudioCapabilities mAudioCapabilities; - private BufferManager mBufferManager; @Override public void onCreate() { + if (!TvApplication.getSingletons(this).getTvInputManagerHelper().hasTvInputManager()) { + Log.wtf(TAG, "Stopping because device does not have a TvInputManager"); + this.stopSelf(); + return; + } 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); @@ -79,11 +76,6 @@ public class TunerTvInputService extends TvInputService jobScheduler.schedule(job); } } - if (mBufferManager == null) { - Log.i(TAG, "Trickplay is disabled"); - } else { - Log.i(TAG, "Trickplay is enabled"); - } } @Override @@ -92,9 +84,6 @@ public class TunerTvInputService extends TvInputService super.onDestroy(); mChannelDataManager.release(); mAudioCapabilitiesReceiver.unregister(); - if (mBufferManager != null) { - mBufferManager.close(); - } } @Override @@ -106,8 +95,7 @@ public class TunerTvInputService extends TvInputService public Session onCreateSession(String inputId) { if (DEBUG) Log.d(TAG, "onCreateSession"); try { - final TunerSession session = new TunerSession( - this, mChannelDataManager, mBufferManager); + final TunerSession session = new TunerSession(this, mChannelDataManager); mTunerSessions.add(session); session.setAudioCapabilities(mAudioCapabilities); session.setOverlayViewEnabled(true); @@ -129,17 +117,6 @@ public class TunerTvInputService extends TvInputService } } - 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/PostalCodeUtils.java b/src/com/android/tv/tuner/util/PostalCodeUtils.java new file mode 100644 index 00000000..9eb689a7 --- /dev/null +++ b/src/com/android/tv/tuner/util/PostalCodeUtils.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2017 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.location.Address; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; +import com.android.tv.tuner.TunerPreferences; +import com.android.tv.util.LocationUtils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A utility class to update, get, and set the last known postal or zip code. + */ +public class PostalCodeUtils { + private static final String TAG = "PostalCodeUtils"; + + // Postcode formats, where A signifies a letter and 9 a digit: + // US zip code format: 99999 + private static final String POSTCODE_REGEX_US = "^(\\d{5})"; + // UK postcode district formats: A9, A99, AA9, AA99 + // Full UK postcode format: Postcode District + space + 9AA + // Should be able to handle both postcode district and full postcode + private static final String POSTCODE_REGEX_GB = + "^([A-Z][A-Z]?[0-9][0-9A-Z]?)( ?[0-9][A-Z]{2})?$"; + private static final String POSTCODE_REGEX_GB_GIR = "^GIR( ?0AA)?$"; // special UK postcode + + private static final Map<String, Pattern> REGION_PATTERN = new HashMap<>(); + private static final Map<String, Integer> REGION_MAX_LENGTH = new HashMap<>(); + + static { + REGION_PATTERN.put(Locale.US.getCountry(), Pattern.compile(POSTCODE_REGEX_US)); + REGION_PATTERN.put( + Locale.UK.getCountry(), + Pattern.compile(POSTCODE_REGEX_GB + "|" + POSTCODE_REGEX_GB_GIR)); + REGION_MAX_LENGTH.put(Locale.US.getCountry(), 5); + REGION_MAX_LENGTH.put(Locale.UK.getCountry(), 8); + } + + // The longest postcode number is 10-character-long. + // Use a larger number to accommodate future changes. + private static final int DEFAULT_MAX_LENGTH = 16; + + /** Returns {@code true} if postal code has been changed */ + public static boolean updatePostalCode(Context context) + throws IOException, SecurityException, NoPostalCodeException { + String postalCode = getPostalCode(context); + String lastPostalCode = getLastPostalCode(context); + if (TextUtils.isEmpty(postalCode)) { + if (TextUtils.isEmpty(lastPostalCode)) { + throw new NoPostalCodeException(); + } + } else if (!TextUtils.equals(postalCode, lastPostalCode)) { + setLastPostalCode(context, postalCode); + return true; + } + return false; + } + + /** + * Gets the last stored postal or zip code, which might be decided by {@link LocationUtils} or + * input by users. + */ + public static String getLastPostalCode(Context context) { + return TunerPreferences.getLastPostalCode(context); + } + + /** + * Sets the last stored postal or zip code. This method will overwrite the value written by + * calling {@link #updatePostalCode(Context)}. + */ + public static void setLastPostalCode(Context context, String postalCode) { + Log.i(TAG, "Set Postal Code:" + postalCode); + TunerPreferences.setLastPostalCode(context, postalCode); + } + + @Nullable + private static String getPostalCode(Context context) throws IOException, SecurityException { + Address address = LocationUtils.getCurrentAddress(context); + if (address != null) { + Log.i(TAG, "Current country and postal code is " + address.getCountryName() + ", " + + address.getPostalCode()); + return address.getPostalCode(); + } + return null; + } + + /** An {@link java.lang.Exception} class to notify no valid postal or zip code is available. */ + public static class NoPostalCodeException extends Exception { + public NoPostalCodeException() { + } + } + + /** + * Checks whether a postcode matches the format of the specific region. + * + * @return {@code false} if the region is supported and the postcode doesn't match; {@code true} + * otherwise + */ + public static boolean matches(@NonNull CharSequence postcode, @NonNull String region) { + Pattern pattern = REGION_PATTERN.get(region.toUpperCase()); + return pattern == null || pattern.matcher(postcode).matches(); + } + + /** + * Gets the largest possible postcode length in the region. + * + * @return maximum postcode length if the region is supported; {@link #DEFAULT_MAX_LENGTH} + * otherwise + */ + public static int getRegionMaxLength(Context context) { + Integer maxLength = + REGION_MAX_LENGTH.get(LocationUtils.getCurrentCountry(context).toUpperCase()); + return maxLength == null ? DEFAULT_MAX_LENGTH : maxLength; + } +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/util/SystemPropertiesProxy.java b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java index 62a64361..2817ccbf 100644 --- a/src/com/android/tv/tuner/util/SystemPropertiesProxy.java +++ b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java @@ -58,4 +58,20 @@ public class SystemPropertiesProxy { } return def; } + + public static String getString(String key, String def) throws IllegalArgumentException { + try { + Class SystemPropertiesClass = Class.forName("android.os.SystemProperties"); + Method getIntMethod = + SystemPropertiesClass.getDeclaredMethod("get", String.class, String.class); + getIntMethod.setAccessible(true); + return (String) getIntMethod.invoke(SystemPropertiesClass, key, def); + } catch (InvocationTargetException + | IllegalAccessException + | NoSuchMethodException + | ClassNotFoundException e) { + Log.e(TAG, "Failed to invoke SystemProperties.get()", e); + } + return def; + } } diff --git a/src/com/android/tv/tuner/util/TunerInputInfoUtils.java b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java index 5c411f64..f421bf1a 100644 --- a/src/com/android/tv/tuner/util/TunerInputInfoUtils.java +++ b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java @@ -21,10 +21,11 @@ import android.content.ComponentName; import android.content.Context; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; +import android.os.AsyncTask; import android.os.Build; import android.support.annotation.Nullable; -import android.support.v4.os.BuildCompat; import android.util.Log; +import android.util.Pair; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.tuner.R; @@ -43,23 +44,31 @@ public class TunerInputInfoUtils { */ @Nullable @TargetApi(Build.VERSION_CODES.N) - public static TvInputInfo buildTunerInputInfo(Context context, boolean fromBuiltInTuner) { - int numOfDevices = TunerHal.getTunerCount(context); - if (numOfDevices == 0) { + public static TvInputInfo buildTunerInputInfo(Context context) { + Pair<Integer, Integer> tunerTypeAndCount = TunerHal.getTunerTypeAndCount(context); + if (tunerTypeAndCount.first == null || tunerTypeAndCount.second == 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); + int inputLabelId = 0; + switch (tunerTypeAndCount.first) { + case TunerHal.TUNER_TYPE_BUILT_IN: + inputLabelId = R.string.bt_app_name; + break; + case TunerHal.TUNER_TYPE_USB: + inputLabelId = R.string.ut_app_name; + break; + case TunerHal.TUNER_TYPE_NETWORK: + inputLabelId = R.string.nt_app_name; + break; } try { - return builder.setCanRecord(CommonFeatures.DVR.isEnabled(context)) - .setTunerCount(numOfDevices) + TvInputInfo.Builder builder = new TvInputInfo.Builder(context, + new ComponentName(context, TunerTvInputService.class)); + return builder.setLabel(inputLabelId) + .setCanRecord(CommonFeatures.DVR.isEnabled(context)) + .setTunerCount(tunerTypeAndCount.second) .build(); - } catch (NullPointerException e) { + } catch (IllegalArgumentException | NullPointerException e) { // TunerTvInputService is not enabled. return null; } @@ -71,30 +80,36 @@ public class TunerInputInfoUtils { * @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()); + final Context appContext = context.getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + new AsyncTask<Void, Void, TvInputInfo>() { + @Override + protected TvInputInfo doInBackground(Void... params) { + if (DEBUG) Log.d(TAG, "updateTunerInputInfo()"); + return buildTunerInputInfo(appContext); } - } else { - if (DEBUG) { - Log.d(TAG, "Updating tuner input's info failed. Input is not ready yet."); + + @Override + @TargetApi(Build.VERSION_CODES.N) + protected void onPostExecute(TvInputInfo info) { + if (info != null) { + ((TvInputManager) appContext.getSystemService(Context.TV_INPUT_SERVICE)) + .updateTvInputInfo(info); + if (DEBUG) { + Log.d( + TAG, + "TvInputInfo [" + + info.loadLabel(appContext) + + "] updated: " + + info.toString()); + } + } else { + if (DEBUG) { + Log.d(TAG, "Updating tuner input info failed. Input is not ready yet."); + } + } } - } + }.execute(); } } - - /** - * 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; - } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/ui/AppLayerTvView.java b/src/com/android/tv/ui/AppLayerTvView.java index 09acb36b..625014ea 100644 --- a/src/com/android/tv/ui/AppLayerTvView.java +++ b/src/com/android/tv/ui/AppLayerTvView.java @@ -22,7 +22,8 @@ import android.util.AttributeSet; import android.view.SurfaceView; import android.view.View; -import com.android.tv.experiments.Experiments; +import com.android.tv.util.Debug; +import com.android.tv.util.Utils; /** * A TvView class for application layer when multiple windows are being used in the app. @@ -55,8 +56,17 @@ public class AppLayerTvView extends TvView { public void onViewAdded(View child) { if (child instanceof SurfaceView) { // Note: See b/29118070 for detail. - ((SurfaceView) child).setSecure(!Experiments.ENABLE_DEVELOPER_FEATURES.get()); + ((SurfaceView) child).setSecure(!Utils.isDeveloper()); } super.onViewAdded(child); } + + @Override + public void getLocationOnScreen(int[] outLocation) { + super.getLocationOnScreen(outLocation); + + // The TvView.MySessionCallback.onSessionCreated() will call this method indirectly. + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "AppLayerTvView.getLocationOnScreen, session created"); + } } diff --git a/src/com/android/tv/ui/BlockScreenView.java b/src/com/android/tv/ui/BlockScreenView.java index 52b9389d..09c167ca 100644 --- a/src/com/android/tv/ui/BlockScreenView.java +++ b/src/com/android/tv/ui/BlockScreenView.java @@ -21,32 +21,37 @@ import android.animation.Animator.AnimatorListener; import android.animation.AnimatorInflater; import android.animation.AnimatorListenerAdapter; import android.content.Context; +import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; +import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.ImageView.ScaleType; -import android.widget.LinearLayout; import android.widget.TextView; import com.android.tv.R; import com.android.tv.ui.TunableTvView.BlockScreenType; -public class BlockScreenView extends LinearLayout { +public class BlockScreenView extends FrameLayout { private View mContainerView; private View mImageContainer; - private ImageView mNormalImageView; - private ImageView mShrunkenImageView; + private ImageView mNormalLockIconView; + private ImageView mShrunkenLockIconView; private View mSpace; - private TextView mTextView; + private TextView mBlockingInfoTextView; + private ImageView mBackgroundImageView; private final int mSpacingNormal; private final int mSpacingShrunken; - // Animators used for fade in/out of block screen icon. - private Animator mFadeIn; + // Animator used to fade out the whole block screen. private Animator mFadeOut; + // Animators used to fade in/out the block screen icon and info text. + private Animator mInfoFadeIn; + private Animator mInfoFadeOut; + public BlockScreenView(Context context) { this(context, null, 0); } @@ -68,21 +73,32 @@ public class BlockScreenView extends LinearLayout { super.onFinishInflate(); mContainerView = findViewById(R.id.block_screen_container); mImageContainer = findViewById(R.id.image_container); - mNormalImageView = (ImageView) findViewById(R.id.block_screen_icon); - mShrunkenImageView = (ImageView) findViewById(R.id.block_screen_shrunken_icon); + mNormalLockIconView = (ImageView) findViewById(R.id.block_screen_icon); + mShrunkenLockIconView = (ImageView) findViewById(R.id.block_screen_shrunken_icon); mSpace = findViewById(R.id.space); - mTextView = (TextView) findViewById(R.id.block_screen_text); - mFadeIn = AnimatorInflater.loadAnimator(getContext(), - R.animator.tvview_block_screen_fade_in); - mFadeIn.setTarget(mContainerView); + mBlockingInfoTextView = (TextView) findViewById(R.id.block_screen_text); + mBackgroundImageView = (ImageView) findViewById(R.id.background_image); mFadeOut = AnimatorInflater.loadAnimator(getContext(), R.animator.tvview_block_screen_fade_out); - mFadeOut.setTarget(mContainerView); + mFadeOut.setTarget(this); mFadeOut.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { + setVisibility(GONE); + setBackgroundImage(null); + setAlpha(1.0f); + } + }); + mInfoFadeIn = AnimatorInflater.loadAnimator(getContext(), + R.animator.tvview_block_screen_fade_in); + mInfoFadeIn.setTarget(mContainerView); + mInfoFadeOut = AnimatorInflater.loadAnimator(getContext(), + R.animator.tvview_block_screen_fade_out); + mInfoFadeOut.setTarget(mContainerView); + mInfoFadeOut.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { mContainerView.setVisibility(GONE); - mContainerView.setAlpha(1f); } }); } @@ -90,53 +106,54 @@ public class BlockScreenView extends LinearLayout { /** * Sets the normal image. */ - public void setImage(int resId) { - mNormalImageView.setImageResource(resId); + public void setIconImage(int resId) { + mNormalLockIconView.setImageResource(resId); updateSpaceVisibility(); } /** * Sets the scale type of the normal image. */ - public void setScaleType(ScaleType scaleType) { - mNormalImageView.setScaleType(scaleType); + public void setIconScaleType(ScaleType scaleType) { + mNormalLockIconView.setScaleType(scaleType); updateSpaceVisibility(); } /** - * Sets the shrunken image. + * Show or hide the image of this view. */ - public void setShrunkenImage(int resId) { - mShrunkenImageView.setImageResource(resId); + public void setIconVisibility(boolean visible) { + mImageContainer.setVisibility(visible ? VISIBLE : GONE); updateSpaceVisibility(); } /** - * Show or hide the image of this view. + * Sets the text message. */ - public void setImageVisibility(boolean visible) { - mImageContainer.setVisibility(visible ? VISIBLE : GONE); + public void setInfoText(int resId) { + mBlockingInfoTextView.setText(resId); updateSpaceVisibility(); } /** * Sets the text message. */ - public void setText(int resId) { - mTextView.setText(resId); + public void setInfoText(String text) { + mBlockingInfoTextView.setText(text); updateSpaceVisibility(); } /** - * Sets the text message. + * Sets the background image should be displayed in the block screen view. Passes {@code null} + * to remove the currently displayed background image. */ - public void setText(String text) { - mTextView.setText(text); - updateSpaceVisibility(); + public void setBackgroundImage(Drawable backgroundImage) { + mBackgroundImageView.setVisibility(backgroundImage == null ? GONE : VISIBLE); + mBackgroundImageView.setImageDrawable(backgroundImage); } private void updateSpaceVisibility() { - if (isImageViewVisible() && isTextViewVisible(mTextView)) { + if (isImageViewVisible() && isTextViewVisible(mBlockingInfoTextView)) { mSpace.setVisibility(VISIBLE); } else { mSpace.setVisibility(GONE); @@ -145,7 +162,8 @@ public class BlockScreenView extends LinearLayout { private boolean isImageViewVisible() { return mImageContainer.getVisibility() == VISIBLE - && (isImageViewVisible(mNormalImageView) || isImageViewVisible(mShrunkenImageView)); + && (isImageViewVisible(mNormalLockIconView) + || isImageViewVisible(mShrunkenLockIconView)); } private static boolean isImageViewVisible(ImageView imageView) { @@ -177,37 +195,39 @@ public class BlockScreenView extends LinearLayout { mContainerView.setVisibility(GONE); break; case TunableTvView.BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW: - mNormalImageView.setVisibility(GONE); - mShrunkenImageView.setVisibility(VISIBLE); + mNormalLockIconView.setVisibility(GONE); + mShrunkenLockIconView.setVisibility(VISIBLE); mContainerView.setVisibility(VISIBLE); + mContainerView.setAlpha(1.0f); break; case TunableTvView.BLOCK_SCREEN_TYPE_NORMAL: - mNormalImageView.setVisibility(VISIBLE); - mShrunkenImageView.setVisibility(GONE); + mNormalLockIconView.setVisibility(VISIBLE); + mShrunkenLockIconView.setVisibility(GONE); mContainerView.setVisibility(VISIBLE); + mContainerView.setAlpha(1.0f); break; } } else { switch (blockScreenType) { case TunableTvView.BLOCK_SCREEN_TYPE_NO_UI: if (mContainerView.getVisibility() == VISIBLE) { - mFadeOut.start(); + mInfoFadeOut.start(); } break; case TunableTvView.BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW: - mNormalImageView.setVisibility(GONE); - mShrunkenImageView.setVisibility(VISIBLE); - mContainerView.setVisibility(VISIBLE); + mNormalLockIconView.setVisibility(GONE); + mShrunkenLockIconView.setVisibility(VISIBLE); if (mContainerView.getVisibility() == GONE) { - mFadeIn.start(); + mContainerView.setVisibility(VISIBLE); + mInfoFadeIn.start(); } break; case TunableTvView.BLOCK_SCREEN_TYPE_NORMAL: - mNormalImageView.setVisibility(VISIBLE); - mShrunkenImageView.setVisibility(GONE); - mContainerView.setVisibility(VISIBLE); + mNormalLockIconView.setVisibility(VISIBLE); + mShrunkenLockIconView.setVisibility(GONE); if (mContainerView.getVisibility() == GONE) { - mFadeIn.start(); + mContainerView.setVisibility(VISIBLE); + mInfoFadeIn.start(); } break; } @@ -216,26 +236,33 @@ public class BlockScreenView extends LinearLayout { } /** - * Scales the contents view by the given {@code scale}. + * Adds a listener to the fade-in animation of info text and icons of the block screen. */ - public void scaleContainerView(float scale) { - mContainerView.setScaleX(scale); - mContainerView.setScaleY(scale); + public void addInfoFadeInAnimationListener(AnimatorListener listener) { + mInfoFadeIn.addListener(listener); } - public void addFadeOutAnimationListener(AnimatorListener listener) { - mFadeOut.addListener(listener); + /** + * Fades out the block screen. + */ + public void fadeOut() { + if (getVisibility() == VISIBLE && !mFadeOut.isStarted()) { + mFadeOut.start(); + } } /** * Ends the currently running animations. */ public void endAnimations() { - if (mFadeIn != null && mFadeIn.isRunning()) { - mFadeIn.end(); - } if (mFadeOut != null && mFadeOut.isRunning()) { mFadeOut.end(); } + if (mInfoFadeIn != null && mInfoFadeIn.isRunning()) { + mInfoFadeIn.end(); + } + if (mInfoFadeOut != null && mInfoFadeOut.isRunning()) { + mInfoFadeOut.end(); + } } } diff --git a/src/com/android/tv/ui/ChannelBannerView.java b/src/com/android/tv/ui/ChannelBannerView.java index 3cf4de83..a5d897f2 100644 --- a/src/com/android/tv/ui/ChannelBannerView.java +++ b/src/com/android/tv/ui/ChannelBannerView.java @@ -23,12 +23,9 @@ import android.animation.AnimatorSet; import android.animation.ValueAnimator; 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; import android.os.Handler; import android.support.annotation.Nullable; import android.text.Spannable; @@ -52,12 +49,13 @@ import android.widget.TextView; 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.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.dvr.data.ScheduledRecording; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.util.ImageCache; import com.android.tv.util.ImageLoader; @@ -65,10 +63,6 @@ import com.android.tv.util.ImageLoader.ImageLoaderCallback; import com.android.tv.util.ImageLoader.LoadTvInputLogoTask; import com.android.tv.util.Utils; -import junit.framework.Assert; - -import java.util.Objects; - /** * A view to render channel banner. */ @@ -98,8 +92,8 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private static final String EMPTY_STRING = ""; - private static Program sNoProgram; - private static Program sLockedChannelProgram; + private Program mNoProgram; + private Program mLockedChannelProgram; private static String sClosedCaptionMark; private final MainActivity mMainActivity; @@ -123,6 +117,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private String mProgramDescriptionText; private View mAnchorView; private Channel mCurrentChannel; + private boolean mCurrentChannelLogoExists; private Program mLastUpdatedProgram; private final Handler mHandler = new Handler(); private final DvrManager mDvrManager; @@ -130,6 +125,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private TvContentRating mBlockingContentRating; private int mLockType; + private boolean mUpdateOnTune; private Animator mResizeAnimator; private int mCurrentHeight; @@ -178,24 +174,6 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage } }; - private final ContentObserver mProgramUpdateObserver = new ContentObserver(mHandler) { - @Override - public void onChange(boolean selfChange, Uri uri) { - // TODO: This {@code uri} argument may be a program which is not related to this - // channel. Consider adding channel id as a parameter of program URI to avoid - // unnecessary update. - mHandler.post(mProgramUpdateRunnable); - } - }; - - private final Runnable mProgramUpdateRunnable = new Runnable() { - @Override - public void run() { - removeCallbacks(this); - updateViews(null); - } - }; - public ChannelBannerView(Context context) { this(context, null); } @@ -243,39 +221,20 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mContentRatingsManager = TvApplication.getSingletons(getContext()) .getTvInputManagerHelper().getContentRatingsManager(); - if (sNoProgram == null) { - sNoProgram = new Program.Builder() - .setTitle(context.getString(R.string.channel_banner_no_title)) - .setDescription(EMPTY_STRING) - .build(); - } - if (sLockedChannelProgram == null){ - sLockedChannelProgram = new Program.Builder() - .setTitle(context.getString(R.string.channel_banner_locked_channel_title)) - .setDescription(EMPTY_STRING) - .build(); - } + mNoProgram = new Program.Builder() + .setTitle(context.getString(R.string.channel_banner_no_title)) + .setDescription(EMPTY_STRING) + .build(); + mLockedChannelProgram = new Program.Builder() + .setTitle(context.getString(R.string.channel_banner_locked_channel_title)) + .setDescription(EMPTY_STRING) + .build(); if (sClosedCaptionMark == null) { sClosedCaptionMark = context.getString(R.string.closed_caption); } } @Override - protected void onAttachedToWindow() { - if (DEBUG) Log.d(TAG, "onAttachedToWindow"); - super.onAttachedToWindow(); - getContext().getContentResolver().registerContentObserver(TvContract.Programs.CONTENT_URI, - true, mProgramUpdateObserver); - } - - @Override - protected void onDetachedFromWindow() { - if (DEBUG) Log.d(TAG, "onDetachedToWindow"); - getContext().getContentResolver().unregisterContentObserver(mProgramUpdateObserver); - super.onDetachedFromWindow(); - } - - @Override protected void onFinishInflate() { super.onFinishInflate(); @@ -345,19 +304,17 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage * Set new lock type. * * @param lockType Any of LOCK_NONE, LOCK_PROGRAM_DETAIL, or LOCK_CHANNEL_INFO. - * @return {@code true} only if lock type is changed + * @return the previous lock type of the channel banner. * @throws IllegalArgumentException if lockType is invalid. */ - public boolean setLockType(int lockType) { + public int setLockType(int lockType) { if (lockType != LOCK_NONE && lockType != LOCK_CHANNEL_INFO && lockType != LOCK_PROGRAM_DETAIL) { throw new IllegalArgumentException("No such lock type " + lockType); } - if (mLockType != lockType) { - mLockType = lockType; - return true; - } - return false; + int previousLockType = mLockType; + mLockType = lockType; + return previousLockType; } /** @@ -372,31 +329,34 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage /** * Update channel banner view. * - * @param info A StreamInfo that includes stream information. - * If it's {@code null}, only program information will be updated. + * @param updateOnTune {@false} denotes the channel banner is updated due to other reasons than + * tuning. The channel info will not be updated in this case. */ - public void updateViews(StreamInfo info) { + public void updateViews(boolean updateOnTune) { resetAnimationEffects(); - Channel channel = mMainActivity.getCurrentChannel(); - if (!Objects.equals(mCurrentChannel, channel)) { - mBlockingContentRating = null; + mChannelView.setVisibility(VISIBLE); + mUpdateOnTune = updateOnTune; + if (mUpdateOnTune) { if (isShown()) { scheduleHide(); } - } - mCurrentChannel = channel; - mChannelView.setVisibility(VISIBLE); - if (info != null) { - // If the current channels between ChannelTuner and TvView are different, - // the stream information should not be seen. - updateStreamInfo(channel != null && channel.equals(info.getCurrentChannel()) ? info - : null); + mBlockingContentRating = null; + mCurrentChannel = mMainActivity.getCurrentChannel(); + mCurrentChannelLogoExists = + mCurrentChannel != null && mCurrentChannel.channelLogoExists(); + updateStreamInfo(null); updateChannelInfo(); } updateProgramInfo(mMainActivity.getCurrentProgram()); + mUpdateOnTune = false; } - private void updateStreamInfo(StreamInfo info) { + /** + * Update channel banner view with stream info. + * + * @param info A StreamInfo that includes stream information. + */ + public void updateStreamInfo(StreamInfo info) { // Update stream information in a channel. if (mLockType != LOCK_CHANNEL_INFO && info != null) { updateText(mClosedCaptionTextView, info.hasClosedCaption() ? sClosedCaptionMark @@ -414,9 +374,6 @@ 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); - } } } @@ -467,7 +424,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage } mChannelLogoImageView.setImageBitmap(null); mChannelLogoImageView.setVisibility(View.GONE); - if (mCurrentChannel != null) { + if (mCurrentChannel != null && mCurrentChannelLogoExists) { mCurrentChannel.loadBitmap(getContext(), Channel.LOAD_IMAGE_TYPE_CHANNEL_LOGO, mChannelLogoImageViewWidth, mChannelLogoImageViewHeight, createChannelLogoCallback(this, mCurrentChannel)); @@ -550,8 +507,9 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage if (mResizeAnimator == null) { String description = mProgramDescriptionTextView.getText().toString(); - boolean needFadeAnimation = !description.equals(mProgramDescriptionText); - updateBannerHeight(needFadeAnimation); + boolean programDescriptionNeedFadeAnimation = + !description.equals(mProgramDescriptionText) && !mUpdateOnTune; + updateBannerHeight(programDescriptionNeedFadeAnimation); } else { mProgramInfoUpdatePendingByResizing = true; } @@ -559,9 +517,9 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage private void updateProgramInfo(Program program) { if (mLockType == LOCK_CHANNEL_INFO) { - program = sLockedChannelProgram; + program = mLockedChannelProgram; } else if (program == null || !program.isValid() || TextUtils.isEmpty(program.getTitle())) { - program = sNoProgram; + program = mNoProgram; } if (mLastUpdatedProgram == null @@ -590,9 +548,9 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mProgramDescriptionText = program.getDescription(); } String description = mProgramDescriptionTextView.getText().toString(); - boolean needFadeAnimation = isProgramChanged - || !description.equals(mProgramDescriptionText); - updateBannerHeight(needFadeAnimation); + boolean programDescriptionNeedFadeAnimation = (isProgramChanged + || !description.equals(mProgramDescriptionText)) && !mUpdateOnTune; + updateBannerHeight(programDescriptionNeedFadeAnimation); } else { mProgramInfoUpdatePendingByResizing = true; } @@ -603,7 +561,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage if (program == null) { return; } - updateProgramTextView(program == sLockedChannelProgram, program.getTitle(), + updateProgramTextView(program == mLockedChannelProgram, program.getTitle(), program.getEpisodeDisplayTitle(getContext())); } @@ -630,9 +588,8 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); mProgramTextView.setText(text); } - int width = mProgramDescriptionTextViewWidth - - ((mChannelLogoImageView.getVisibility() != View.VISIBLE) - ? 0 : mChannelLogoImageViewWidth + mChannelLogoImageViewMarginStart); + int width = mProgramDescriptionTextViewWidth + (mCurrentChannelLogoExists ? + 0 : mChannelLogoImageViewWidth + mChannelLogoImageViewMarginStart); ViewGroup.LayoutParams lp = mProgramTextView.getLayoutParams(); lp.width = width; mProgramTextView.setLayoutParams(lp); @@ -655,23 +612,32 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage } 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++) { + if (mLockType == LOCK_CHANNEL_INFO) { + for (int i = 0; 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 if (mBlockingContentRating != null) { + String displayNameForRating = + mContentRatingsManager.getDisplayNameForRating(mBlockingContentRating); + if (!TextUtils.isEmpty(displayNameForRating)) { + mContentRatingsTextViews[0].setText(displayNameForRating); + mContentRatingsTextViews[0].setVisibility(View.VISIBLE); } else { - mContentRatingsTextViews[i].setText( - mContentRatingsManager.getDisplayNameForRating(ratings[i])); - mContentRatingsTextViews[i].setVisibility(View.VISIBLE); + mContentRatingsTextViews[0].setVisibility(View.GONE); + } + for (int i = 1; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) { + mContentRatingsTextViews[i].setVisibility(View.GONE); + } + } else { + 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); + } } } } @@ -769,8 +735,8 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage mLastUpdatedProgram = program; } - private void updateBannerHeight(boolean needFadeAnimation) { - Assert.assertNull(mResizeAnimator); + private void updateBannerHeight(boolean needProgramDescriptionFadeAnimation) { + SoftPreconditions.checkState(mResizeAnimator == null); // Need to measure the layout height with the new description text. CharSequence oldDescription = mProgramDescriptionTextView.getText(); mProgramDescriptionTextView.setText(mProgramDescriptionText); @@ -785,12 +751,13 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage layoutParams.height = targetHeight; setLayoutParams(layoutParams); } - } else if (mCurrentHeight != targetHeight || needFadeAnimation) { + } else if (mCurrentHeight != targetHeight || needProgramDescriptionFadeAnimation) { // Restore description text for fade in/out animation. - if (needFadeAnimation) { + if (needProgramDescriptionFadeAnimation) { mProgramDescriptionTextView.setText(oldDescription); } - mResizeAnimator = createResizeAnimator(targetHeight, needFadeAnimation); + mResizeAnimator = + createResizeAnimator(targetHeight, needProgramDescriptionFadeAnimation); mResizeAnimator.start(); } } diff --git a/src/com/android/tv/ui/KeypadChannelSwitchView.java b/src/com/android/tv/ui/KeypadChannelSwitchView.java index abc05bad..ac5d841d 100644 --- a/src/com/android/tv/ui/KeypadChannelSwitchView.java +++ b/src/com/android/tv/ui/KeypadChannelSwitchView.java @@ -39,7 +39,7 @@ import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.analytics.DurationTimer; +import com.android.tv.util.DurationTimer; import com.android.tv.analytics.Tracker; import com.android.tv.common.SoftPreconditions; import com.android.tv.data.Channel; diff --git a/src/com/android/tv/ui/SelectInputView.java b/src/com/android/tv/ui/SelectInputView.java index 5e25ae43..dc92111c 100644 --- a/src/com/android/tv/ui/SelectInputView.java +++ b/src/com/android/tv/ui/SelectInputView.java @@ -18,7 +18,6 @@ package com.android.tv.ui; import android.content.Context; import android.content.res.Resources; -import android.hardware.hdmi.HdmiDeviceInfo; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; @@ -37,14 +36,13 @@ import android.widget.TextView; 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.util.DurationTimer; import com.android.tv.analytics.Tracker; import com.android.tv.data.Channel; import com.android.tv.util.TvInputManagerHelper; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -58,7 +56,7 @@ public class SelectInputView extends VerticalGridView implements private final TvInputManagerHelper mTvInputManagerHelper; private final List<TvInputInfo> mInputList = new ArrayList<>(); - private final InputsComparator mComparator = new InputsComparator(); + private final TvInputManagerHelper.HardwareInputComparator mComparator; private final Tracker mTracker; private final DurationTimer mViewDurationTimer = new DurationTimer(); private final TvInputCallback mTvInputCallback = new TvInputCallback() { @@ -149,6 +147,8 @@ public class SelectInputView extends VerticalGridView implements ApplicationSingletons appSingletons = TvApplication.getSingletons(context); mTracker = appSingletons.getTracker(); mTvInputManagerHelper = appSingletons.getTvInputManagerHelper(); + mComparator = + new TvInputManagerHelper.HardwareInputComparator(context, mTvInputManagerHelper); Resources resources = context.getResources(); mInputItemHeight = resources.getDimensionPixelSize(R.dimen.input_banner_item_height); @@ -385,72 +385,6 @@ public class SelectInputView extends VerticalGridView implements } } - private class InputsComparator implements Comparator<TvInputInfo> { - @Override - public int compare(TvInputInfo lhs, TvInputInfo rhs) { - if (lhs == null) { - return (rhs == null) ? 0 : 1; - } - if (rhs == null) { - return -1; - } - - boolean enabledL = isInputEnabled(lhs); - boolean enabledR = isInputEnabled(rhs); - if (enabledL != enabledR) { - return enabledL ? -1 : 1; - } - - int priorityL = getPriority(lhs); - int priorityR = getPriority(rhs); - if (priorityL != priorityR) { - return priorityR - priorityL; - } - - String customLabelL = (String) lhs.loadCustomLabel(getContext()); - String customLabelR = (String) rhs.loadCustomLabel(getContext()); - if (!TextUtils.equals(customLabelL, customLabelR)) { - customLabelL = customLabelL == null ? "" : customLabelL; - customLabelR = customLabelR == null ? "" : customLabelR; - return customLabelL.compareToIgnoreCase(customLabelR); - } - - String labelL = (String) lhs.loadLabel(getContext()); - String labelR = (String) rhs.loadLabel(getContext()); - labelL = labelL == null ? "" : labelL; - labelR = labelR == null ? "" : labelR; - return labelL.compareToIgnoreCase(labelR); - } - - private int getPriority(TvInputInfo info) { - switch (info.getType()) { - case TvInputInfo.TYPE_TUNER: - return 9; - case TvInputInfo.TYPE_HDMI: - HdmiDeviceInfo hdmiInfo = info.getHdmiDeviceInfo(); - if (hdmiInfo != null && hdmiInfo.isCecDevice()) { - return 8; - } - return 7; - case TvInputInfo.TYPE_DVI: - return 6; - case TvInputInfo.TYPE_COMPONENT: - return 5; - case TvInputInfo.TYPE_SVIDEO: - return 4; - case TvInputInfo.TYPE_COMPOSITE: - return 3; - case TvInputInfo.TYPE_DISPLAY_PORT: - return 2; - case TvInputInfo.TYPE_VGA: - return 1; - case TvInputInfo.TYPE_SCART: - default: - return 0; - } - } - } - /** * A callback interface for the input selection. */ diff --git a/src/com/android/tv/ui/TunableTvView.java b/src/com/android/tv/ui/TunableTvView.java index cbe459fb..48386698 100644 --- a/src/com/android/tv/ui/TunableTvView.java +++ b/src/com/android/tv/ui/TunableTvView.java @@ -19,9 +19,18 @@ package com.android.tv.ui; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.TimeInterpolator; -import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ApplicationErrorReport; import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; import android.media.PlaybackParams; import android.media.tv.TvContentRating; import android.media.tv.TvInputInfo; @@ -33,12 +42,11 @@ 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; @@ -47,23 +55,29 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceView; import android.view.View; -import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import com.android.tv.ApplicationSingletons; +import com.android.tv.Features; 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.data.Program; +import com.android.tv.data.ProgramDataManager; +import com.android.tv.parental.ParentalControlSettings; +import com.android.tv.util.DurationTimer; +import com.android.tv.util.Debug; import com.android.tv.analytics.Tracker; +import com.android.tv.common.BuildConfig; import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.Channel; import com.android.tv.data.StreamInfo; import com.android.tv.data.WatchedHistoryManager; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.recommendation.NotificationService; +import com.android.tv.util.ImageLoader; import com.android.tv.util.NetworkUtils; import com.android.tv.util.PermissionUtils; import com.android.tv.util.TvInputManagerHelper; @@ -79,6 +93,8 @@ public class TunableTvView extends FrameLayout implements StreamInfo { public static final int VIDEO_UNAVAILABLE_REASON_NOT_TUNED = -1; public static final int VIDEO_UNAVAILABLE_REASON_NO_RESOURCE = -2; + public static final int VIDEO_UNAVAILABLE_REASON_SCREEN_BLOCKED = -3; + public static final int VIDEO_UNAVAILABLE_REASON_NONE = -100; @Retention(RetentionPolicy.SOURCE) @IntDef({BLOCK_SCREEN_TYPE_NO_UI, BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW, BLOCK_SCREEN_TYPE_NORMAL}) @@ -105,17 +121,17 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private static final int FADING_IN = 2; private static final int FADING_OUT = 3; - // 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 TvInputManagerHelper mInputManagerHelper; private ContentRatingsManager mContentRatingsManager; + private ParentalControlSettings mParentalControlSettings; + private ProgramDataManager mProgramDataManager; @Nullable private WatchedHistoryManager mWatchedHistoryManager; private boolean mStarted; + private String mTagetInputId; private TvInputInfo mInputInfo; private OnTuneListener mOnTuneListener; private int mVideoWidth; @@ -125,7 +141,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private float mVideoDisplayAspectRatio; private int mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; private boolean mHasClosedCaption = false; - private boolean mVideoAvailable; private boolean mScreenBlocked; private OnScreenBlockingChangedListener mOnScreenBlockedListener; private TvContentRating mBlockedContentRating; @@ -136,10 +151,8 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private boolean mParentControlEnabled; private int mFixedSurfaceWidth; private int mFixedSurfaceHeight; - private boolean mIsPip; - private int mScreenHeight; - private int mShrunkenTvViewHeight; private final boolean mCanModifyParentalControls; + private boolean mIsUnderShrunken; @TimeShiftState private int mTimeShiftState = TIME_SHIFT_STATE_NONE; private TimeShiftListener mTimeShiftListener; @@ -150,21 +163,16 @@ public class TunableTvView extends FrameLayout implements StreamInfo { private final DurationTimer mChannelViewTimer = new DurationTimer(); private InternetCheckTask mInternetCheckTask; - // A block screen view which has lock icon with black background. - // This indicates that user's action is needed to play video. + // A block screen view to hide the real TV view underlying. It may be used to enforce parental + // control, or hide screen when there's no video available and show appropriate information. private final BlockScreenView mBlockScreenView; - - // A View to hide screen when there's problem in video playback. - private final BlockScreenView mHideScreenView; - - // A View to block screen until onContentAllowed is received if parental control is on. - private final View mBlockScreenForTuneView; + private final int mTuningImageColorFilter; // A spinner view to show buffering status. private final View mBufferingSpinnerView; - // A View for fade-in/out animation private final View mDimScreenView; + private int mFadeState = FADED_IN; private Runnable mActionAfterFade; @@ -286,21 +294,77 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override public void onVideoAvailable(String inputId) { - unhideScreenByVideoAvailability(); + if (DEBUG) Log.d(TAG, "onVideoAvailable: {inputId=" + inputId + "}"); + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("Start up of Live TV ends," + + " TunableTvView.onVideoAvailable resets timer"); + long startUpDurationTime = Debug.getTimer(Debug.TAG_START_UP_TIMER).reset(); + Debug.removeTimer(Debug.TAG_START_UP_TIMER); + if (BuildConfig.ENG && startUpDurationTime > Debug.TIME_START_UP_DURATION_THRESHOLD) { + showAlertDialogForLongStartUp(); + } + mVideoUnavailableReason = VIDEO_UNAVAILABLE_REASON_NONE; + updateBlockScreenAndMuting(); if (mOnTuneListener != null) { mOnTuneListener.onStreamInfoChanged(TunableTvView.this); } } + private void showAlertDialogForLongStartUp() { + new AlertDialog.Builder(getContext()).setTitle( + getContext().getString(R.string.settings_send_feedback)) + .setMessage("Because the start up time of Live channels is too long," + + " please send feedback") + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + Intent intent = new Intent(Intent.ACTION_APP_ERROR); + ApplicationErrorReport report = new ApplicationErrorReport(); + report.packageName = report.processName = getContext() + .getApplicationContext().getPackageName(); + report.time = System.currentTimeMillis(); + report.type = ApplicationErrorReport.TYPE_CRASH; + + // Add the crash info to add title of feedback automatically. + ApplicationErrorReport.CrashInfo crash = new + ApplicationErrorReport.CrashInfo(); + crash.exceptionClassName = + "Live TV start up takes long time"; + crash.exceptionMessage = + "The start up time of Live TV is too long"; + report.crashInfo = crash; + + intent.putExtra(Intent.EXTRA_BUG_REPORT, report); + getContext().startActivity(intent); + } + }) + .setNegativeButton(android.R.string.no, null) + .show(); + } + @Override public void onVideoUnavailable(String inputId, int reason) { - hideScreenByVideoAvailability(inputId, reason); + if (reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING + && reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING) { + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "TunableTvView.onVideoUnAvailable reason = (" + reason + + ") and removes timer"); + Debug.removeTimer(Debug.TAG_START_UP_TIMER); + } else { + Debug.getTimer(Debug.TAG_START_UP_TIMER).log( + "TunableTvView.onVideoUnAvailable reason = (" + reason + ")"); + } + mVideoUnavailableReason = reason; + if (closePipIfNeeded()) { + return; + } + updateBlockScreenAndMuting(); 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_BUFFERING: case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason); default: @@ -310,8 +374,8 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override public void onContentAllowed(String inputId) { - mBlockScreenForTuneView.setVisibility(View.GONE); - unblockScreenByContentRating(); + mBlockedContentRating = null; + updateBlockScreenAndMuting(); if (mOnTuneListener != null) { mOnTuneListener.onContentAllowed(); } @@ -319,7 +383,14 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override public void onContentBlocked(String inputId, TvContentRating rating) { - blockScreenByContentRating(rating); + if (rating != null && rating.equals(mBlockedContentRating)) { + return; + } + mBlockedContentRating = rating; + if (closePipIfNeeded()) { + return; + } + updateBlockScreenAndMuting(); if (mOnTuneListener != null) { mOnTuneListener.onContentBlocked(); } @@ -327,6 +398,10 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override public void onTimeShiftStatusChanged(String inputId, int status) { + if (DEBUG) { + Log.d(TAG, "onTimeShiftStatusChanged: {inputId=" + inputId + ", status=" + status + + "}"); + } boolean available = status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE; setTimeShiftAvailable(available); } @@ -361,25 +436,17 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mTracker = appSingletons.getTracker(); mBlockScreenType = BLOCK_SCREEN_TYPE_NORMAL; mBlockScreenView = (BlockScreenView) findViewById(R.id.block_screen); - if (!mCanModifyParentalControls) { - mBlockScreenView.setImage(R.drawable.ic_message_lock_no_permission); - mBlockScreenView.setScaleType(ImageView.ScaleType.CENTER); - } else { - mBlockScreenView.setImage(R.drawable.ic_message_lock); - } - mBlockScreenView.setShrunkenImage(R.drawable.ic_message_lock_preview); - mBlockScreenView.addFadeOutAnimationListener(new AnimatorListenerAdapter() { + mBlockScreenView.addInfoFadeInAnimationListener(new AnimatorListenerAdapter() { @Override - public void onAnimationEnd(Animator animation) { + public void onAnimationStart(Animator animation) { adjustBlockScreenSpacingAndText(); } }); - mHideScreenView = (BlockScreenView) findViewById(R.id.hide_screen); - mHideScreenView.setImageVisibility(false); mBufferingSpinnerView = findViewById(R.id.buffering_spinner); - mBlockScreenForTuneView = findViewById(R.id.block_screen_for_tune); - mDimScreenView = findViewById(R.id.dim); + mTuningImageColorFilter = getResources() + .getColor(R.color.tvview_block_image_color_filter, null); + mDimScreenView = findViewById(R.id.dim_screen); mDimScreenView.animate().setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { @@ -397,27 +464,21 @@ public class TunableTvView extends FrameLayout implements StreamInfo { }); } - public void initialize(AppLayerTvView tvView, boolean isPip, int screenHeight, - int shrunkenTvViewHeight) { - mTvView = tvView; + public void initialize(ProgramDataManager programDataManager, + TvInputManagerHelper tvInputManagerHelper) { + mTvView = (AppLayerTvView) findViewById(R.id.tv_view); + mProgramDataManager = programDataManager; + mInputManagerHelper = tvInputManagerHelper; + mContentRatingsManager = tvInputManagerHelper.getContentRatingsManager(); + mParentalControlSettings = tvInputManagerHelper.getParentalControlSettings(); if (mInputSessionManager != null) { - mTvViewSession = mInputSessionManager.createTvViewSession(tvView, this, mCallback); + mTvViewSession = mInputSessionManager.createTvViewSession(mTvView, this, mCallback); } else { mTvView.setCallback(mCallback); } - mIsPip = isPip; - mScreenHeight = screenHeight; - mShrunkenTvViewHeight = shrunkenTvViewHeight; - mTvView.setZOrderOnTop(isPip); - copyLayoutParamsToTvView(); } - public void start(TvInputManagerHelper tvInputManagerHelper) { - mInputManagerHelper = tvInputManagerHelper; - mContentRatingsManager = tvInputManagerHelper.getContentRatingsManager(); - if (mStarted) { - return; - } + public void start() { mStarted = true; } @@ -431,7 +492,8 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } else { mTvView.tune(inputId, channelUri); } - hideScreenByVideoAvailability(inputId, TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + mVideoUnavailableReason = TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING; + updateBlockScreenAndMuting(); } } @@ -462,15 +524,16 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } /** - * Reset TV view. + * Resets TV view. */ public void reset() { resetInternal(); - hideScreenByVideoAvailability(null, VIDEO_UNAVAILABLE_REASON_NOT_TUNED); + mVideoUnavailableReason = VIDEO_UNAVAILABLE_REASON_NOT_TUNED; + updateBlockScreenAndMuting(); } /** - * Reset TV view to acquire the recording session. + * Resets TV view to acquire the recording session. */ public void resetByRecording() { resetInternal(); @@ -497,6 +560,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mWatchedHistoryManager = watchedHistoryManager; } + /** + * Sets if the TunableTvView is under shrunken. + */ + public void setIsUnderShrunken(boolean isUnderShrunken) { + mIsUnderShrunken = isUnderShrunken; + } + public boolean isPlaying() { return mStarted; } @@ -506,8 +576,9 @@ public class TunableTvView extends FrameLayout implements StreamInfo { */ public void onParentalControlChanged(boolean enabled) { mParentControlEnabled = enabled; - if (!mParentControlEnabled) { - mBlockScreenForTuneView.setVisibility(View.GONE); + if (!enabled) { + // Unblock screen immediately if parental control is turned off + updateBlockScreenAndMuting(); } } @@ -519,6 +590,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * if the state is disconnected or channelId doesn't exist, it returns false. */ public boolean tuneTo(Channel channel, Bundle params, OnTuneListener listener) { + Debug.getTimer(Debug.TAG_START_UP_TIMER).log("TunableTvView.tuneTo"); if (!mStarted) { throw new IllegalStateException("TvView isn't started"); } @@ -541,6 +613,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { && params.getString(NotificationService.TUNE_PARAMS_RECOMMENDATION_TYPE) != null; boolean needSurfaceSizeUpdate = false; if (!inputInfo.equals(mInputInfo)) { + mTagetInputId = inputInfo.getId(); mInputInfo = inputInfo; mCanReceiveInputEvent = getContext().getPackageManager().checkPermission( PERMISSION_RECEIVE_INPUT_EVENT, mInputInfo.getServiceInfo().packageName) @@ -560,6 +633,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo { mVideoDisplayAspectRatio = 0f; mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN; mHasClosedCaption = false; + mBlockedContentRating = null; mTimeShiftCurrentPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME; // To reduce the IPCs, unregister the callback here and register it when necessary. mTvView.setTimeShiftPositionCallback(null); @@ -569,19 +643,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo { // So we need to call SurfaceHolder.setFixedSize for the new SurfaceView. getSurfaceView().getHolder().setFixedSize(mFixedSurfaceWidth, mFixedSurfaceHeight); } - hideScreenByVideoAvailability(mInputInfo.getId(), - TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING); + mVideoUnavailableReason = 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); - } else if (mParentControlEnabled) { - mBlockScreenForTuneView.setVisibility(View.VISIBLE); - } + updateBlockScreenAndMuting(); if (mOnTuneListener != null) { mOnTuneListener.onStreamInfoChanged(this); } @@ -711,7 +779,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override public boolean isVideoAvailable() { - return mVideoAvailable; + return mVideoUnavailableReason == VIDEO_UNAVAILABLE_REASON_NONE; + } + + @Override + public boolean isVideoOrAudioAvailable() { + return mVideoUnavailableReason == VIDEO_UNAVAILABLE_REASON_NONE + || mVideoUnavailableReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY; } @Override @@ -747,12 +821,50 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } /** - * Returns if the screen is blocked by {@link #blockScreen()}. + * Gets {@link android.view.ViewGroup.MarginLayoutParams} of the underlying + * {@link TvView}, which is the actual view to play live TV videos. + */ + public MarginLayoutParams getTvViewLayoutParams() { + return (MarginLayoutParams) mTvView.getLayoutParams(); + } + + /** + * Sets {@link android.view.ViewGroup.MarginLayoutParams} of the underlying + * {@link TvView}, which is the actual view to play live TV videos. + */ + public void setTvViewLayoutParams(MarginLayoutParams layoutParams) { + mTvView.setLayoutParams(layoutParams); + } + + /** + * Gets the underlying {@link AppLayerTvView}, which is the actual view to play live TV videos. + */ + public TvView getTvView() { + return mTvView; + } + + /** + * Returns if the screen is blocked, either by {@link #blockOrUnblockScreen(boolean)} or because + * the content is blocked. + */ + public boolean isBlocked() { + return isScreenBlocked() || isContentBlocked(); + } + + /** + * Returns if the screen is blocked by {@link #blockOrUnblockScreen(boolean)}. */ public boolean isScreenBlocked() { return mScreenBlocked; } + /** + * Returns {@code true} if the content is blocked, otherwise {@code false}. + */ + public boolean isContentBlocked() { + return mBlockedContentRating != null; + } + public void setOnScreenBlockedListener(OnScreenBlockingChangedListener listener) { mOnScreenBlockedListener = listener; } @@ -766,77 +878,23 @@ public class TunableTvView extends FrameLayout implements StreamInfo { } /** - * Locks current TV screen and mutes. + * Blocks/unblocks current TV screen and mutes. * There would be black screen with lock icon in order to show that * screen block is intended and not an error. - * TODO: Accept parameter to show lock icon or not. + * + * @param blockOrUnblock {@code true} to block the screen, or {@code false} to unblock. */ - public void blockScreen() { - mScreenBlocked = true; - checkBlockScreenAndMuteNeeded(); - if (mOnScreenBlockedListener != null) { - mOnScreenBlockedListener.onScreenBlockingChanged(true); - } - } - - private void blockScreenByContentRating(TvContentRating rating) { - mBlockedContentRating = rating; - checkBlockScreenAndMuteNeeded(); - } - - @Override - @SuppressLint("RtlHardcoded") - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - if (mIsPip) { - int height = bottom - top; - float scale; - if (mBlockScreenType == BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW) { - scale = height * PIP_BLOCK_SCREEN_SCALE_FACTOR / mShrunkenTvViewHeight; - } else { - scale = height * PIP_BLOCK_SCREEN_SCALE_FACTOR / mScreenHeight; - } - // TODO: need to get UX confirmation. - mBlockScreenView.scaleContainerView(scale); + public void blockOrUnblockScreen(boolean blockOrUnblock) { + if (mScreenBlocked == blockOrUnblock) { + return; } - } - - @Override - public void setLayoutParams(ViewGroup.LayoutParams params) { - super.setLayoutParams(params); - if (mTvView != null) { - copyLayoutParamsToTvView(); + mScreenBlocked = blockOrUnblock; + if (closePipIfNeeded()) { + return; } - } - - private void copyLayoutParamsToTvView() { - FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams(); - FrameLayout.LayoutParams tvViewLp = (FrameLayout.LayoutParams) mTvView.getLayoutParams(); - if (tvViewLp.bottomMargin != lp.bottomMargin - || tvViewLp.topMargin != lp.topMargin - || tvViewLp.leftMargin != lp.leftMargin - || tvViewLp.rightMargin != lp.rightMargin - || tvViewLp.gravity != lp.gravity - || tvViewLp.height != lp.height - || tvViewLp.width != lp.width) { - 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; - } - tvViewLp.topMargin = lp.topMargin; - tvViewLp.bottomMargin = lp.bottomMargin; - tvViewLp.rightMargin = lp.rightMargin; - tvViewLp.gravity = lp.gravity; - tvViewLp.height = lp.height; - tvViewLp.width = lp.width; - mTvView.setLayoutParams(tvViewLp); + updateBlockScreenAndMuting(); + if (mOnScreenBlockedListener != null) { + mOnScreenBlockedListener.onScreenBlockingChanged(blockOrUnblock); } } @@ -859,35 +917,67 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * @param type The type of block screen to set. */ public void setBlockScreenType(@BlockScreenType int type) { - // TODO: need to support the transition from NORMAL to SHRUNKEN and vice verse. if (mBlockScreenType != type) { mBlockScreenType = type; - updateBlockScreenUI(true); + updateBlockScreen(true); } } - private void updateBlockScreenUI(boolean animation) { + private void updateBlockScreen(boolean animation) { mBlockScreenView.endAnimations(); - - if (!mScreenBlocked && mBlockedContentRating == null) { - mBlockScreenView.setVisibility(GONE); - return; - } - - mBlockScreenView.setVisibility(VISIBLE); - if (!animation || mBlockScreenType != TunableTvView.BLOCK_SCREEN_TYPE_NO_UI) { - adjustBlockScreenSpacingAndText(); + int blockReason = (mScreenBlocked || mBlockedContentRating != null) + && mParentControlEnabled ? VIDEO_UNAVAILABLE_REASON_SCREEN_BLOCKED + : mVideoUnavailableReason; + if (blockReason != VIDEO_UNAVAILABLE_REASON_NONE) { + mBufferingSpinnerView.setVisibility( + blockReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING + || blockReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING ? + VISIBLE : GONE); + if (!animation) { + adjustBlockScreenSpacingAndText(); + } + if (blockReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING) { + return; + } + mBlockScreenView.setVisibility(VISIBLE); + mBlockScreenView.setBackgroundImage(null); + if (blockReason == VIDEO_UNAVAILABLE_REASON_SCREEN_BLOCKED) { + mBlockScreenView.setIconVisibility(true); + if (!mCanModifyParentalControls) { + mBlockScreenView.setIconImage(R.drawable.ic_message_lock_no_permission); + mBlockScreenView.setIconScaleType(ImageView.ScaleType.CENTER); + } else { + mBlockScreenView.setIconImage(R.drawable.ic_message_lock); + mBlockScreenView.setIconScaleType(ImageView.ScaleType.FIT_CENTER); + } + } else { + if (mInternetCheckTask != null) { + mInternetCheckTask.cancel(true); + mInternetCheckTask = null; + } + mBlockScreenView.setIconVisibility(false); + if (blockReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING) { + showImageForTuningIfNeeded(); + } else if (blockReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN + && mCurrentChannel != null && !mCurrentChannel.isPhysicalTunerChannel()) { + mInternetCheckTask = new InternetCheckTask(); + mInternetCheckTask.execute(); + } + } + mBlockScreenView.onBlockStatusChanged(mBlockScreenType, animation); + } else { + mBufferingSpinnerView.setVisibility(GONE); + if (mBlockScreenView.getVisibility() == VISIBLE) { + mBlockScreenView.fadeOut(); + } } - mBlockScreenView.onBlockStatusChanged(mBlockScreenType, animation); } private void adjustBlockScreenSpacingAndText() { - // TODO: need to add animation for padding change when the block screen type is changed - // NORMAL to SHRUNKEN and vice verse. mBlockScreenView.setSpacing(mBlockScreenType); String text = getBlockScreenText(); if (text != null) { - mBlockScreenView.setText(text); + mBlockScreenView.setInfoText(text); } } @@ -896,151 +986,121 @@ public class TunableTvView extends FrameLayout implements StreamInfo { * Note that returning {@code null} value means that the current text should not be changed. */ private String getBlockScreenText() { - if (mScreenBlocked) { + // TODO: add a test for this method + Resources res = getResources(); + if (mScreenBlocked && mParentControlEnabled) { switch (mBlockScreenType) { case BLOCK_SCREEN_TYPE_NO_UI: case BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW: return ""; case BLOCK_SCREEN_TYPE_NORMAL: if (mCanModifyParentalControls) { - return getResources().getString(R.string.tvview_channel_locked); + return res.getString(R.string.tvview_channel_locked); } else { - return getResources().getString( - R.string.tvview_channel_locked_no_permission); + return res.getString(R.string.tvview_channel_locked_no_permission); } } - } else if (mBlockedContentRating != null) { + } else if (mBlockedContentRating != null && mParentControlEnabled) { String name = mContentRatingsManager.getDisplayNameForRating(mBlockedContentRating); switch (mBlockScreenType) { case BLOCK_SCREEN_TYPE_NO_UI: return ""; case BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW: if (TextUtils.isEmpty(name)) { - return getResources().getString(R.string.shrunken_tvview_content_locked); + return res.getString(R.string.shrunken_tvview_content_locked); + } else if (name.equals(res.getString(R.string.unrated_rating_name))) { + return res.getString(R.string.shrunken_tvview_content_locked_unrated); } else { - return getContext().getString( - R.string.shrunken_tvview_content_locked_format, name); + return res.getString(R.string.shrunken_tvview_content_locked_format, name); } case BLOCK_SCREEN_TYPE_NORMAL: if (TextUtils.isEmpty(name)) { if (mCanModifyParentalControls) { - return getResources().getString(R.string.tvview_content_locked); + return res.getString(R.string.tvview_content_locked); } else { - return getResources().getString( - R.string.tvview_content_locked_no_permission); + return res.getString(R.string.tvview_content_locked_no_permission); } } else { if (mCanModifyParentalControls) { - return getContext().getString( - R.string.tvview_content_locked_format, name); + return name.equals(res.getString(R.string.unrated_rating_name)) + ? res.getString(R.string.tvview_content_locked_unrated) + : res.getString(R.string.tvview_content_locked_format, name); } else { - return getContext().getString( - R.string.tvview_content_locked_format_no_permission, name); + return name.equals(res.getString(R.string.unrated_rating_name)) + ? res.getString( + R.string.tvview_content_locked_unrated_no_permission) + : res.getString( + R.string.tvview_content_locked_format_no_permission, + name); } } } + } else if (mVideoUnavailableReason != VIDEO_UNAVAILABLE_REASON_NONE) { + switch (mVideoUnavailableReason) { + case TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY: + return res.getString(R.string.tvview_msg_audio_only); + case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: + return res.getString(R.string.tvview_msg_weak_signal); + case VIDEO_UNAVAILABLE_REASON_NO_RESOURCE: + return getTuneConflictMessage(); + default: + return ""; + } } return null; } - private void checkBlockScreenAndMuteNeeded() { - updateBlockScreenUI(false); - if (mScreenBlocked || mBlockedContentRating != null) { - mute(); - if (mIsPip) { - // If we don't make mTvView invisible, some frames are leaked when a user changes - // PIP layout in options. - // Note: When video is unavailable, we keep the mTvView's visibility, because - // TIS implementation may not send video available with no surface. - mTvView.setVisibility(View.INVISIBLE); - } - } else { - unmuteIfPossible(); - if (mIsPip) { - mTvView.setVisibility(View.VISIBLE); - } + private boolean closePipIfNeeded() { + if (Features.PICTURE_IN_PICTURE.isEnabled(getContext()) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && ((Activity) getContext()).isInPictureInPictureMode() + && (mScreenBlocked + || mBlockedContentRating != null + || mVideoUnavailableReason + == TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN)) { + ((Activity) getContext()).finish(); + return true; } + return false; } - public void unblockScreen() { - mScreenBlocked = false; - checkBlockScreenAndMuteNeeded(); - if (mOnScreenBlockedListener != null) { - mOnScreenBlockedListener.onScreenBlockingChanged(false); - } + private void updateBlockScreenAndMuting() { + updateBlockScreen(false); + updateMuteStatus(); } - private void unblockScreenByContentRating() { - mBlockedContentRating = null; - checkBlockScreenAndMuteNeeded(); + private boolean shouldShowImageForTuning() { + if (mVideoUnavailableReason != TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING + || mScreenBlocked || mBlockedContentRating != null || mCurrentChannel == null + || mIsUnderShrunken || getWidth() == 0 || getWidth() == 0 || !isBundledInput()) { + return false; + } + Program currentProgram = mProgramDataManager.getCurrentProgram(mCurrentChannel.getId()); + if (currentProgram == null) { + return false; + } + TvContentRating rating = + mParentalControlSettings.getBlockedRating(currentProgram.getContentRatings()); + return !(mParentControlEnabled && rating != null); } - @UiThread - private void hideScreenByVideoAvailability(String inputId, int reason) { - mVideoAvailable = false; - mVideoUnavailableReason = reason; - if (mInternetCheckTask != null) { - mInternetCheckTask.cancel(true); - mInternetCheckTask = null; - } - switch (reason) { - case TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY: - mHideScreenView.setVisibility(VISIBLE); - mHideScreenView.setImageVisibility(false); - mHideScreenView.setText(R.string.tvview_msg_audio_only); - mBufferingSpinnerView.setVisibility(GONE); - unmuteIfPossible(); - break; - case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING: - mBufferingSpinnerView.setVisibility(VISIBLE); - mute(); - break; - case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL: - mHideScreenView.setVisibility(VISIBLE); - mHideScreenView.setText(R.string.tvview_msg_weak_signal); - mBufferingSpinnerView.setVisibility(GONE); - 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); - mHideScreenView.setText(null); - 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); - mHideScreenView.setImageVisibility(false); - mHideScreenView.setText(null); - mBufferingSpinnerView.setVisibility(GONE); - mute(); - if (mCurrentChannel != null && !mCurrentChannel.isPhysicalTunerChannel()) { - mInternetCheckTask = new InternetCheckTask(); - mInternetCheckTask.execute(); - } - break; + private void showImageForTuningIfNeeded() { + if (shouldShowImageForTuning()) { + if (mCurrentChannel == null) { + return; + } + Program currentProgram = mProgramDataManager.getCurrentProgram(mCurrentChannel.getId()); + if (currentProgram != null) { + currentProgram.loadPosterArt(getContext(), getWidth(), getHeight(), + createProgramPosterArtCallback(mCurrentChannel.getId())); + } } } - private String getTuneConflictMessage(String inputId) { - if (inputId != null) { - TvInputInfo input = mInputManager.getTvInputInfo(inputId); - Long timeMs = mInputSessionManager.getEarliestRecordingSessionEndTimeMs(inputId); + private String getTuneConflictMessage() { + if (mTagetInputId != null) { + TvInputInfo input = mInputManager.getTvInputInfo(mTagetInputId); + Long timeMs = mInputSessionManager.getEarliestRecordingSessionEndTimeMs(mTagetInputId); if (timeMs != null) { return getResources().getQuantityString(R.plurals.tvview_msg_input_no_resource, input.getTunerCount(), @@ -1050,27 +1110,36 @@ public class TunableTvView extends FrameLayout implements StreamInfo { return null; } - private void unhideScreenByVideoAvailability() { - mVideoAvailable = true; - mHideScreenView.setVisibility(GONE); - mBufferingSpinnerView.setVisibility(GONE); - unmuteIfPossible(); - } - - private void unmuteIfPossible() { - if (mVideoAvailable && !mScreenBlocked && mBlockedContentRating == null) { - unmute(); + private void updateMuteStatus() { + // Workaround: TunerTvInputService uses AC3 pass-through implementation, which disables + // audio tracks to enforce the mute request. We don't want to send mute request if we are + // not going to block the screen to prevent the video jankiness resulted by disabling audio + // track before the playback is started. In other way, we should send unmute request before + // the playback is started, because TunerTvInput will remember the muted state and mute + // itself right way when the playback is going to be started, which results the initial + // jankiness, too. + boolean isBundledInput = isBundledInput(); + if ((isBundledInput || isVideoOrAudioAvailable()) && !mScreenBlocked + && mBlockedContentRating == null) { + if (mIsMuted) { + mIsMuted = false; + mTvView.setStreamVolume(mVolume); + } + } else { + if (!mIsMuted) { + if ((mInputInfo == null || isBundledInput) + && !mScreenBlocked && mBlockedContentRating == null) { + return; + } + mIsMuted = true; + mTvView.setStreamVolume(0); + } } } - private void mute() { - mIsMuted = true; - mTvView.setStreamVolume(0); - } - - private void unmute() { - mIsMuted = false; - mTvView.setStreamVolume(mVolume); + private boolean isBundledInput() { + return mInputInfo != null && mInputInfo.getType() == TvInputInfo.TYPE_TUNER + && Utils.isBundledInput(mInputInfo.getId()); } /** Returns true if this view is faded out. */ @@ -1268,6 +1337,24 @@ public class TunableTvView extends FrameLayout implements StreamInfo { return mTimeShiftCurrentPositionMs; } + private ImageLoader.ImageLoaderCallback<BlockScreenView> createProgramPosterArtCallback( + final long channelId) { + return new ImageLoader.ImageLoaderCallback<BlockScreenView>(mBlockScreenView) { + @Override + public void onBitmapLoaded(BlockScreenView view, @Nullable Bitmap posterArt) { + if (posterArt == null || getCurrentChannel() == null + || channelId != getCurrentChannel().getId() + || !shouldShowImageForTuning()) { + return; + } + Drawable drawablePosterArt = new BitmapDrawable(view.getResources(), posterArt); + drawablePosterArt.mutate().setColorFilter( + mTuningImageColorFilter, PorterDuff.Mode.SRC_OVER); + view.setBackgroundImage(drawablePosterArt); + } + }; + } + /** * Used to receive the time-shift events. */ @@ -1304,11 +1391,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo { @Override protected void onPostExecute(Boolean networkAvailable) { mInternetCheckTask = null; - if (!mVideoAvailable && !networkAvailable && isAttachedToWindow() + if (!networkAvailable && isAttachedToWindow() + && !mScreenBlocked && mBlockedContentRating == null && mVideoUnavailableReason == TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN) { - mHideScreenView.setImageVisibility(true); - mHideScreenView.setImage(R.drawable.ic_sad_cloud); - mHideScreenView.setText(R.string.tvview_msg_no_internet_connection); + mBlockScreenView.setIconVisibility(true); + mBlockScreenView.setIconImage(R.drawable.ic_sad_cloud); + mBlockScreenView.setInfoText(R.string.tvview_msg_no_internet_connection); } } } diff --git a/src/com/android/tv/ui/TvOverlayManager.java b/src/com/android/tv/ui/TvOverlayManager.java index e14b286b..9324742e 100644 --- a/src/com/android/tv/ui/TvOverlayManager.java +++ b/src/com/android/tv/ui/TvOverlayManager.java @@ -20,6 +20,7 @@ import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentManager.OnBackStackChangedListener; import android.content.Intent; +import android.media.tv.TvContentRating; import android.media.tv.TvInputInfo; import android.os.Bundle; import android.os.Handler; @@ -39,6 +40,7 @@ import com.android.tv.MainActivity.KeyHandlerResultType; import com.android.tv.R; import com.android.tv.TimeShiftManager; import com.android.tv.TvApplication; +import com.android.tv.TvOptionsManager; import com.android.tv.analytics.Tracker; import com.android.tv.common.WeakHandler; import com.android.tv.common.feature.CommonFeatures; @@ -46,14 +48,16 @@ import com.android.tv.common.ui.setup.OnActionClickListener; import com.android.tv.common.ui.setup.SetupFragment; import com.android.tv.common.ui.setup.SetupMultiPaneFragment; import com.android.tv.data.ChannelDataManager; +import com.android.tv.dialog.DvrHistoryDialogFragment; import com.android.tv.dialog.FullscreenDialogFragment; +import com.android.tv.dialog.HalfSizedDialogFragment; import com.android.tv.dialog.PinDialogFragment; import com.android.tv.dialog.RecentlyWatchedDialogFragment; import com.android.tv.dialog.SafeDismissDialogFragment; import com.android.tv.dvr.DvrDataManager; -import com.android.tv.dvr.ui.DvrActivity; -import com.android.tv.dvr.ui.HalfSizedDialogFragment; +import com.android.tv.dvr.ui.browse.DvrBrowseActivity; import com.android.tv.guide.ProgramGuide; +import com.android.tv.license.LicenseDialogFragment; import com.android.tv.menu.Menu; import com.android.tv.menu.Menu.MenuShowReason; import com.android.tv.menu.MenuRowFactory; @@ -62,7 +66,6 @@ import com.android.tv.onboarding.NewSourcesFragment; import com.android.tv.onboarding.SetupSourcesFragment; 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; @@ -157,15 +160,47 @@ public class TvOverlayManager { // Used for the padded print of the overlay type. private static final int NUM_OVERLAY_TYPES = 9; + @Retention(RetentionPolicy.SOURCE) + @IntDef({UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW, UPDATE_CHANNEL_BANNER_REASON_TUNE, + UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST, UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO, + UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK, + UPDATE_CHANNEL_BANNER_REASON_UPDATE_STREAM_INFO}) + private @interface ChannelBannerUpdateReason {} + /** + * Updates channel banner because the channel banner is forced to show. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW = 1; + /** + * Updates channel banner because of tuning. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_TUNE = 2; + /** + * Updates channel banner because of fast tuning. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST = 3; + /** + * Updates channel banner because of info updating. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO = 4; + /** + * Updates channel banner because the current watched channel is locked or unlocked. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK = 5; + /** + * Updates channel banner because of stream info updating. + */ + public static final int UPDATE_CHANNEL_BANNER_REASON_UPDATE_STREAM_INFO = 6; + 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 { AVAILABLE_DIALOG_TAGS.add(RecentlyWatchedDialogFragment.DIALOG_TAG); + AVAILABLE_DIALOG_TAGS.add(DvrHistoryDialogFragment.DIALOG_TAG); AVAILABLE_DIALOG_TAGS.add(PinDialogFragment.DIALOG_TAG); AVAILABLE_DIALOG_TAGS.add(FullscreenDialogFragment.DIALOG_TAG); - AVAILABLE_DIALOG_TAGS.add(SettingsFragment.LicenseActionItem.DIALOG_TAG); + AVAILABLE_DIALOG_TAGS.add(LicenseDialogFragment.DIALOG_TAG); AVAILABLE_DIALOG_TAGS.add(RatingsFragment.AttributionItem.DIALOG_TAG); AVAILABLE_DIALOG_TAGS.add(HalfSizedDialogFragment.DIALOG_TAG); } @@ -176,8 +211,10 @@ public class TvOverlayManager { private final ChannelDataManager mChannelDataManager; private final TvInputManagerHelper mInputManager; private final Menu mMenu; + private final TunableTvView mTvView; private final SideFragmentManager mSideFragmentManager; private final ProgramGuide mProgramGuide; + private final ChannelBannerView mChannelBannerView; private final KeypadChannelSwitchView mKeypadChannelSwitchView; private final SelectInputView mSelectInputView; private final ProgramGuideSearchFragment mSearchFragment; @@ -185,6 +222,7 @@ public class TvOverlayManager { private SafeDismissDialogFragment mCurrentDialog; private boolean mSetupFragmentActive; private boolean mNewSourcesFragmentActive; + private boolean mChannelBannerHiddenBySideFragment; private final Handler mHandler = new TvOverlayHandler(this); private @TvOverlayType int mOpenedOverlays; @@ -195,15 +233,17 @@ public class TvOverlayManager { private OnBackStackChangedListener mOnBackStackChangedListener; public TvOverlayManager(MainActivity mainActivity, ChannelTuner channelTuner, - TunableTvView tvView, KeypadChannelSwitchView keypadChannelSwitchView, - ChannelBannerView channelBannerView, InputBannerView inputBannerView, - SelectInputView selectInputView, ViewGroup sceneContainer, - ProgramGuideSearchFragment searchFragment) { + TunableTvView tvView, TvOptionsManager optionsManager, + KeypadChannelSwitchView keypadChannelSwitchView, ChannelBannerView channelBannerView, + InputBannerView inputBannerView, SelectInputView selectInputView, + ViewGroup sceneContainer, ProgramGuideSearchFragment searchFragment) { mMainActivity = mainActivity; mChannelTuner = channelTuner; ApplicationSingletons singletons = TvApplication.getSingletons(mainActivity); mChannelDataManager = singletons.getChannelDataManager(); mInputManager = singletons.getTvInputManagerHelper(); + mTvView = tvView; + mChannelBannerView = channelBannerView; mKeypadChannelSwitchView = keypadChannelSwitchView; mSelectInputView = selectInputView; mSearchFragment = searchFragment; @@ -225,7 +265,8 @@ public class TvOverlayManager { }); // Menu MenuView menuView = (MenuView) mainActivity.findViewById(R.id.menu); - mMenu = new Menu(mainActivity, tvView, menuView, new MenuRowFactory(mainActivity, tvView), + mMenu = new Menu(mainActivity, tvView, optionsManager, menuView, + new MenuRowFactory(mainActivity, tvView), new Menu.OnMenuVisibilityChangeListener() { @Override public void onMenuVisibilityChange(boolean visible) { @@ -249,7 +290,7 @@ public class TvOverlayManager { new Runnable() { @Override public void run() { - mMainActivity.showChannelBannerIfHiddenBySideFragment(); + showChannelBannerIfHiddenBySideFragment(); onOverlayClosed(OVERLAY_TYPE_SIDE_FRAGMENT); } }); @@ -320,6 +361,9 @@ public class TvOverlayManager { public void release() { mMenu.release(); mHandler.removeCallbacksAndMessages(null); + if (mKeypadChannelSwitchView != null) { + mKeypadChannelSwitchView.setChannels(null); + } } /** @@ -436,6 +480,13 @@ public class TvOverlayManager { onOverlayOpened(OVERLAY_TYPE_DIALOG); } + /** + * Should be called by {@link MainActivity} when the currently browsable channels are updated. + */ + public void onBrowsableChannelsUpdated() { + mKeypadChannelSwitchView.setChannels(mChannelTuner.getBrowsableChannelList()); + } + private void runAfterSideFragmentsAreClosed(final Runnable runnable) { if (mSideFragmentManager.isSidePanelVisible()) { // When the side panel is closing, it closes all the fragments, so the new fragment @@ -541,7 +592,7 @@ public class TvOverlayManager { * Shows DVR manager. */ public void showDvrManager() { - Intent intent = new Intent(mMainActivity, DvrActivity.class); + Intent intent = new Intent(mMainActivity, DvrBrowseActivity.class); mMainActivity.startActivity(intent); } @@ -564,18 +615,34 @@ public class TvOverlayManager { } /** + * Shows DVR history dialog. + */ + public void showDvrHistoryDialog() { + showDialogFragment(DvrHistoryDialogFragment.DIALOG_TAG, + new DvrHistoryDialogFragment(), false); + } + + /** * Shows banner view. */ public void showBanner() { mTransitionManager.goToChannelBannerScene(); } - public void showKeypadChannelSwitch() { - hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_SCENE - | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS - | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_DIALOG - | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT); - mTransitionManager.goToKeypadChannelSwitchScene(); + /** + * Pops up the KeypadChannelSwitchView with the given key input event. + * + * @param keyCode A key code of the key event. + */ + public void showKeypadChannelSwitch(int keyCode) { + if (mChannelTuner.areAllChannelsLoaded()) { + hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_SCENE + | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS + | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_DIALOG + | TvOverlayManager.FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT); + mTransitionManager.goToKeypadChannelSwitchScene(); + mKeypadChannelSwitchView.onNumberKeyUp(keyCode - KeyEvent.KEYCODE_0); + } } /** @@ -619,6 +686,31 @@ public class TvOverlayManager { } /** + * Shows/hides the program guide according to it's hidden or shown now. + * + * @return {@code true} if program guide is going to be shown, otherwise {@code false}. + */ + public boolean toggleProgramGuide() { + if (mProgramGuide.isActive()) { + mProgramGuide.onBackPressed(); + return false; + } else { + showProgramGuide(); + return true; + } + } + + /** + * Sets blocking content rating of the currently playing TV channel. + */ + public void setBlockingContentRating(TvContentRating rating) { + if (!mMainActivity.isChannelChangeKeyDownReceived()) { + mChannelBannerView.setBlockingContentRating(rating); + updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK); + } + } + + /** * Hides all the opened overlays according to the flags. */ // TODO: Add test for this method. @@ -631,12 +723,12 @@ public class TvOverlayManager { } else { if (mCurrentDialog != null) { if (mCurrentDialog instanceof PinDialogFragment) { - // The result listener of PinDialogFragment could call MenuView when - // the dialog is dismissed. In order not to call it, set the result listener - // to null. - ((PinDialogFragment) mCurrentDialog).setResultListener(null); + // We don't want any OnPinCheckedListener is triggered to prevent any possible + // side effects. Dismisses the dialog silently. + ((PinDialogFragment) mCurrentDialog).dismissSilently(); + } else { + mCurrentDialog.dismiss(); } - mCurrentDialog.dismiss(); } mPendingDialogActionQueue.clear(); mCurrentDialog = null; @@ -674,7 +766,7 @@ public class TvOverlayManager { } if ((flags & FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS) != 0) { // Keeps side panels. - } else if (mSideFragmentManager.isSidePanelVisible()) { + } else if (mSideFragmentManager.isActive()) { if ((flags & FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANEL_HISTORY) != 0) { mSideFragmentManager.hideSidePanel(withAnimation); } else { @@ -701,6 +793,83 @@ public class TvOverlayManager { || mNewSourcesFragmentActive; } + /** + * Updates and shows channel banner if it's needed. + */ + public void updateChannelBannerAndShowIfNeeded(@ChannelBannerUpdateReason int reason) { + if(DEBUG) Log.d(TAG, "updateChannelBannerAndShowIfNeeded(reason=" + reason + ")"); + if (mMainActivity.isChannelChangeKeyDownReceived() + && reason != UPDATE_CHANNEL_BANNER_REASON_TUNE + && reason != UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST) { + // Tuning is still ongoing, no need to update banner for other reasons + return; + } + if (!mChannelTuner.isCurrentChannelPassthrough()) { + int lockType = ChannelBannerView.LOCK_NONE; + if (reason == UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST) { + if (mMainActivity.getParentalControlSettings().isParentalControlsEnabled() + && mMainActivity.getCurrentChannel().isLocked()) { + lockType = ChannelBannerView.LOCK_CHANNEL_INFO; + } else { + // Do not show detailed program information while fast-tuning. + lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; + } + } else if (reason == UPDATE_CHANNEL_BANNER_REASON_TUNE) { + if (mMainActivity.getParentalControlSettings().isParentalControlsEnabled()) { + if (mMainActivity.getCurrentChannel().isLocked()) { + lockType = ChannelBannerView.LOCK_CHANNEL_INFO; + } else { + // If parental control is turned on, + // assumes that program is locked by default and waits for onContentAllowed. + lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; + } + } + } else if (mTvView.isScreenBlocked()) { + lockType = ChannelBannerView.LOCK_CHANNEL_INFO; + } else if (mTvView.isContentBlocked() || (mMainActivity.getParentalControlSettings() + .isParentalControlsEnabled() && !mTvView.isVideoOrAudioAvailable())) { + // If the parental control is enabled, do not show the program detail until the + // video becomes available. + lockType = ChannelBannerView.LOCK_PROGRAM_DETAIL; + } + // If lock type is not changed, we don't need to update channel banner by parental + // control. + int previousLockType = mChannelBannerView.setLockType(lockType); + if (previousLockType == lockType + && reason == UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK) { + return; + } else if (reason == UPDATE_CHANNEL_BANNER_REASON_UPDATE_STREAM_INFO) { + mChannelBannerView.updateStreamInfo(mTvView); + // If parental control is enabled, we shows program description when the video is + // available, instead of tuning. Therefore we need to check it here if the program + // description is previously hidden by parental control. + if (previousLockType == ChannelBannerView.LOCK_PROGRAM_DETAIL && + lockType != ChannelBannerView.LOCK_PROGRAM_DETAIL) { + mChannelBannerView.updateViews(false); + } + } else { + mChannelBannerView.updateViews(reason == UPDATE_CHANNEL_BANNER_REASON_TUNE + || reason == UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST); + } + } + boolean needToShowBanner = (reason == UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW + || reason == UPDATE_CHANNEL_BANNER_REASON_TUNE + || reason == UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST); + if (needToShowBanner && !mMainActivity.willShowOverlayUiWhenResume() + && getCurrentDialog() == null + && !isSetupFragmentActive() + && !isNewSourcesFragmentActive()) { + if (mChannelTuner.getCurrentChannel() == null) { + mChannelBannerHiddenBySideFragment = false; + } else if (getSideFragmentManager().isActive()) { + mChannelBannerHiddenBySideFragment = true; + } else { + mChannelBannerHiddenBySideFragment = false; + showBanner(); + } + } + } + @TvOverlayType private int convertSceneToOverlayType(@SceneType int sceneType) { switch (sceneType) { case TvTransitionManager.SCENE_TYPE_CHANNEL_BANNER: @@ -749,6 +918,18 @@ public class TvOverlayManager { } } + /** + * Shows the channel banner if it was hidden from the side fragment. + * + * <p>When the side fragment is visible, showing the channel banner should be put off until the + * side fragment is closed even though the channel changes. + */ + private void showChannelBannerIfHiddenBySideFragment() { + if (mChannelBannerHiddenBySideFragment) { + updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW); + } + } + private String toBinaryString(int value) { return String.format("0b%" + NUM_OVERLAY_TYPES + "s", Integer.toBinaryString(value)) .replace(' ', '0'); @@ -843,6 +1024,7 @@ public class TvOverlayManager { timeShiftManager.play(); showMenu(Menu.REASON_PLAY_CONTROLS_PLAY); break; + case KeyEvent.KEYCODE_MEDIA_STOP: case KeyEvent.KEYCODE_MEDIA_PAUSE: timeShiftManager.pause(); showMenu(Menu.REASON_PLAY_CONTROLS_PAUSE); @@ -916,7 +1098,7 @@ public class TvOverlayManager { } if (mMenu.isActive()) { if (KeypadChannelSwitchView.isChannelNumberKey(keyCode)) { - mMainActivity.showKeypadChannelSwitchView(keyCode); + showKeypadChannelSwitch(keyCode); return MainActivity.KEY_EVENT_HANDLER_RESULT_HANDLED; } return MainActivity.KEY_EVENT_HANDLER_RESULT_DISPATCH_TO_OVERLAY; @@ -971,6 +1153,7 @@ public class TvOverlayManager { case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD: case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD: + case KeyEvent.KEYCODE_MEDIA_STOP: return true; } return false; diff --git a/src/com/android/tv/ui/TvTransitionManager.java b/src/com/android/tv/ui/TvTransitionManager.java index 52e96cc0..628bbb72 100644 --- a/src/com/android/tv/ui/TvTransitionManager.java +++ b/src/com/android/tv/ui/TvTransitionManager.java @@ -129,8 +129,8 @@ public class TvTransitionManager extends TransitionManager { public void goToSelectInputScene() { initIfNeeded(); if (mCurrentScene != mSelectInputScene) { - transitionTo(mSelectInputScene); mSelectInputView.setCurrentChannel(mMainActivity.getCurrentChannel()); + transitionTo(mSelectInputScene); } } diff --git a/src/com/android/tv/ui/TvViewUiManager.java b/src/com/android/tv/ui/TvViewUiManager.java index bf874fc7..f042987a 100644 --- a/src/com/android/tv/ui/TvViewUiManager.java +++ b/src/com/android/tv/ui/TvViewUiManager.java @@ -24,21 +24,19 @@ import android.animation.TimeInterpolator; 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.Build; import android.os.Handler; import android.os.Message; import android.preference.PreferenceManager; import android.util.Log; import android.util.Property; import android.view.Display; -import android.view.Gravity; -import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewGroup.MarginLayoutParams; @@ -52,9 +50,8 @@ import com.android.tv.data.DisplayMode; import com.android.tv.util.TvSettings; /** - * The TvViewUiManager is responsible for handling UI layouting and animation of main and PIP - * TvViews. It also control the settings regarding TvView UI such as display mode, PIP layout, - * and PIP size. + * The TvViewUiManager is responsible for handling UI layouting and animation of main TvView. + * It also control the settings regarding TvView UI such as display mode. */ public class TvViewUiManager { private static final String TAG = "TvViewManager"; @@ -69,42 +66,44 @@ public class TvViewUiManager { private final Resources mResources; private final FrameLayout mContentView; private final TunableTvView mTvView; - private final TunableTvView mPipView; private final TvOptionsManager mTvOptionsManager; - private final int mTvViewPapWidth; private final int mTvViewShrunkenStartMargin; private final int mTvViewShrunkenEndMargin; - private final int mTvViewPapStartMargin; - private final int mTvViewPapEndMargin; private int mWindowWidth; private int mWindowHeight; - private final int mPipViewHorizontalMargin; - private final int mPipViewTopMargin; - private final int mPipViewBottomMargin; private final SharedPreferences mSharedPreferences; private final TimeInterpolator mLinearOutSlowIn; private final TimeInterpolator mFastOutLinearIn; - 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); + 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.setTvViewLayoutParams(layoutParams); + mTvView.setLayoutParams(mTvViewFrame); + // Smooth PIP size change, we don't change surface size when + // isInPictureInPictureMode is true. + if (!Features.PICTURE_IN_PICTURE.isEnabled(mContext) + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && !((Activity) mContext).isInPictureInPictureMode())) { + mTvView.setFixedSurfaceSize( + layoutParams.width, layoutParams.height); + } + break; } - break; - } - } - }; + } + }; private int mDisplayMode; // Used to restore the previous state from ShrunkenTvView state. private int mTvViewStartMarginBeforeShrunken; @@ -113,16 +112,13 @@ public class TvViewUiManager { private boolean mIsUnderShrunkenTvView; private int mTvViewStartMargin; private int mTvViewEndMargin; - private int mPipLayout; - private int mPipSize; - private boolean mPipStarted; private ObjectAnimator mTvViewAnimator; private FrameLayout.LayoutParams mTvViewLayoutParams; // TV view's position when the display mode is FULL. It is used to compute PIP location relative // to TV view's position. - private MarginLayoutParams mTvViewFrame; - private MarginLayoutParams mLastAnimatedTvViewFrame; - private MarginLayoutParams mOldTvViewFrame; + private FrameLayout.LayoutParams mTvViewFrame; + private FrameLayout.LayoutParams mLastAnimatedTvViewFrame; + private FrameLayout.LayoutParams mOldTvViewFrame; private ObjectAnimator mBackgroundAnimator; private int mBackgroundColor; private int mAppliedDisplayedMode = DisplayMode.MODE_NOT_DEFINED; @@ -130,12 +126,11 @@ public class TvViewUiManager { private int mAppliedTvViewEndMargin; private float mAppliedVideoDisplayAspectRatio; - public TvViewUiManager(Context context, TunableTvView tvView, TunableTvView pipView, + public TvViewUiManager(Context context, TunableTvView tvView, FrameLayout contentView, TvOptionsManager tvOptionManager) { mContext = context; mResources = mContext.getResources(); mTvView = tvView; - mPipView = pipView; mContentView = contentView; mTvOptionsManager = tvOptionManager; @@ -147,18 +142,12 @@ public class TvViewUiManager { mWindowWidth = size.x; mWindowHeight = size.y; - // Have an assumption that PIP and TvView Shrinking happens only in full screen. + // Have an assumption that TvView Shrinking happens only in full screen. mTvViewShrunkenStartMargin = mResources .getDimensionPixelOffset(R.dimen.shrunken_tvview_margin_start); mTvViewShrunkenEndMargin = mResources.getDimensionPixelOffset(R.dimen.shrunken_tvview_margin_end) + mResources.getDimensionPixelSize(R.dimen.side_panel_width); - int papMarginHorizontal = mResources - .getDimensionPixelOffset(R.dimen.papview_margin_horizontal); - int papSpacing = mResources.getDimensionPixelOffset(R.dimen.papview_spacing); - mTvViewPapWidth = (mWindowWidth - papSpacing) / 2 - papMarginHorizontal; - mTvViewPapStartMargin = papMarginHorizontal + mTvViewPapWidth + papSpacing; - mTvViewPapEndMargin = papMarginHorizontal; mTvViewFrame = createMarginLayoutParams(0, 0, 0, 0); mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); @@ -167,11 +156,6 @@ public class TvViewUiManager { .loadInterpolator(mContext, android.R.interpolator.linear_out_slow_in); mFastOutLinearIn = AnimationUtils .loadInterpolator(mContext, android.R.interpolator.fast_out_linear_in); - - mPipViewHorizontalMargin = mResources - .getDimensionPixelOffset(R.dimen.pipview_margin_horizontal); - mPipViewTopMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_top); - mPipViewBottomMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_bottom); } public void onConfigurationChanged(final int windowWidth, final int windowHeight) { @@ -200,18 +184,11 @@ public class TvViewUiManager { */ public void startShrunkenTvView() { mIsUnderShrunkenTvView = true; + mTvView.setIsUnderShrunken(true); mTvViewStartMarginBeforeShrunken = mTvViewStartMargin; mTvViewEndMarginBeforeShrunken = mTvViewEndMargin; - if (mPipStarted && getPipLayout() == TvSettings.PIP_LAYOUT_SIDE_BY_SIDE) { - float sidePanelWidth = mResources.getDimensionPixelOffset(R.dimen.side_panel_width); - float factor = 1.0f - sidePanelWidth / mWindowWidth; - int startMargin = (int) (mTvViewPapStartMargin * factor); - int endMargin = (int) (mTvViewPapEndMargin * factor + sidePanelWidth); - setTvViewMargin(startMargin, endMargin); - } else { - setTvViewMargin(mTvViewShrunkenStartMargin, mTvViewShrunkenEndMargin); - } + setTvViewMargin(mTvViewShrunkenStartMargin, mTvViewShrunkenEndMargin); mDisplayModeBeforeShrunken = setDisplayMode(DisplayMode.MODE_NORMAL, false, true); } @@ -221,6 +198,7 @@ public class TvViewUiManager { */ public void endShrunkenTvView() { mIsUnderShrunkenTvView = false; + mTvView.setIsUnderShrunken(false); setTvViewMargin(mTvViewStartMarginBeforeShrunken, mTvViewEndMarginBeforeShrunken); setDisplayMode(mDisplayModeBeforeShrunken, false, true); } @@ -296,9 +274,9 @@ public class TvViewUiManager { } /** - * Updates TvView. It is called when video resolution is updated. + * Updates TvView's aspect ratio. It should be called when video resolution is changed. */ - public void updateTvView() { + public void updateTvAspectRatio() { applyDisplayMode(mTvView.getVideoDisplayAspectRatio(), false, false); if (mTvView.isVideoAvailable() && mTvView.isFadedOut()) { mTvView.fadeIn(mResources.getInteger(R.integer.tvview_fade_in_duration), @@ -327,120 +305,6 @@ public class TvViewUiManager { } /** - * Returns the current PIP layout. The layout should be one of - * {@link TvSettings#PIP_LAYOUT_BOTTOM_RIGHT}, {@link TvSettings#PIP_LAYOUT_TOP_RIGHT}, - * {@link TvSettings#PIP_LAYOUT_TOP_LEFT}, {@link TvSettings#PIP_LAYOUT_BOTTOM_LEFT} and - * {@link TvSettings#PIP_LAYOUT_SIDE_BY_SIDE}. - */ - public int getPipLayout() { - return mPipLayout; - } - - /** - * Sets the PIP layout. The layout should be one of - * {@link TvSettings#PIP_LAYOUT_BOTTOM_RIGHT}, {@link TvSettings#PIP_LAYOUT_TOP_RIGHT}, - * {@link TvSettings#PIP_LAYOUT_TOP_LEFT}, {@link TvSettings#PIP_LAYOUT_BOTTOM_LEFT} and - * {@link TvSettings#PIP_LAYOUT_SIDE_BY_SIDE}. - * - * @param storeInPreference if true, the stored value will be restored by - * {@link #restorePipLayout()}. - */ - public void setPipLayout(int pipLayout, boolean storeInPreference) { - mPipLayout = pipLayout; - if (storeInPreference) { - TvSettings.setPipLayout(mContext, pipLayout); - } - updatePipView(mTvViewFrame); - if (mPipLayout == TvSettings.PIP_LAYOUT_SIDE_BY_SIDE) { - setTvViewMargin(mTvViewPapStartMargin, mTvViewPapEndMargin); - setDisplayMode(DisplayMode.MODE_NORMAL, false, false); - } else { - setTvViewMargin(0, 0); - restoreDisplayMode(false); - } - mTvOptionsManager.onPipLayoutChanged(pipLayout); - } - - /** - * Restores the PIP layout which {@link #setPipLayout} lastly stores. - */ - public void restorePipLayout() { - setPipLayout(TvSettings.getPipLayout(mContext), false); - } - - /** - * Called when PIP is started. - */ - public void onPipStart() { - mPipStarted = true; - updatePipView(); - mPipView.setVisibility(View.VISIBLE); - } - - /** - * Called when PIP is stopped. - */ - public void onPipStop() { - setTvViewMargin(0, 0); - mPipView.setVisibility(View.GONE); - mPipStarted = false; - } - - /** - * Called when PIP is resumed. - */ - public void showPipForResume() { - mPipView.setVisibility(View.VISIBLE); - } - - /** - * Called when PIP is paused. - */ - public void hidePipForPause() { - if (mPipLayout != TvSettings.PIP_LAYOUT_SIDE_BY_SIDE) { - mPipView.setVisibility(View.GONE); - } - } - - /** - * Updates PIP view. It is usually called, when video resolution in PIP is updated. - */ - public void updatePipView() { - updatePipView(mTvViewFrame); - } - - /** - * Returns the size of the PIP view. - */ - public int getPipSize() { - return mPipSize; - } - - /** - * Sets PIP size and applies it immediately. - * - * @param pipSize PIP size. The value should be one of {@link TvSettings#PIP_SIZE_BIG} - * and {@link TvSettings#PIP_SIZE_SMALL}. - * @param storeInPreference if true, the stored value will be restored by - * {@link #restorePipSize()}. - */ - public void setPipSize(int pipSize, boolean storeInPreference) { - mPipSize = pipSize; - if (storeInPreference) { - TvSettings.setPipSize(mContext, pipSize); - } - updatePipView(mTvViewFrame); - mTvOptionsManager.onPipSizeChanged(pipSize); - } - - /** - * Restores the PIP size which {@link #setPipSize} lastly stores. - */ - public void restorePipSize() { - setPipSize(TvSettings.getPipSize(mContext), false); - } - - /** * This margins will be applied when applyDisplayMode is called. */ private void setTvViewMargin(int tvViewStartMargin, int tvViewEndMargin) { @@ -488,14 +352,14 @@ public class TvViewUiManager { } private void setTvViewPosition(final FrameLayout.LayoutParams layoutParams, - MarginLayoutParams tvViewFrame, boolean animate) { + FrameLayout.LayoutParams tvViewFrame, boolean animate) { if (DEBUG) { Log.d(TAG, "setTvViewPosition: w=" + layoutParams.width + " h=" + layoutParams.height + " s=" + layoutParams.getMarginStart() + " t=" + layoutParams.topMargin + " e=" + layoutParams.getMarginEnd() + " b=" + layoutParams.bottomMargin + " animate=" + animate); } - MarginLayoutParams oldTvViewFrame = mTvViewFrame; + FrameLayout.LayoutParams oldTvViewFrame = mTvViewFrame; mTvViewLayoutParams = layoutParams; mTvViewFrame = tvViewFrame; if (animate) { @@ -503,11 +367,11 @@ public class TvViewUiManager { if (mTvViewAnimator.isStarted()) { // Cancel the current animation and start new one. mTvViewAnimator.cancel(); - mOldTvViewFrame = mLastAnimatedTvViewFrame; + mOldTvViewFrame = new FrameLayout.LayoutParams(mLastAnimatedTvViewFrame); } else { - mOldTvViewFrame = oldTvViewFrame; + mOldTvViewFrame = new FrameLayout.LayoutParams(oldTvViewFrame); } - mTvViewAnimator.setObjectValues(mTvView.getLayoutParams(), layoutParams); + mTvViewAnimator.setObjectValues(mTvView.getTvViewLayoutParams(), layoutParams); mTvViewAnimator.setEvaluator(new TypeEvaluator<FrameLayout.LayoutParams>() { FrameLayout.LayoutParams lp; @Override @@ -517,7 +381,7 @@ public class TvViewUiManager { lp = new FrameLayout.LayoutParams(0, 0); lp.gravity = startValue.gravity; } - interpolateMarginsRelative(lp, startValue, endValue, fraction); + interpolateMargins(lp, startValue, endValue, fraction); return lp; } }); @@ -538,116 +402,10 @@ public class TvViewUiManager { mHandler.removeMessages(MSG_SET_LAYOUT_PARAMS); mHandler.obtainMessage(MSG_SET_LAYOUT_PARAMS, layoutParams).sendToTarget(); } else { - mTvView.setLayoutParams(layoutParams); - } - updatePipView(mTvViewFrame); - } - } - - /** - * The redlines assume that the ratio of the TV screen is 16:9. If the radio is not 16:9, the - * layout of PAP can be broken. - */ - @SuppressLint("RtlHardcoded") - private void updatePipView(MarginLayoutParams tvViewFrame) { - if (!mPipStarted) { - return; - } - int width; - int height; - int startMargin; - int endMargin; - int topMargin; - int bottomMargin; - int gravity; - - if (mPipLayout == TvSettings.PIP_LAYOUT_SIDE_BY_SIDE) { - gravity = Gravity.CENTER_VERTICAL | Gravity.START; - height = tvViewFrame.height; - float videoDisplayAspectRatio = mPipView.getVideoDisplayAspectRatio(); - if (videoDisplayAspectRatio <= 0f) { - width = tvViewFrame.width; - } else { - width = (int) (height * videoDisplayAspectRatio); - if (width > tvViewFrame.width) { - width = tvViewFrame.width; - } - } - startMargin = mResources.getDimensionPixelOffset(R.dimen.papview_margin_horizontal) - * tvViewFrame.width / mTvViewPapWidth + (tvViewFrame.width - width) / 2; - endMargin = 0; - topMargin = 0; - bottomMargin = 0; - } else { - int tvViewWidth = tvViewFrame.width; - int tvViewHeight = tvViewFrame.height; - int tvStartMargin = tvViewFrame.getMarginStart(); - int tvEndMargin = tvViewFrame.getMarginEnd(); - int tvTopMargin = tvViewFrame.topMargin; - int tvBottomMargin = tvViewFrame.bottomMargin; - float horizontalScaleFactor = (float) tvViewWidth / mWindowWidth; - float verticalScaleFactor = (float) tvViewHeight / mWindowHeight; - - int maxWidth; - if (mPipSize == TvSettings.PIP_SIZE_SMALL) { - maxWidth = (int) (mResources.getDimensionPixelSize(R.dimen.pipview_small_size_width) - * horizontalScaleFactor); - height = (int) (mResources.getDimensionPixelSize(R.dimen.pipview_small_size_height) - * verticalScaleFactor); - } else if (mPipSize == TvSettings.PIP_SIZE_BIG) { - maxWidth = (int) (mResources.getDimensionPixelSize(R.dimen.pipview_large_size_width) - * horizontalScaleFactor); - height = (int) (mResources.getDimensionPixelSize(R.dimen.pipview_large_size_height) - * verticalScaleFactor); - } else { - throw new IllegalArgumentException("Invalid PIP size: " + mPipSize); - } - float videoDisplayAspectRatio = mPipView.getVideoDisplayAspectRatio(); - if (videoDisplayAspectRatio <= 0f) { - width = maxWidth; - } else { - width = (int) (height * videoDisplayAspectRatio); - if (width > maxWidth) { - width = maxWidth; - } - } - - startMargin = tvStartMargin + (int) (mPipViewHorizontalMargin * horizontalScaleFactor); - endMargin = tvEndMargin + (int) (mPipViewHorizontalMargin * horizontalScaleFactor); - topMargin = tvTopMargin + (int) (mPipViewTopMargin * verticalScaleFactor); - bottomMargin = tvBottomMargin + (int) (mPipViewBottomMargin * verticalScaleFactor); - - switch (mPipLayout) { - case TvSettings.PIP_LAYOUT_TOP_LEFT: - gravity = Gravity.TOP | Gravity.LEFT; - break; - case TvSettings.PIP_LAYOUT_TOP_RIGHT: - gravity = Gravity.TOP | Gravity.RIGHT; - break; - case TvSettings.PIP_LAYOUT_BOTTOM_LEFT: - gravity = Gravity.BOTTOM | Gravity.LEFT; - break; - case TvSettings.PIP_LAYOUT_BOTTOM_RIGHT: - gravity = Gravity.BOTTOM | Gravity.RIGHT; - break; - default: - throw new IllegalArgumentException("Invalid PIP location: " + mPipLayout); + mTvView.setTvViewLayoutParams(layoutParams); + mTvView.setLayoutParams(mTvViewFrame); } } - - FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) mPipView.getLayoutParams(); - if (lp.width != width || lp.height != height || lp.getMarginStart() != startMargin - || lp.getMarginEnd() != endMargin || lp.topMargin != topMargin - || lp.bottomMargin != bottomMargin || lp.gravity != gravity) { - lp.width = width; - lp.height = height; - lp.setMarginStart(startMargin); - lp.setMarginEnd(endMargin); - lp.topMargin = topMargin; - lp.bottomMargin = bottomMargin; - lp.gravity = gravity; - mPipView.setLayoutParams(lp); - } } private void initTvAnimatorIfNeeded() { @@ -663,7 +421,7 @@ public class TvViewUiManager { // because TvView may request layout itself during animation and layout SurfaceView with // its own parameters when TvInputService requests to do so. mTvViewAnimator = new ObjectAnimator(); - mTvViewAnimator.setTarget(mTvView); + mTvViewAnimator.setTarget(mTvView.getTvView()); mTvViewAnimator.setProperty( Property.of(FrameLayout.class, ViewGroup.LayoutParams.class, "layoutParams")); mTvViewAnimator.setDuration(mResources.getInteger(R.integer.tvview_anim_duration)); @@ -693,10 +451,10 @@ public class TvViewUiManager { @Override public void onAnimationUpdate(ValueAnimator animator) { float fraction = animator.getAnimatedFraction(); - mLastAnimatedTvViewFrame = new MarginLayoutParams(0, 0); - interpolateMarginsRelative(mLastAnimatedTvViewFrame, + mLastAnimatedTvViewFrame = (FrameLayout.LayoutParams) mTvView.getLayoutParams(); + interpolateMargins(mLastAnimatedTvViewFrame, mOldTvViewFrame, mTvViewFrame, fraction); - updatePipView(mLastAnimatedTvViewFrame); + mTvView.setLayoutParams(mLastAnimatedTvViewFrame); } }); } @@ -745,66 +503,58 @@ public class TvViewUiManager { } int availableAreaWidth = mWindowWidth - mTvViewStartMargin - mTvViewEndMargin; int availableAreaHeight = availableAreaWidth * mWindowHeight / mWindowWidth; - FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(0, 0, - ((FrameLayout.LayoutParams) mTvView.getLayoutParams()).gravity); int displayMode = mDisplayMode; - double availableAreaRatio = 0; - double videoRatio = 0; + float availableAreaRatio = 0; if (availableAreaWidth <= 0 || availableAreaHeight <= 0) { displayMode = DisplayMode.MODE_FULL; Log.w(TAG, "Some resolution info is missing during applyDisplayMode. (" + "availableAreaWidth=" + availableAreaWidth + ", availableAreaHeight=" + availableAreaHeight + ")"); } else { - availableAreaRatio = (double) availableAreaWidth / availableAreaHeight; - videoRatio = videoDisplayAspectRatio; + availableAreaRatio = (float) availableAreaWidth / availableAreaHeight; } - - int tvViewFrameTop = (mWindowHeight - availableAreaHeight) / 2; - MarginLayoutParams tvViewFrame = createMarginLayoutParams( - mTvViewStartMargin, mTvViewEndMargin, tvViewFrameTop, tvViewFrameTop); - layoutParams.width = availableAreaWidth; - layoutParams.height = availableAreaHeight; + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(0, 0, + ((FrameLayout.LayoutParams) mTvView.getTvViewLayoutParams()).gravity); switch (displayMode) { - case DisplayMode.MODE_FULL: - layoutParams.width = availableAreaWidth; - layoutParams.height = availableAreaHeight; - break; case DisplayMode.MODE_ZOOM: - if (videoRatio < availableAreaRatio) { + if (videoDisplayAspectRatio < availableAreaRatio) { // Y axis will be clipped. layoutParams.width = availableAreaWidth; - layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio); + layoutParams.height = Math.round(availableAreaWidth / videoDisplayAspectRatio); } else { // X axis will be clipped. - layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio); + layoutParams.width = Math.round(availableAreaHeight * videoDisplayAspectRatio); layoutParams.height = availableAreaHeight; } break; case DisplayMode.MODE_NORMAL: - if (videoRatio < availableAreaRatio) { + if (videoDisplayAspectRatio < availableAreaRatio) { // X axis has black area. - layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio); + layoutParams.width = Math.round(availableAreaHeight * videoDisplayAspectRatio); layoutParams.height = availableAreaHeight; } else { // Y axis has black area. layoutParams.width = availableAreaWidth; - layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio); + layoutParams.height = Math.round(availableAreaWidth / videoDisplayAspectRatio); } break; + case DisplayMode.MODE_FULL: + default: + layoutParams.width = availableAreaWidth; + layoutParams.height = availableAreaHeight; + break; } - // FrameLayout has an issue with centering when left and right margins differ. // So stick to Gravity.START | Gravity.CENTER_VERTICAL. - int marginStart = mTvViewStartMargin + (availableAreaWidth - layoutParams.width) / 2; + int marginStart = (availableAreaWidth - layoutParams.width) / 2; layoutParams.setMarginStart(marginStart); - // Set marginEnd as well because setTvViewPosition uses both start/end margin. - layoutParams.setMarginEnd(mWindowWidth - layoutParams.width - marginStart); - + int tvViewFrameTop = (mWindowHeight - availableAreaHeight) / 2; + FrameLayout.LayoutParams tvViewFrame = createMarginLayoutParams( + mTvViewStartMargin, mTvViewEndMargin, tvViewFrameTop, tvViewFrameTop); + setTvViewPosition(layoutParams, tvViewFrame, animate); setBackgroundColor(mResources.getColor(isTvViewFullScreen() ? R.color.tvactivity_background : R.color.tvactivity_background_on_shrunken_tvview, - null), layoutParams, animate); - setTvViewPosition(layoutParams, tvViewFrame, animate); + null), layoutParams, animate); // Update the current display mode. mTvOptionsManager.onDisplayModeChanged(displayMode); @@ -814,7 +564,7 @@ public class TvViewUiManager { return (int) (start + (end - start) * fraction); } - private static void interpolateMarginsRelative(MarginLayoutParams out, + private static void interpolateMargins(MarginLayoutParams out, MarginLayoutParams startValue, MarginLayoutParams endValue, float fraction) { out.topMargin = interpolate(startValue.topMargin, endValue.topMargin, fraction); out.bottomMargin = interpolate(startValue.bottomMargin, endValue.bottomMargin, fraction); @@ -825,9 +575,9 @@ public class TvViewUiManager { out.height = interpolate(startValue.height, endValue.height, fraction); } - private MarginLayoutParams createMarginLayoutParams( + private FrameLayout.LayoutParams createMarginLayoutParams( int startMargin, int endMargin, int topMargin, int bottomMargin) { - MarginLayoutParams lp = new MarginLayoutParams(0, 0); + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(0, 0); lp.setMarginStart(startMargin); lp.setMarginEnd(endMargin); lp.topMargin = topMargin; @@ -836,4 +586,4 @@ public class TvViewUiManager { lp.height = mWindowHeight - topMargin - bottomMargin; return lp; } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/ui/sidepanel/ActionItem.java b/src/com/android/tv/ui/sidepanel/ActionItem.java index 23aff91c..cd70a886 100644 --- a/src/com/android/tv/ui/sidepanel/ActionItem.java +++ b/src/com/android/tv/ui/sidepanel/ActionItem.java @@ -17,7 +17,6 @@ package com.android.tv.ui.sidepanel; import android.view.View; -import android.widget.ImageView; import android.widget.TextView; import com.android.tv.R; @@ -25,24 +24,14 @@ import com.android.tv.R; public abstract class ActionItem extends Item { private final String mTitle; private final String mDescription; - private final int mIconId; public ActionItem(String title) { - this(title, null, 0); + this(title, null); } public ActionItem(String title, String description) { - this(title, description, 0); - } - - public ActionItem(String title, int iconId) { - this(title, null, iconId); - } - - public ActionItem(String title, String description, int iconId) { mTitle = title; mDescription = description; - mIconId = iconId; } @Override @@ -62,12 +51,5 @@ public abstract class ActionItem extends Item { } else { descriptionView.setVisibility(View.GONE); } - ImageView iconView = (ImageView) view.findViewById(R.id.icon); - if (mIconId != 0) { - iconView.setVisibility(View.VISIBLE); - iconView.setImageResource(mIconId); - } else { - iconView.setVisibility(View.GONE); - } } }
\ No newline at end of file diff --git a/src/com/android/tv/ui/sidepanel/ClosedCaptionFragment.java b/src/com/android/tv/ui/sidepanel/ClosedCaptionFragment.java index d6ccdf6b..341e4350 100644 --- a/src/com/android/tv/ui/sidepanel/ClosedCaptionFragment.java +++ b/src/com/android/tv/ui/sidepanel/ClosedCaptionFragment.java @@ -18,6 +18,7 @@ package com.android.tv.ui.sidepanel; import android.media.tv.TvTrackInfo; import android.os.Bundle; +import android.text.TextUtils; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; @@ -25,7 +26,6 @@ import android.view.ViewGroup; import com.android.tv.R; import com.android.tv.util.CaptionSettings; -import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -38,8 +38,6 @@ public class ClosedCaptionFragment extends SideFragment { private String mClosedCaptionLanguage; private String mClosedCaptionTrackId; private ClosedCaptionOptionItem mSelectedItem; - private List<Item> mItems; - private boolean mPaused; public ClosedCaptionFragment() { super(KeyEvent.KEYCODE_CAPTIONS, KeyEvent.KEYCODE_S); @@ -63,37 +61,32 @@ public class ClosedCaptionFragment extends SideFragment { mClosedCaptionLanguage = captionSettings.getLanguage(); mClosedCaptionTrackId = captionSettings.getTrackId(); - mItems = new ArrayList<>(); + List<Item> items = new ArrayList<>(); mSelectedItem = null; List<TvTrackInfo> tracks = getMainActivity().getTracks(TvTrackInfo.TYPE_SUBTITLE); if (tracks != null && !tracks.isEmpty()) { - String trackId = captionSettings.isEnabled() ? + String selectedTrackId = captionSettings.isEnabled() ? getMainActivity().getSelectedTrack(TvTrackInfo.TYPE_SUBTITLE) : null; - boolean isEnabled = trackId != null; - - ClosedCaptionOptionItem item = new ClosedCaptionOptionItem( - getString(R.string.closed_caption_option_item_off), - CaptionSettings.OPTION_OFF, null, null); - // Pick 'Off' as default because we may fail to find the matching language. - mSelectedItem = item; - if (!isEnabled) { + ClosedCaptionOptionItem item = new ClosedCaptionOptionItem(null, null); + items.add(item); + if (selectedTrackId == null) { + mSelectedItem = item; item.setChecked(true); + setSelectedPosition(0); } - mItems.add(item); - - for (final TvTrackInfo track : tracks) { - item = new ClosedCaptionOptionItem(getLabel(track), - CaptionSettings.OPTION_ON, track.getId(), track.getLanguage()); - if (isEnabled && track.getId().equals(trackId)) { - item.setChecked(true); + for (int i = 0; i < tracks.size(); i++) { + item = new ClosedCaptionOptionItem(tracks.get(i), i); + if (TextUtils.equals(selectedTrackId, tracks.get(i).getId())) { mSelectedItem = item; + item.setChecked(true); + setSelectedPosition(i + 1); } - mItems.add(item); + items.add(item); } } if (getMainActivity().hasCaptioningSettingsActivity()) { - mItems.add(new ActionItem(getString(R.string.closed_caption_system_settings), + items.add(new ActionItem(getString(R.string.closed_caption_system_settings), getString(R.string.closed_caption_system_settings_description)) { @Override protected void onSelected() { @@ -103,14 +96,14 @@ public class ClosedCaptionFragment extends SideFragment { @Override protected void onFocused() { super.onFocused(); - if (!mPaused && mSelectedItem != null) { + if (mSelectedItem != null) { getMainActivity().selectSubtitleTrack( mSelectedItem.mOption, mSelectedItem.mTrackId); } } }); } - return mItems; + return items; } @Override @@ -120,50 +113,6 @@ public class ClosedCaptionFragment extends SideFragment { } @Override - public void onResume() { - super.onResume(); - if (mPaused) { - // Apply system's closed caption settings to the UI. - CaptionSettings captionSettings = getMainActivity().getCaptionSettings(); - mClosedCaptionOption = CaptionSettings.OPTION_SYSTEM; - mClosedCaptionLanguage = captionSettings.getSystemLanguage(); - ClosedCaptionOptionItem selectedItem = null; - if (captionSettings.isSystemSettingEnabled()) { - for (Item item : mItems) { - if (!(item instanceof ClosedCaptionOptionItem)) { - continue; - } - ClosedCaptionOptionItem captionItem = (ClosedCaptionOptionItem) item; - if (Utils.isEqualLanguage(captionItem.mLanguage, mClosedCaptionLanguage)) { - selectedItem = captionItem; - break; - } - } - } - if (mSelectedItem != null) { - mSelectedItem.setChecked(false); - } - if (selectedItem == null && mItems.get(0) instanceof ClosedCaptionOptionItem) { - selectedItem = (ClosedCaptionOptionItem) mItems.get(0); - } - if (selectedItem != null) { - selectedItem.setChecked(true); - } - // We shouldn't call MainActivity.selectSubtitleTrack() here because - // 1. Tracks are not available because video is just started at this moment. - // 2. MainActivity will apply system settings when video's tracks are available. - mSelectedItem = selectedItem; - } - mPaused = false; - } - - @Override - public void onPause() { - super.onPause(); - mPaused = true; - } - - @Override public void onDestroyView() { if (mResetClosedCaption) { getMainActivity().selectSubtitleLanguage(mClosedCaptionOption, mClosedCaptionLanguage, @@ -172,23 +121,28 @@ public class ClosedCaptionFragment extends SideFragment { super.onDestroyView(); } - private String getLabel(TvTrackInfo track) { - if (track.getLanguage() != null) { + private String getLabel(TvTrackInfo track, Integer trackIndex) { + if (track == null) { + return getString(R.string.closed_caption_option_item_off); + } else if (track.getLanguage() != null) { return new Locale(track.getLanguage()).getDisplayName(); } - return getString(R.string.default_language); + return getString(R.string.closed_caption_unknown_language, trackIndex + 1); } private class ClosedCaptionOptionItem extends RadioButtonItem { private final int mOption; private final String mTrackId; - private final String mLanguage; - private ClosedCaptionOptionItem(String title, int option, String trackId, String language) { - super(title); - mOption = option; - mTrackId = trackId; - mLanguage = language; + private ClosedCaptionOptionItem(TvTrackInfo track, Integer trackIndex) { + super(getLabel(track, trackIndex)); + if (track == null) { + mOption = CaptionSettings.OPTION_OFF; + mTrackId = null; + } else { + mOption = CaptionSettings.OPTION_ON; + mTrackId = track.getId(); + } } @Override diff --git a/src/com/android/tv/ui/sidepanel/CompoundButtonItem.java b/src/com/android/tv/ui/sidepanel/CompoundButtonItem.java index 7613a9a2..c2746937 100644 --- a/src/com/android/tv/ui/sidepanel/CompoundButtonItem.java +++ b/src/com/android/tv/ui/sidepanel/CompoundButtonItem.java @@ -23,9 +23,12 @@ import android.widget.TextView; import com.android.tv.R; public abstract class CompoundButtonItem extends Item { + private static int sDefaultMaxLine = 0; + private final String mCheckedTitle; private final String mUncheckedTitle; private final String mDescription; + private final int mMaxLine; private boolean mChecked; private TextView mTextView; private CompoundButton mCompoundButton; @@ -38,6 +41,15 @@ public abstract class CompoundButtonItem extends Item { mCheckedTitle = checkedTitle; mUncheckedTitle = uncheckedTitle; mDescription = description; + mMaxLine = 0; + } + + public CompoundButtonItem(String checkedTitle, String uncheckedTitle, String description, + int maxLine) { + mCheckedTitle = checkedTitle; + mUncheckedTitle = uncheckedTitle; + mDescription = description; + mMaxLine = maxLine; } protected abstract int getCompoundButtonId(); @@ -57,6 +69,15 @@ public abstract class CompoundButtonItem extends Item { mTextView = (TextView) view.findViewById(getTitleViewId()); TextView descriptionView = (TextView) view.findViewById(getDescriptionViewId()); if (mDescription != null) { + if (mMaxLine != 0) { + descriptionView.setMaxLines(mMaxLine); + } else { + if (sDefaultMaxLine == 0) { + sDefaultMaxLine = view.getContext().getResources() + .getInteger(R.integer.option_item_description_max_lines); + } + descriptionView.setMaxLines(sDefaultMaxLine); + } descriptionView.setVisibility(View.VISIBLE); descriptionView.setText(mDescription); } else { diff --git a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java index 9cc54ed2..297e69d9 100644 --- a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java +++ b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java @@ -16,6 +16,8 @@ package com.android.tv.ui.sidepanel; +import android.content.Context; +import android.content.SharedPreferences; import android.media.tv.TvContract.Channels; import android.os.Bundle; import android.support.v17.leanback.widget.VerticalGridView; @@ -27,6 +29,7 @@ import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; +import com.android.tv.common.SharedPreferencesUtils; import com.android.tv.data.Channel; import com.android.tv.data.ChannelNumber; import com.android.tv.ui.OnRepeatedKeyInterceptListener; @@ -36,39 +39,38 @@ import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.List; import java.util.Iterator; +import java.util.List; public class CustomizeChannelListFragment extends SideFragment { private static final int GROUP_BY_SOURCE = 0; private static final int GROUP_BY_HD_SD = 1; private static final String TRACKER_LABEL = "customize channel list"; - private final List<Channel> mChannels = new ArrayList<>(); - private final long mInitialChannelId; + private static final String PREF_KEY_GROUP_SETTINGS = "pref_key_group_settigns"; + private final List<Channel> mChannels = new ArrayList<>(); + private long mInitialChannelId = Channel.INVALID_ID; private long mLastFocusedChannelId = Channel.INVALID_ID; - private int mGroupingType = GROUP_BY_SOURCE; + private static Integer sGroupingType; private TvInputManagerHelper mInputManager; private Channel.DefaultComparator mChannelComparator; private boolean mGroupByFragmentRunning; private final List<Item> mItems = new ArrayList<>(); - public CustomizeChannelListFragment() { - this(Channel.INVALID_ID); - } - - public CustomizeChannelListFragment(long initialChannelId) { - mInitialChannelId = initialChannelId; - } - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mInputManager = getMainActivity().getTvInputManagerHelper(); + mInitialChannelId = getMainActivity().getCurrentChannelId(); mChannelComparator = new Channel.DefaultComparator(getActivity(), mInputManager); + if (sGroupingType == null) { + SharedPreferences sharedPreferences = getContext().getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_UI_SETTINGS, Context.MODE_PRIVATE); + sGroupingType = sharedPreferences.getInt(PREF_KEY_GROUP_SETTINGS, GROUP_BY_SOURCE); + } } @Override @@ -128,13 +130,16 @@ public class CustomizeChannelListFragment extends SideFragment { @Override public void onDestroyView() { getChannelDataManager().applyUpdatedValuesToDb(); - if (!mGroupByFragmentRunning) { - getMainActivity().endShrunkenTvView(); - } super.onDestroyView(); } @Override + public void onDestroy() { + super.onDestroy(); + getMainActivity().endShrunkenTvView(); + } + + @Override protected String getTitle() { return getString(R.string.side_panel_title_edit_channels_for_an_input); } @@ -149,7 +154,7 @@ public class CustomizeChannelListFragment extends SideFragment { mItems.clear(); mChannels.clear(); mChannels.addAll(getChannelDataManager().getChannelList()); - if (mGroupingType == GROUP_BY_SOURCE) { + if (sGroupingType == GROUP_BY_SOURCE) { addItemForGroupBySource(mItems); } else { // GROUP_BY_HD_SD @@ -321,6 +326,49 @@ public class CustomizeChannelListFragment extends SideFragment { } } + public static class GroupByFragment extends SideFragment { + @Override + protected String getTitle() { + return getString(R.string.side_panel_title_group_by); + } + @Override + public String getTrackerLabel() { + return GroupBySubMenu.TRACKER_LABEL; + } + + @Override + protected List<Item> getItemList() { + List<Item> items = new ArrayList<>(); + items.add(new RadioButtonItem( + getString(R.string.edit_channels_group_by_sources)) { + @Override + protected void onSelected() { + super.onSelected(); + setGroupingType(GROUP_BY_SOURCE); + closeFragment(); + } + }); + items.add(new RadioButtonItem( + getString(R.string.edit_channels_group_by_hd_sd)) { + @Override + protected void onSelected() { + super.onSelected(); + setGroupingType(GROUP_BY_HD_SD); + closeFragment(); + } + }); + ((RadioButtonItem) items.get(sGroupingType)).setChecked(true); + return items; + } + + private void setGroupingType(int groupingType) { + sGroupingType = groupingType; + SharedPreferences sharedPreferences = getContext().getSharedPreferences( + SharedPreferencesUtils.SHARED_PREF_UI_SETTINGS, Context.MODE_PRIVATE); + sharedPreferences.edit().putInt(PREF_KEY_GROUP_SETTINGS, groupingType).apply(); + } + } + private class GroupBySubMenu extends SubMenuItem { private static final String TRACKER_LABEL = "Group by"; public GroupBySubMenu(String description) { @@ -330,41 +378,7 @@ public class CustomizeChannelListFragment extends SideFragment { @Override protected SideFragment getFragment() { - return new SideFragment() { - @Override - protected String getTitle() { - return getString(R.string.side_panel_title_group_by); - } - @Override - public String getTrackerLabel() { - return GroupBySubMenu.TRACKER_LABEL; - } - - @Override - protected List<Item> getItemList() { - List<Item> items = new ArrayList<>(); - items.add(new RadioButtonItem( - getString(R.string.edit_channels_group_by_sources)) { - @Override - protected void onSelected() { - super.onSelected(); - mGroupingType = GROUP_BY_SOURCE; - closeFragment(); - } - }); - items.add(new RadioButtonItem( - getString(R.string.edit_channels_group_by_hd_sd)) { - @Override - protected void onSelected() { - super.onSelected(); - mGroupingType = GROUP_BY_HD_SD; - closeFragment(); - } - }); - ((RadioButtonItem) items.get(mGroupingType)).setChecked(true); - return items; - } - }; + return new GroupByFragment(); } @Override diff --git a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java index 0d189cca..f633fa5a 100644 --- a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java +++ b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java @@ -18,8 +18,6 @@ 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; @@ -27,9 +25,11 @@ import android.widget.Toast; import com.android.tv.R; import com.android.tv.TvApplication; import com.android.tv.common.BuildConfig; +import com.android.tv.common.feature.CommonFeatures; import com.android.tv.data.epg.EpgFetcher; import com.android.tv.experiments.Experiments; import com.android.tv.tuner.TunerPreferences; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -54,7 +54,15 @@ public class DeveloperOptionFragment extends SideFragment { @Override protected List<Item> getItemList() { List<Item> items = new ArrayList<>(); - if (BuildConfig.ENG) { + if (CommonFeatures.DVR.isEnabled(getContext())) { + items.add(new ActionItem(getString(R.string.dev_item_dvr_history)) { + @Override + protected void onSelected() { + getMainActivity().getOverlayManager().showDvrHistoryDialog(); + } + }); + } + if (Utils.isDeveloper()) { items.add(new ActionItem(getString(R.string.dev_item_watch_history)) { @Override protected void onSelected() { @@ -62,18 +70,6 @@ public class DeveloperOptionFragment extends SideFragment { } }); } - 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)) { @@ -89,13 +85,18 @@ public class DeveloperOptionFragment extends SideFragment { TunerPreferences.setStoreTsStream(getContext(), isChecked()); } }); + if (Utils.isDeveloper()) { + items.add( + new ActionItem(getString(R.string.dev_item_show_performance_monitor_log)) { + @Override + protected void onSelected() { + TvApplication.getSingletons(getContext()) + .getPerformanceMonitor() + .startPerformanceMonitorEventDebugActivity(getContext()); + } + }); + } return items; } - - /** True if there is the dev options menu */ - public static boolean shouldShow() { - return Experiments.ENABLE_DEVELOPER_FEATURES.get() || BuildConfig.ENG; - } - -} +}
\ No newline at end of file diff --git a/src/com/android/tv/ui/sidepanel/Item.java b/src/com/android/tv/ui/sidepanel/Item.java index 00f16427..4e47e75b 100644 --- a/src/com/android/tv/ui/sidepanel/Item.java +++ b/src/com/android/tv/ui/sidepanel/Item.java @@ -24,6 +24,7 @@ import android.view.ViewGroup; public abstract class Item { private View mItemView; private boolean mEnabled = true; + private boolean mClickable = true; public void setEnabled(boolean enabled) { if (mEnabled != enabled) { @@ -35,6 +36,16 @@ public abstract class Item { } /** + * Sets the item to be clickable or not. + */ + public void setClickable(boolean clickable) { + mClickable = clickable; + if (mItemView != null) { + mItemView.setClickable(clickable); + } + } + + /** * Returns whether this item is enabled. */ public boolean isEnabled() { @@ -64,6 +75,7 @@ public abstract class Item { */ protected void onUpdate() { setEnabledInternal(mItemView, mEnabled); + mItemView.setClickable(mClickable); } protected abstract void onSelected(); diff --git a/src/com/android/tv/ui/sidepanel/PipInputSelectorFragment.java b/src/com/android/tv/ui/sidepanel/PipInputSelectorFragment.java deleted file mode 100644 index dec017a8..00000000 --- a/src/com/android/tv/ui/sidepanel/PipInputSelectorFragment.java +++ /dev/null @@ -1,170 +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 android.media.tv.TvInputInfo; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.tv.R; -import com.android.tv.util.PipInputManager; -import com.android.tv.util.PipInputManager.PipInput; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public class PipInputSelectorFragment extends SideFragment { - private static final String TAG = "PipInputSelector"; - private static final String TRACKER_LABEL = "PIP input source"; - - private final List<Item> mInputItems = new ArrayList<>(); - private PipInputManager mPipInputManager; - private PipInput mInitialPipInput; - private boolean mSelected; - - private final PipInputManager.Listener mPipInputListener = new PipInputManager.Listener() { - @Override - public void onPipInputStateUpdated() { - notifyDataSetChanged(); - } - - @Override - public void onPipInputListUpdated() { - refreshInputList(); - setItems(mInputItems); - } - }; - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - mPipInputManager = getMainActivity().getPipInputManager(); - mPipInputManager.addListener(mPipInputListener); - getMainActivity().startShrunkenTvView(false, false); - return super.onCreateView(inflater, container, savedInstanceState); - } - - @Override - public void onStart() { - super.onStart(); - mInitialPipInput = mPipInputManager.getPipInput(getMainActivity().getPipChannel()); - if (mInitialPipInput == null) { - Log.w(TAG, "PIP should be on"); - closeFragment(); - } - int count = 0; - for (Item item : mInputItems) { - InputItem inputItem = (InputItem) item; - if (Objects.equals(inputItem.mPipInput, mInitialPipInput)) { - setSelectedPosition(count); - break; - } - ++count; - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mPipInputManager.removeListener(mPipInputListener); - if (!mSelected) { - getMainActivity().tuneToChannelForPip(mInitialPipInput.getChannel()); - } - getMainActivity().endShrunkenTvView(); - } - - @Override - protected String getTitle() { - return getString(R.string.side_panel_title_pip_input_source); - } - - @Override - public String getTrackerLabel() { - return TRACKER_LABEL; - } - - @Override - protected List<Item> getItemList() { - refreshInputList(); - return mInputItems; - } - - private void refreshInputList() { - mInputItems.clear(); - for (PipInput input : mPipInputManager.getPipInputList(false)) { - mInputItems.add(new InputItem(input)); - } - } - - private class InputItem extends RadioButtonItem { - private final PipInput mPipInput; - - private InputItem(PipInput input) { - super(input.getLongLabel()); - mPipInput = input; - setEnabled(isAvailable()); - } - - @Override - protected void onUpdate() { - super.onUpdate(); - setEnabled(mPipInput.isAvailable()); - setChecked(mPipInput == mInitialPipInput); - } - - @Override - protected void onFocused() { - super.onFocused(); - if (isEnabled()) { - getMainActivity().tuneToChannelForPip(mPipInput.getChannel()); - } - } - - @Override - protected void onSelected() { - super.onSelected(); - if (isEnabled()) { - mSelected = true; - closeFragment(); - } - } - - private boolean isAvailable() { - if (!mPipInput.isAvailable()) { - return false; - } - - // If this input shares the same parent with the current main input, you cannot select - // it. (E.g. two HDMI CEC devices that are connected to HDMI port 1 through an A/V - // receiver.) - PipInput pipInput = mPipInputManager.getPipInput(getMainActivity().getCurrentChannel()); - if (pipInput == null) { - return false; - } - TvInputInfo mainInputInfo = pipInput.getInputInfo(); - TvInputInfo pipInputInfo = mPipInput.getInputInfo(); - return mainInputInfo == null || pipInputInfo == null - || !TextUtils.equals(mainInputInfo.getId(), pipInputInfo.getId()) - && !TextUtils.equals(mainInputInfo.getParentId(), pipInputInfo.getParentId()); - } - } -} diff --git a/src/com/android/tv/ui/sidepanel/SettingsFragment.java b/src/com/android/tv/ui/sidepanel/SettingsFragment.java index e8033a22..6a5b510c 100644 --- a/src/com/android/tv/ui/sidepanel/SettingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/SettingsFragment.java @@ -16,18 +16,25 @@ package com.android.tv.ui.sidepanel; +import static com.android.tv.Features.TUNER; + +import android.app.ApplicationErrorReport; +import android.content.Intent; +import android.media.tv.TvInputInfo; import android.view.View; import android.widget.Toast; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; +import com.android.tv.customization.TvCustomizationManager; import com.android.tv.dialog.PinDialogFragment; -import com.android.tv.dialog.WebDialogFragment; -import com.android.tv.license.LicenseUtils; -import com.android.tv.ui.sidepanel.parentalcontrols.ParentalControlsFragment; +import com.android.tv.license.LicenseSideFragment; +import com.android.tv.license.Licenses; +import com.android.tv.tuner.TunerPreferences; import com.android.tv.util.PermissionUtils; import com.android.tv.util.SetupUtils; +import com.android.tv.util.Utils; import java.util.ArrayList; import java.util.List; @@ -38,33 +45,6 @@ import java.util.List; public class SettingsFragment extends SideFragment { private static final String TRACKER_LABEL = "settings"; - private final long mCurrentChannelId; - - public SettingsFragment(long currentChannelId) { - mCurrentChannelId = currentChannelId; - } - - /** - * Opens a dialog showing open source licenses. - */ - public static final class LicenseActionItem extends ActionItem { - public final static String DIALOG_TAG = LicenseActionItem.class.getSimpleName(); - public static final String TRACKER_LABEL = "Open Source Licenses"; - private final MainActivity mMainActivity; - - public LicenseActionItem(MainActivity mainActivity) { - super(mainActivity.getString(R.string.settings_menu_licenses)); - mMainActivity = mainActivity; - } - - @Override - protected void onSelected() { - WebDialogFragment dialog = WebDialogFragment.newInstance(LicenseUtils.LICENSE_FILE, - mMainActivity.getString(R.string.dialog_title_licenses), TRACKER_LABEL); - mMainActivity.getOverlayManager().showDialogFragment(DIALOG_TAG, dialog, false); - } - } - @Override protected String getTitle() { return getResources().getString(R.string.side_panel_title_settings); @@ -80,11 +60,11 @@ 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, + getString(R.string.settings_channel_source_item_customize_channels_description), getMainActivity().getOverlayManager().getSideFragmentManager()) { @Override protected SideFragment getFragment() { - return new CustomizeChannelListFragment(mCurrentChannelId); + return new CustomizeChannelListFragment(); } @Override @@ -122,25 +102,11 @@ public class SettingsFragment extends SideFragment { : R.string.option_toggle_parental_controls_off)) { @Override protected void onSelected() { - final MainActivity tvActivity = getMainActivity(); - final SideFragmentManager sideFragmentManager = tvActivity.getOverlayManager() - .getSideFragmentManager(); - sideFragmentManager.hideSidePanel(true); - PinDialogFragment fragment = new PinDialogFragment( - PinDialogFragment.PIN_DIALOG_TYPE_ENTER_PIN, - new PinDialogFragment.ResultListener() { - @Override - public void done(boolean success) { - if (success) { - sideFragmentManager - .show(new ParentalControlsFragment(), false); - sideFragmentManager.showSidePanel(true); - } else { - sideFragmentManager.hideAll(false); - } - } - }); - tvActivity.getOverlayManager() + getMainActivity().getOverlayManager() + .getSideFragmentManager().hideSidePanel(true); + PinDialogFragment fragment = PinDialogFragment + .create(PinDialogFragment.PIN_DIALOG_TYPE_ENTER_PIN); + getMainActivity().getOverlayManager() .showDialogFragment(PinDialogFragment.DIALOG_TAG, fragment, true); } }); @@ -149,12 +115,73 @@ public class SettingsFragment extends SideFragment { // But, we may be able to turn on channel lock feature regardless of the permission. // It's TBD. } - if (LicenseUtils.hasLicenses(activity.getAssets())) { - items.add(new LicenseActionItem(activity)); + boolean showTrickplaySetting = false; + if (TUNER.isEnabled(getContext())) { + for (TvInputInfo inputInfo : TvApplication.getSingletons(getContext()) + .getTvInputManagerHelper().getTvInputInfos(true, true)) { + if (Utils.isInternalTvInput(getContext(), inputInfo.getId())) { + showTrickplaySetting = true; + break; + } + } + if (showTrickplaySetting) { + showTrickplaySetting = + TvCustomizationManager.getTrickplayMode(getContext()) + == TvCustomizationManager.TRICKPLAY_MODE_ENABLED; + } + } + if (showTrickplaySetting) { + items.add( + new SwitchItem(getString(R.string.settings_trickplay), + getString(R.string.settings_trickplay), + getString(R.string.settings_trickplay_description), + getResources().getInteger(R.integer.trickplay_description_max_lines)) { + @Override + protected void onUpdate() { + super.onUpdate(); + boolean enabled = TunerPreferences.getTrickplaySetting(getContext()) + != TunerPreferences.TRICKPLAY_SETTING_DISABLED; + setChecked(enabled); + } + + @Override + protected void onSelected() { + super.onSelected(); + @TunerPreferences.TrickplaySetting int setting = + isChecked() ? TunerPreferences.TRICKPLAY_SETTING_ENABLED + : TunerPreferences.TRICKPLAY_SETTING_DISABLED; + TunerPreferences.setTrickplaySetting(getContext(), setting); + } + }); + } + items.add(new ActionItem(getString(R.string.settings_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); + } + }); + if (Licenses.hasLicenses(getContext())) { + items.add( + new SubMenuItem( + getString(R.string.settings_menu_licenses), + getMainActivity().getOverlayManager().getSideFragmentManager()) { + @Override + protected SideFragment getFragment() { + return new LicenseSideFragment(); + } + }); } // Show version. - items.add(new SimpleItem(getString(R.string.settings_menu_version), - ((TvApplication) activity.getApplicationContext()).getVersionName())); + SimpleActionItem version = new SimpleActionItem(getString(R.string.settings_menu_version), + ((TvApplication) activity.getApplicationContext()).getVersionName()); + version.setClickable(false); + items.add(version); return items; } diff --git a/src/com/android/tv/ui/sidepanel/SideFragment.java b/src/com/android/tv/ui/sidepanel/SideFragment.java index 8df56cd2..6bd921a2 100644 --- a/src/com/android/tv/ui/sidepanel/SideFragment.java +++ b/src/com/android/tv/ui/sidepanel/SideFragment.java @@ -26,32 +26,36 @@ import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.FrameLayout; import android.widget.TextView; import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.TvApplication; -import com.android.tv.analytics.DurationTimer; +import com.android.tv.util.DurationTimer; import com.android.tv.analytics.HasTrackerLabel; import com.android.tv.analytics.Tracker; import com.android.tv.data.ChannelDataManager; import com.android.tv.data.ProgramDataManager; import com.android.tv.util.SystemProperties; +import com.android.tv.util.ViewCache; import java.util.List; -public abstract class SideFragment extends Fragment implements HasTrackerLabel { +public abstract class SideFragment<T extends Item> extends Fragment implements HasTrackerLabel { public static final int INVALID_POSITION = -1; - private static final int RECYCLED_VIEW_POOL_SIZE = 7; - private static final int[] PRELOADED_VIEW_IDS = { + private static final int PRELOAD_VIEW_SIZE = 7; + private static final int[] PRELOAD_VIEW_IDS = { R.layout.option_item_radio_button, R.layout.option_item_channel_lock, R.layout.option_item_check_box, - R.layout.option_item_channel_check + R.layout.option_item_channel_check, + R.layout.option_item_action }; - private static RecyclerView.RecycledViewPool sRecycledViewPool; + private static RecyclerView.RecycledViewPool sRecycledViewPool = + new RecyclerView.RecycledViewPool(); private VerticalGridView mListView; private ItemAdapter mAdapter; @@ -89,14 +93,8 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (sRecycledViewPool == null) { - // sRecycledViewPool should be initialized by calling preloadRecycledViews() - // before the entering animation of this fragment starts, - // because it takes long time and if it is called after the animation starts (e.g. here) - // it can affect the animation. - throw new IllegalStateException("The RecyclerView pool has not been initialized."); - } - View view = inflater.inflate(getFragmentLayoutResourceId(), container, false); + View view = ViewCache.getInstance().getOrCreateView( + inflater, getFragmentLayoutResourceId(), container); TextView textView = (TextView) view.findViewById(R.id.side_panel_title); textView.setText(getTitle()); @@ -158,7 +156,7 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { return mListView.getSelectedPosition(); } - public void setItems(List<Item> items) { + public void setItems(List<T> items) { mAdapter.reset(items); } @@ -229,56 +227,50 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { protected abstract String getTitle(); @Override public abstract String getTrackerLabel(); - protected abstract List<Item> getItemList(); + protected abstract List<T> getItemList(); public interface SideFragmentListener { void onSideFragmentViewDestroyed(); } /** - * Preloads the view holders. + * Preloads the item views. */ - public static void preloadRecycledViews(Context context) { - if (sRecycledViewPool != null) { - return; - } - sRecycledViewPool = new RecyclerView.RecycledViewPool(); - LayoutInflater inflater = - (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - for (int id : PRELOADED_VIEW_IDS) { - sRecycledViewPool.setMaxRecycledViews(id, RECYCLED_VIEW_POOL_SIZE); - for (int j = 0; j < RECYCLED_VIEW_POOL_SIZE; ++j) { - ItemAdapter.ViewHolder viewHolder = new ItemAdapter.ViewHolder( - inflater.inflate(id, null, false)); - sRecycledViewPool.putRecycledView(viewHolder); - } + public static void preloadItemViews(Context context) { + ViewCache.getInstance().putView( + context, R.layout.option_fragment, new FrameLayout(context), 1); + VerticalGridView fakeParent = new VerticalGridView(context); + for (int id : PRELOAD_VIEW_IDS) { + sRecycledViewPool.setMaxRecycledViews(id, PRELOAD_VIEW_SIZE); + ViewCache.getInstance().putView(context, id, fakeParent, PRELOAD_VIEW_SIZE); } } /** - * Releases the pre-loaded view holders. + * Releases the recycled view pool. */ - public static void releasePreloadedRecycledViews() { - sRecycledViewPool = null; + public static void releaseRecycledViewPool() { + sRecycledViewPool.clear(); } - private static class ItemAdapter extends RecyclerView.Adapter<ItemAdapter.ViewHolder> { + private static class ItemAdapter<T extends Item> extends RecyclerView.Adapter<ViewHolder> { private final LayoutInflater mLayoutInflater; - private List<Item> mItems; + private List<T> mItems; - private ItemAdapter(LayoutInflater layoutInflater, List<Item> items) { + private ItemAdapter(LayoutInflater layoutInflater, List<T> items) { mLayoutInflater = layoutInflater; mItems = items; } - private void reset(List<Item> items) { + private void reset(List<T> items) { mItems = items; notifyDataSetChanged(); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - return new ViewHolder(mLayoutInflater.inflate(viewType, parent, false)); + View view = ViewCache.getInstance().getOrCreateView(mLayoutInflater, viewType, parent); + return new ViewHolder(view); } @Override @@ -301,11 +293,11 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { return mItems == null ? 0 : mItems.size(); } - private Item getItem(int position) { + private T getItem(int position) { return mItems.get(position); } - private void clearRadioGroup(Item item) { + private void clearRadioGroup(T item) { int position = mItems.indexOf(item); for (int i = position - 1; i >= 0; --i) { if ((item = mItems.get(i)) instanceof RadioButtonItem) { @@ -322,55 +314,57 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel { } } } + } - private static class ViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener, View.OnFocusChangeListener { - private ItemAdapter mAdapter; - public Item mItem; + private static class ViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener, View.OnFocusChangeListener { + private ItemAdapter mAdapter; + public Item mItem; - private ViewHolder(View view) { - super(view); - itemView.setOnClickListener(this); - itemView.setOnFocusChangeListener(this); - } + private ViewHolder(View view) { + super(view); + itemView.setOnClickListener(this); + itemView.setOnFocusChangeListener(this); + } - public void onBind(ItemAdapter adapter, Item item) { - mAdapter = adapter; - mItem = item; - mItem.onBind(itemView); - mItem.onUpdate(); - } + public void onBind(ItemAdapter adapter, Item item) { + mAdapter = adapter; + mItem = item; + mItem.onBind(itemView); + mItem.onUpdate(); + } - public void onUnbind() { - mItem.onUnbind(); - mItem = null; - mAdapter = null; - } + public void onUnbind() { + mItem.onUnbind(); + mItem = null; + mAdapter = null; + } - @Override - public void onClick(View view) { - if (mItem instanceof RadioButtonItem) { - mAdapter.clearRadioGroup(mItem); - } - if (view.getBackground() instanceof RippleDrawable) { - view.postDelayed(new Runnable() { - @Override - public void run() { - if (mItem != null) { - mItem.onSelected(); + @Override + public void onClick(View view) { + if (mItem instanceof RadioButtonItem) { + mAdapter.clearRadioGroup(mItem); + } + if (view.getBackground() instanceof RippleDrawable) { + view.postDelayed( + new Runnable() { + @Override + public void run() { + if (mItem != null) { + mItem.onSelected(); + } } - } - }, view.getResources().getInteger(R.integer.side_panel_ripple_anim_duration)); - } else { - mItem.onSelected(); - } + }, + view.getResources().getInteger(R.integer.side_panel_ripple_anim_duration)); + } else { + mItem.onSelected(); } + } - @Override - public void onFocusChange(View view, boolean focusGained) { - if (focusGained) { - mItem.onFocused(); - } + @Override + public void onFocusChange(View view, boolean focusGained) { + if (focusGained) { + mItem.onFocused(); } } } diff --git a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java index 553cd9d7..d02d3fb7 100644 --- a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java +++ b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java @@ -24,6 +24,7 @@ import android.app.FragmentManager; import android.app.FragmentTransaction; import android.os.Handler; import android.view.View; +import android.view.ViewTreeObserver; import com.android.tv.R; @@ -34,6 +35,7 @@ public class SideFragmentManager { private final FragmentManager mFragmentManager; private final Runnable mPreShowRunnable; private final Runnable mPostHideRunnable; + private ViewTreeObserver.OnGlobalLayoutListener mShowOnGlobalLayoutListener; // To get the count reliably while using popBackStack(), // instead of using getBackStackEntryCount() with popBackStackImmediate(). @@ -99,17 +101,10 @@ public class SideFragmentManager { * Shows the given {@link SideFragment}. */ public void show(SideFragment sideFragment, boolean showEnterAnimation) { - SideFragment.preloadRecycledViews(mActivity); if (isHiding()) { mHideAnimator.end(); } boolean isFirst = (mFragmentCount == 0); - if (isFirst) { - if (mPreShowRunnable != null) { - mPreShowRunnable.run(); - } - } - FragmentTransaction ft = mFragmentManager.beginTransaction(); if (!isFirst) { ft.setCustomAnimations( @@ -123,8 +118,22 @@ public class SideFragmentManager { mFragmentCount++; if (isFirst) { + // We should wait for fragment transition and intital layouting finished to start the + // slide-in animation to prevent jankiness resulted by performing transition and + // layouting at the same time with animation. mPanel.setVisibility(View.VISIBLE); - mShowAnimator.start(); + mShowOnGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mPanel.getViewTreeObserver().removeOnGlobalLayoutListener(this); + mShowOnGlobalLayoutListener = null; + if (mPreShowRunnable != null) { + mPreShowRunnable.run(); + } + mShowAnimator.start(); + } + }; + mPanel.getViewTreeObserver().addOnGlobalLayoutListener(mShowOnGlobalLayoutListener); } scheduleHideAll(); } @@ -142,6 +151,18 @@ public class SideFragmentManager { } public void hideAll(boolean withAnimation) { + if (mShowAnimator.isStarted()) { + mShowAnimator.end(); + } + if (mShowOnGlobalLayoutListener != null) { + // The show operation maybe requested but the show animator is not started yet, in this + // case, we show still run mPreShowRunnable. + mPanel.getViewTreeObserver().removeOnGlobalLayoutListener(mShowOnGlobalLayoutListener); + mShowOnGlobalLayoutListener = null; + if (mPreShowRunnable != null) { + mPreShowRunnable.run(); + } + } if (withAnimation) { if (!isHiding()) { mHideAnimator.start(); @@ -178,7 +199,6 @@ public class SideFragmentManager { * @param withAnimation specifies if animation should be shown. */ public void showSidePanel(boolean withAnimation) { - SideFragment.preloadRecycledViews(mActivity); if (mFragmentCount == 0) { return; } diff --git a/src/com/android/tv/ui/sidepanel/SimpleItem.java b/src/com/android/tv/ui/sidepanel/SimpleActionItem.java index 52a5f13f..42553b66 100644 --- a/src/com/android/tv/ui/sidepanel/SimpleItem.java +++ b/src/com/android/tv/ui/sidepanel/SimpleActionItem.java @@ -19,12 +19,12 @@ package com.android.tv.ui.sidepanel; /** * A simple item which shows title and description. */ -public class SimpleItem extends ActionItem { - public SimpleItem(String title) { +public class SimpleActionItem extends ActionItem { + public SimpleActionItem(String title) { super(title); } - public SimpleItem(String title, String description) { + public SimpleActionItem(String title, String description) { super(title, description); } diff --git a/src/com/android/tv/ui/sidepanel/SubMenuItem.java b/src/com/android/tv/ui/sidepanel/SubMenuItem.java index aa349dbd..4b0e8e2c 100644 --- a/src/com/android/tv/ui/sidepanel/SubMenuItem.java +++ b/src/com/android/tv/ui/sidepanel/SubMenuItem.java @@ -21,20 +21,11 @@ public abstract class SubMenuItem extends ActionItem { private final SideFragmentManager mSideFragmentManager; public SubMenuItem(String title, SideFragmentManager fragmentManager) { - this(title, null, 0, fragmentManager); + this(title, null, fragmentManager); } public SubMenuItem(String title, String description, SideFragmentManager fragmentManager) { - this(title, description, 0, fragmentManager); - } - - public SubMenuItem(String title, int iconId, SideFragmentManager fragmentManager) { - this(title, null, iconId, fragmentManager); - } - - public SubMenuItem(String title, String description, int iconId, - SideFragmentManager fragmentManager) { - super(title, description, iconId); + super(title, description); mSideFragmentManager = fragmentManager; } diff --git a/src/com/android/tv/ui/sidepanel/SwitchItem.java b/src/com/android/tv/ui/sidepanel/SwitchItem.java index ef9966a5..06591b62 100644 --- a/src/com/android/tv/ui/sidepanel/SwitchItem.java +++ b/src/com/android/tv/ui/sidepanel/SwitchItem.java @@ -31,6 +31,11 @@ public class SwitchItem extends CompoundButtonItem { super(checkedTitle, uncheckedTitle, description); } + public SwitchItem(String checkedTitle, String uncheckedTitle, String description, + int maxLines) { + super(checkedTitle, uncheckedTitle, description, maxLines); + } + @Override protected int getResourceId() { return R.layout.option_item_switch; diff --git a/src/com/android/tv/ui/sidepanel/parentalcontrols/ParentalControlsFragment.java b/src/com/android/tv/ui/sidepanel/parentalcontrols/ParentalControlsFragment.java index da712924..9a4879fc 100644 --- a/src/com/android/tv/ui/sidepanel/parentalcontrols/ParentalControlsFragment.java +++ b/src/com/android/tv/ui/sidepanel/parentalcontrols/ParentalControlsFragment.java @@ -130,17 +130,8 @@ public class ParentalControlsFragment extends SideFragment { protected void onSelected() { final MainActivity tvActivity = getMainActivity(); tvActivity.getOverlayManager().getSideFragmentManager().hideSidePanel(true); - - PinDialogFragment fragment = - new PinDialogFragment( - PinDialogFragment.PIN_DIALOG_TYPE_NEW_PIN, - new PinDialogFragment.ResultListener() { - @Override - public void done(boolean success) { - tvActivity.getOverlayManager().getSideFragmentManager() - .showSidePanel(true); - } - }); + PinDialogFragment fragment = PinDialogFragment.create( + PinDialogFragment.PIN_DIALOG_TYPE_NEW_PIN); tvActivity.getOverlayManager().showDialogFragment(PinDialogFragment.DIALOG_TAG, fragment, true); } diff --git a/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java b/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java index 6bc47939..7c8cecbe 100644 --- a/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/parentalcontrols/RatingsFragment.java @@ -17,16 +17,17 @@ package com.android.tv.ui.sidepanel.parentalcontrols; import android.graphics.drawable.Drawable; +import android.media.tv.TvContentRating; import android.os.Bundle; import android.util.ArrayMap; import android.util.SparseIntArray; import android.view.View; import android.widget.CompoundButton; import android.widget.ImageView; - import com.android.tv.MainActivity; import com.android.tv.R; import com.android.tv.dialog.WebDialogFragment; +import com.android.tv.experiments.Experiments; import com.android.tv.license.LicenseUtils; import com.android.tv.parental.ContentRatingSystem; import com.android.tv.parental.ContentRatingSystem.Rating; @@ -38,7 +39,6 @@ import com.android.tv.ui.sidepanel.RadioButtonItem; import com.android.tv.ui.sidepanel.SideFragment; import com.android.tv.util.TvSettings; import com.android.tv.util.TvSettings.ContentRatingLevel; - import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -77,6 +77,7 @@ public class RatingsFragment extends SideFragment { private final List<RatingLevelItem> mRatingLevelItems = new ArrayList<>(); // A map from the rating system ID string to RatingItem objects. private final Map<String, List<RatingItem>> mContentRatingSystemItemMap = new ArrayMap<>(); + private CheckBoxItem mBlockUnratedItem; private ParentalControlSettings mParentalControlSettings; public static String getDescription(MainActivity tvActivity) { @@ -102,6 +103,12 @@ public class RatingsFragment extends SideFragment { protected List<Item> getItemList() { List<Item> items = new ArrayList<>(); + if (mBlockUnratedItem != null + && Boolean.TRUE.equals(Experiments.ENABLE_UNRATED_CONTENT_SETTINGS.get())) { + items.add(mBlockUnratedItem); + items.add(new DividerItem()); + } + mRatingLevelItems.clear(); for (int i = 0; i < sLevelResourceIdMap.size(); ++i) { mRatingLevelItems.add(new RatingLevelItem(sLevelResourceIdMap.keyAt(i))); @@ -152,6 +159,28 @@ public class RatingsFragment extends SideFragment { super.onCreate(savedInstanceState); mParentalControlSettings = getMainActivity().getParentalControlSettings(); mParentalControlSettings.loadRatings(); + if (Boolean.TRUE.equals(Experiments.ENABLE_UNRATED_CONTENT_SETTINGS.get())) { + mBlockUnratedItem = + new CheckBoxItem( + getResources().getString(R.string.option_block_unrated_programs)) { + + @Override + protected void onUpdate() { + super.onUpdate(); + setChecked( + mParentalControlSettings.isRatingBlocked( + new TvContentRating[] {TvContentRating.UNRATED})); + } + + @Override + protected void onSelected() { + super.onSelected(); + if (mParentalControlSettings.setUnratedBlocked(isChecked())) { + updateRatingLevels(); + } + } + }; + } } @Override @@ -202,6 +231,13 @@ public class RatingsFragment extends SideFragment { super.onSelected(); mParentalControlSettings.setContentRatingLevel( getMainActivity().getContentRatingsManager(), mRatingLevel); + if (mBlockUnratedItem != null + && Boolean.TRUE.equals(Experiments.ENABLE_UNRATED_CONTENT_SETTINGS.get())) { + // set checked if UNRATED is blocked, and set unchecked otherwise. + mBlockUnratedItem.setChecked( + mParentalControlSettings.isRatingBlocked( + new TvContentRating[] {TvContentRating.UNRATED})); + } notifyItemsChanged(mRatingLevelItems.size()); } } @@ -302,7 +338,7 @@ public class RatingsFragment extends SideFragment { @Override protected void onSelected() { getMainActivity().getOverlayManager().getSideFragmentManager() - .show(new SubRatingsFragment(mContentRatingSystem, mRating)); + .show(SubRatingsFragment.create(mContentRatingSystem, mRating.getName())); } @Override diff --git a/src/com/android/tv/ui/sidepanel/parentalcontrols/SubRatingsFragment.java b/src/com/android/tv/ui/sidepanel/parentalcontrols/SubRatingsFragment.java index f6612fdb..4634b74c 100644 --- a/src/com/android/tv/ui/sidepanel/parentalcontrols/SubRatingsFragment.java +++ b/src/com/android/tv/ui/sidepanel/parentalcontrols/SubRatingsFragment.java @@ -17,6 +17,7 @@ package com.android.tv.ui.sidepanel.parentalcontrols; import android.graphics.drawable.Drawable; +import android.os.Bundle; import android.view.View; import android.widget.CompoundButton; import android.widget.ImageView; @@ -36,13 +37,34 @@ import java.util.List; public class SubRatingsFragment extends SideFragment { private static final String TRACKER_LABEL = "Sub ratings"; - private final ContentRatingSystem mContentRatingSystem; - private final Rating mRating; + private static final String ARGS_CONTENT_RATING_SYSTEM_ID = "args_content_rating_system_id"; + private static final String ARGS_RATING_NAME = "args_rating_name"; + + private ContentRatingSystem mContentRatingSystem; + private Rating mRating; private final List<SubRatingItem> mSubRatingItems = new ArrayList<>(); - public SubRatingsFragment(ContentRatingSystem contentRatingSystem, Rating rating) { - mContentRatingSystem = contentRatingSystem; - mRating = rating; + public static SubRatingsFragment create(ContentRatingSystem contentRatingSystem, + String ratingName) { + SubRatingsFragment fragment = new SubRatingsFragment(); + Bundle args = new Bundle(); + args.putString(ARGS_CONTENT_RATING_SYSTEM_ID, contentRatingSystem.getId()); + args.putString(ARGS_RATING_NAME, ratingName); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mContentRatingSystem = getMainActivity().getContentRatingsManager() + .getContentRatingSystem(getArguments().getString(ARGS_CONTENT_RATING_SYSTEM_ID)); + if (mContentRatingSystem != null) { + mRating = mContentRatingSystem.getRating(getArguments().getString(ARGS_RATING_NAME)); + } + if (mRating == null) { + closeFragment(); + } } @Override diff --git a/src/com/android/tv/util/AsyncDbTask.java b/src/com/android/tv/util/AsyncDbTask.java index 78243642..477412e4 100644 --- a/src/com/android/tv/util/AsyncDbTask.java +++ b/src/com/android/tv/util/AsyncDbTask.java @@ -31,7 +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 com.android.tv.dvr.data.RecordedProgram; import java.util.ArrayList; import java.util.List; @@ -76,7 +76,7 @@ public abstract class AsyncDbTask<Params, Progress, Result> * accepted for execution * @throws NullPointerException if command is null */ - public static void execute(Runnable command) { + public static void executeOnDbThread(Runnable command) { DB_EXECUTOR.execute(command); } diff --git a/src/com/android/tv/util/BitmapUtils.java b/src/com/android/tv/util/BitmapUtils.java index d45a8dce..fbaab023 100644 --- a/src/com/android/tv/util/BitmapUtils.java +++ b/src/com/android/tv/util/BitmapUtils.java @@ -24,6 +24,7 @@ import android.graphics.BitmapFactory; import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.net.TrafficStats; import android.net.Uri; import android.support.annotation.NonNull; import android.text.TextUtils; @@ -56,6 +57,12 @@ public final class BitmapUtils { return Bitmap.createScaledBitmap(bm, rect.right, rect.bottom, false); } + public static Bitmap getScaledMutableBitmap(Bitmap bm, int maxWidth, int maxHeight) { + Bitmap scaledBitmap = scaleBitmap(bm, maxWidth, maxHeight); + return scaledBitmap.isMutable() ? scaledBitmap + : scaledBitmap.copy(Bitmap.Config.ARGB_8888, true); + } + private static Rect calculateNewSize(Bitmap bm, int maxWidth, int maxHeight) { final double ratio = maxHeight / (double) maxWidth; final double bmRatio = bm.getHeight() / (double) bm.getWidth(); @@ -89,6 +96,8 @@ public final class BitmapUtils { boolean isResourceUri = isContentResolverUri(uri); URLConnection urlConnection = null; InputStream inputStream = null; + final int oldTag = TrafficStats.getThreadStatsTag(); + TrafficStats.setThreadStatsTag(NetworkTrafficTags.LOGO_FETCHER); try { if (isResourceUri) { inputStream = context.getContentResolver().openInputStream(uri); @@ -142,6 +151,7 @@ public final class BitmapUtils { return null; } finally { close(inputStream, urlConnection); + TrafficStats.setThreadStatsTag(oldTag); } } diff --git a/src/com/android/tv/util/Debug.java b/src/com/android/tv/util/Debug.java new file mode 100644 index 00000000..67a2683d --- /dev/null +++ b/src/com/android/tv/util/Debug.java @@ -0,0 +1,60 @@ +/* + * 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.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * A class only for help developers. + */ +public class Debug { + /** + * A threshold of start up time, when the start up time of Live TV is more than it, + * a warning will show to the developer. + */ + public static final long TIME_START_UP_DURATION_THRESHOLD = TimeUnit.SECONDS.toMillis(6); + /** + * Tag for measuring start up time of Live TV. + */ + public static final String TAG_START_UP_TIMER = "start_up_timer"; + + /** + * A global map for duration timers. + */ + private final static Map<String, DurationTimer> sTimerMap = new HashMap<>(); + + /** + * Returns the global duration timer by tag. + */ + public static DurationTimer getTimer(String tag) { + if (sTimerMap.get(tag) != null) { + return sTimerMap.get(tag); + } + DurationTimer timer = new DurationTimer(tag, true); + sTimerMap.put(tag, timer); + return timer; + } + + /** + * Removes the global duration timer by tag. + */ + public static DurationTimer removeTimer(String tag) { + return sTimerMap.remove(tag); + } +} diff --git a/src/com/android/tv/analytics/DurationTimer.java b/src/com/android/tv/util/DurationTimer.java index ad2d91f8..1f057bf6 100644 --- a/src/com/android/tv/analytics/DurationTimer.java +++ b/src/com/android/tv/util/DurationTimer.java @@ -14,30 +14,50 @@ * limitations under the License. */ -package com.android.tv.analytics; +package com.android.tv.util; import android.os.SystemClock; +import android.util.Log; + +import com.android.tv.common.BuildConfig; /** * Times a duration. */ public final class DurationTimer { + private static final String TAG = "DurationTimer"; public static final long TIME_NOT_SET = -1; - private long startTimeMs = TIME_NOT_SET; + private long mStartTimeMs = TIME_NOT_SET; + private String mTag = TAG; + private boolean mLogEngOnly; + + public DurationTimer() { } + + public DurationTimer(String tag, boolean logEngOnly) { + mTag = tag; + mLogEngOnly = logEngOnly; + } /** * Returns true if the timer is running. */ public boolean isRunning() { - return startTimeMs != TIME_NOT_SET; + return mStartTimeMs != TIME_NOT_SET; } /** * Start the timer. */ public void start() { - startTimeMs = SystemClock.elapsedRealtime(); + mStartTimeMs = SystemClock.elapsedRealtime(); + } + + /** + * Returns true if timer is started. + */ + public boolean isStarted() { + return mStartTimeMs != TIME_NOT_SET; } /** @@ -45,7 +65,7 @@ public final class DurationTimer { * running. */ public long getDuration() { - return isRunning() ? SystemClock.elapsedRealtime() - startTimeMs : TIME_NOT_SET; + return isRunning() ? SystemClock.elapsedRealtime() - mStartTimeMs : TIME_NOT_SET; } /** @@ -56,7 +76,16 @@ public final class DurationTimer { */ public long reset() { long duration = getDuration(); - startTimeMs = TIME_NOT_SET; + mStartTimeMs = TIME_NOT_SET; return duration; } + + /** + * Adds information and duration time to the log. + */ + public void log(String message) { + if (isRunning() && (!mLogEngOnly || BuildConfig.ENG)) { + Log.i(mTag, message + " : " + getDuration() + "ms"); + } + } } diff --git a/src/com/android/tv/util/ImageLoader.java b/src/com/android/tv/util/ImageLoader.java index 04bb478a..86bb94c1 100644 --- a/src/com/android/tv/util/ImageLoader.java +++ b/src/com/android/tv/util/ImageLoader.java @@ -292,7 +292,8 @@ public final class ImageLoader { * Checks if a reload would be needed if the results of other was available. */ private boolean isReloadNeeded(LoadBitmapTask other) { - return mMaxHeight >= other.mMaxHeight * 2 || mMaxWidth >= other.mMaxWidth * 2; + return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2) + || (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2); } @Nullable diff --git a/src/com/android/tv/util/LocationUtils.java b/src/com/android/tv/util/LocationUtils.java index 8e3b59e9..d5d7bee3 100644 --- a/src/com/android/tv/util/LocationUtils.java +++ b/src/com/android/tv/util/LocationUtils.java @@ -16,15 +16,20 @@ package com.android.tv.util; +import android.Manifest; import android.content.Context; +import android.content.pm.PackageManager; 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.support.annotation.NonNull; +import android.text.TextUtils; import android.util.Log; +import com.android.tv.tuner.util.PostalCodeUtils; import java.io.IOException; import java.util.List; @@ -39,6 +44,7 @@ public class LocationUtils { private static Context sApplicationContext; private static Address sAddress; + private static String sCountry; private static IOException sError; /** @@ -59,6 +65,18 @@ public class LocationUtils { return null; } + /** Returns the current country. */ + @NonNull + public static synchronized String getCurrentCountry(Context context) { + if (sCountry != null) { + return sCountry; + } + if (TextUtils.isEmpty(sCountry)) { + sCountry = context.getResources().getConfiguration().locale.getCountry(); + } + return sCountry; + } + private static void updateAddress(Location location) { if (DEBUG) Log.d(TAG, "Updating address with " + location); if (location == null) { @@ -68,9 +86,14 @@ public class LocationUtils { try { List<Address> addresses = geocoder.getFromLocation( location.getLatitude(), location.getLongitude(), 1); - if (addresses != null) { + if (addresses != null && !addresses.isEmpty()) { sAddress = addresses.get(0); if (DEBUG) Log.d(TAG, "Got " + sAddress); + try { + PostalCodeUtils.updatePostalCode(sApplicationContext); + } catch (Exception e) { + // Do nothing + } } else { if (DEBUG) Log.d(TAG, "No address returned"); } diff --git a/src/com/android/tv/util/NetworkTrafficTags.java b/src/com/android/tv/util/NetworkTrafficTags.java new file mode 100644 index 00000000..2dca613c --- /dev/null +++ b/src/com/android/tv/util/NetworkTrafficTags.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 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.net.TrafficStats; +import android.support.annotation.NonNull; + +import java.util.concurrent.Executor; + +/** Constants for tagging network traffic in the Live channels app. */ +public final class NetworkTrafficTags { + + public static final int DEFAULT_LIVE_CHANNELS = 1; + public static final int LOGO_FETCHER = 2; + public static final int HDHOMERUN = 3; + public static final int EPG_FETCH = 4; + + /** + * An executor which simply wraps a provided delegate executor, but calls {@link + * TrafficStats#setThreadStatsTag(int)} before executing any task. + */ + public static class TrafficStatsTaggingExecutor implements Executor { + private final Executor delegateExecutor; + private final int tag; + + public TrafficStatsTaggingExecutor(Executor delegateExecutor, int tag) { + this.delegateExecutor = delegateExecutor; + this.tag = tag; + } + + @Override + public void execute(final @NonNull Runnable command) { + // TODO(b/62038127): robolectric does not support lamdas in unbundled apps + delegateExecutor.execute( + new Runnable() { + @Override + public void run() { + TrafficStats.setThreadStatsTag(tag); + try { + command.run(); + } finally { + TrafficStats.clearThreadStatsTag(); + } + } + }); + } + } + + private NetworkTrafficTags() {} +} diff --git a/src/com/android/tv/util/OnboardingUtils.java b/src/com/android/tv/util/OnboardingUtils.java index 3040020e..49b02b82 100644 --- a/src/com/android/tv/util/OnboardingUtils.java +++ b/src/com/android/tv/util/OnboardingUtils.java @@ -16,17 +16,10 @@ package com.android.tv.util; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.media.tv.TvContract.Channels; import android.net.Uri; import android.preference.PreferenceManager; -import android.support.annotation.UiThread; - -import com.android.tv.TvApplication; -import com.android.tv.data.ChannelDataManager; /** * A utility class related to onboarding experience. @@ -82,41 +75,11 @@ public final class OnboardingUtils { } /** - * Checks whether the onboarding screen should be shown or not. - */ - public static boolean needToShowOnboarding(Context context) { - return isFirstRunWithCurrentVersion(context) || !areChannelsAvailable(context); - } - - /** - * Checks if there are any available tuner channels. - */ - @UiThread - public static boolean areChannelsAvailable(Context context) { - ChannelDataManager manager = TvApplication.getSingletons(context).getChannelDataManager(); - if (manager.isDbLoadFinished()) { - return manager.getChannelCount() != 0; - } - // This method should block the UI thread. - ContentResolver resolver = context.getContentResolver(); - try (Cursor c = resolver.query(Channels.CONTENT_URI, new String[] {Channels._ID}, null, - null, null)) { - return c != null && c.getCount() != 0; - } - } - - /** - * Checks if there are any available TV inputs. - */ - public static boolean areInputsAvailable(Context context) { - 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"; } + + private OnboardingUtils() {} } diff --git a/src/com/android/tv/util/Partner.java b/src/com/android/tv/util/Partner.java new file mode 100644 index 00000000..e3688392 --- /dev/null +++ b/src/com/android/tv/util/Partner.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2017 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.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.media.tv.TvInputInfo; +import android.text.TextUtils; +import android.util.Log; + +import java.util.HashMap; +import java.util.Map; + +/** + * This file refers to Partner.java in LeanbackLauncher. Interact with partner customizations. There + * can only be one set of customizations on a device, and it must be bundled with the system. + */ +public class Partner { + private static final String TAG = "Partner"; + /** Marker action used to discover partner */ + private static final String ACTION_PARTNER_CUSTOMIZATION = + "com.google.android.leanbacklauncher.action.PARTNER_CUSTOMIZATION"; + + /** ID tags for device input types */ + public static final String INPUT_TYPE_BUNDLED_TUNER = "input_type_combined_tuners"; + public static final String INPUT_TYPE_TUNER = "input_type_tuner"; + public static final String INPUT_TYPE_CEC_LOGICAL = "input_type_cec_logical"; + public static final String INPUT_TYPE_CEC_RECORDER = "input_type_cec_recorder"; + public static final String INPUT_TYPE_CEC_PLAYBACK = "input_type_cec_playback"; + public static final String INPUT_TYPE_MHL_MOBILE = "input_type_mhl_mobile"; + public static final String INPUT_TYPE_HDMI = "input_type_hdmi"; + public static final String INPUT_TYPE_DVI = "input_type_dvi"; + public static final String INPUT_TYPE_COMPONENT = "input_type_component"; + public static final String INPUT_TYPE_SVIDEO = "input_type_svideo"; + public static final String INPUT_TYPE_COMPOSITE = "input_type_composite"; + public static final String INPUT_TYPE_DISPLAY_PORT = "input_type_displayport"; + public static final String INPUT_TYPE_VGA = "input_type_vga"; + public static final String INPUT_TYPE_SCART = "input_type_scart"; + public static final String INPUT_TYPE_OTHER = "input_type_other"; + + private static final String INPUTS_ORDER = "home_screen_inputs_ordering"; + private static final String TYPE_ARRAY = "array"; + + private static Partner sPartner; + private static final Object sLock = new Object(); + + private final String mPackageName; + private final String mReceiverName; + private final Resources mResources; + + private static final Map<String, Integer> INPUT_TYPE_MAP = new HashMap<>(); + static { + INPUT_TYPE_MAP.put(INPUT_TYPE_BUNDLED_TUNER, TvInputManagerHelper.TYPE_BUNDLED_TUNER); + INPUT_TYPE_MAP.put(INPUT_TYPE_TUNER, TvInputInfo.TYPE_TUNER); + INPUT_TYPE_MAP.put(INPUT_TYPE_CEC_LOGICAL, TvInputManagerHelper.TYPE_CEC_DEVICE); + INPUT_TYPE_MAP.put(INPUT_TYPE_CEC_RECORDER, TvInputManagerHelper.TYPE_CEC_DEVICE_RECORDER); + INPUT_TYPE_MAP.put(INPUT_TYPE_CEC_PLAYBACK, TvInputManagerHelper.TYPE_CEC_DEVICE_PLAYBACK); + INPUT_TYPE_MAP.put(INPUT_TYPE_MHL_MOBILE, TvInputManagerHelper.TYPE_MHL_MOBILE); + INPUT_TYPE_MAP.put(INPUT_TYPE_HDMI, TvInputInfo.TYPE_HDMI); + INPUT_TYPE_MAP.put(INPUT_TYPE_DVI, TvInputInfo.TYPE_DVI); + INPUT_TYPE_MAP.put(INPUT_TYPE_COMPONENT, TvInputInfo.TYPE_COMPONENT); + INPUT_TYPE_MAP.put(INPUT_TYPE_SVIDEO, TvInputInfo.TYPE_SVIDEO); + INPUT_TYPE_MAP.put(INPUT_TYPE_COMPOSITE, TvInputInfo.TYPE_COMPOSITE); + INPUT_TYPE_MAP.put(INPUT_TYPE_DISPLAY_PORT, TvInputInfo.TYPE_DISPLAY_PORT); + INPUT_TYPE_MAP.put(INPUT_TYPE_VGA, TvInputInfo.TYPE_VGA); + INPUT_TYPE_MAP.put(INPUT_TYPE_SCART, TvInputInfo.TYPE_SCART); + INPUT_TYPE_MAP.put(INPUT_TYPE_OTHER, TvInputInfo.TYPE_OTHER); + } + + private Partner(String packageName, String receiverName, Resources res) { + mPackageName = packageName; + mReceiverName = receiverName; + mResources = res; + } + + /** Returns partner instance. */ + public static Partner getInstance(Context context) { + PackageManager pm = context.getPackageManager(); + synchronized (sLock) { + ResolveInfo info = getPartnerResolveInfo(pm); + if (info != null) { + final String packageName = info.activityInfo.packageName; + final String receiverName = info.activityInfo.name; + try { + final Resources res = pm.getResourcesForApplication(packageName); + sPartner = new Partner(packageName, receiverName, res); + sPartner.sendInitBroadcast(context); + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "Failed to find resources for " + packageName); + } + } + if (sPartner == null) { + sPartner = new Partner(null, null, null); + } + } + return sPartner; + } + + /** Resets the Partner instance to handle the partner package has changed. */ + public static void reset(Context context, String packageName) { + synchronized (sLock) { + if (sPartner != null && !TextUtils.isEmpty(packageName)) { + if (packageName.equals(sPartner.mPackageName)) { + // Force a refresh, so we send an Init to the updated package + sPartner = null; + getInstance(context); + } + } + } + } + + /** This method is used to send init broadcast to the new/changed partner package. */ + private void sendInitBroadcast(Context context) { + if (!TextUtils.isEmpty(mPackageName) && !TextUtils.isEmpty(mReceiverName)) { + Intent intent = new Intent(ACTION_PARTNER_CUSTOMIZATION); + final ComponentName componentName = new ComponentName(mPackageName, mReceiverName); + intent.setComponent(componentName); + intent.setFlags(Intent.FLAG_RECEIVER_FOREGROUND); + context.sendBroadcast(intent); + } + } + + /** Returns the order of inputs. */ + public Map<Integer, Integer> getInputsOrderMap() { + HashMap<Integer, Integer> map = new HashMap<>(); + if (mResources != null && !TextUtils.isEmpty(mPackageName)) { + String[] inputsArray = null; + final int resId = mResources.getIdentifier(INPUTS_ORDER, TYPE_ARRAY, mPackageName); + if (resId != 0) { + inputsArray = mResources.getStringArray(resId); + } + if (inputsArray != null) { + int priority = 0; + for (String input : inputsArray) { + Integer type = INPUT_TYPE_MAP.get(input); + if (type != null) { + map.put(type, priority++); + } + } + } + } + return map; + } + + private static ResolveInfo getPartnerResolveInfo(PackageManager pm) { + final Intent intent = new Intent(ACTION_PARTNER_CUSTOMIZATION); + ResolveInfo partnerInfo = null; + for (ResolveInfo info : pm.queryBroadcastReceivers(intent, 0)) { + if (isSystemApp(info)) { + partnerInfo = info; + break; + } + } + return partnerInfo; + } + + protected static boolean isSystemApp(ResolveInfo info) { + return (info.activityInfo != null + && info.activityInfo.applicationInfo != null + && (info.activityInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0); + } +} diff --git a/src/com/android/tv/util/PermissionUtils.java b/src/com/android/tv/util/PermissionUtils.java index 453885a4..a355be99 100644 --- a/src/com/android/tv/util/PermissionUtils.java +++ b/src/com/android/tv/util/PermissionUtils.java @@ -47,4 +47,9 @@ public class PermissionUtils { return context.checkSelfPermission(PERMISSION_READ_TV_LISTINGS) == PackageManager.PERMISSION_GRANTED; } + + public static boolean hasInternet(Context context) { + return context.checkSelfPermission("android.permission.INTERNET") + == PackageManager.PERMISSION_GRANTED; + } } diff --git a/src/com/android/tv/util/PipInputManager.java b/src/com/android/tv/util/PipInputManager.java deleted file mode 100644 index 2c51d5a0..00000000 --- a/src/com/android/tv/util/PipInputManager.java +++ /dev/null @@ -1,432 +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.util; - -import android.content.Context; -import android.media.tv.TvInputInfo; -import android.media.tv.TvInputManager; -import android.media.tv.TvInputManager.TvInputCallback; -import android.util.ArraySet; -import android.util.Log; - -import com.android.tv.ChannelTuner; -import com.android.tv.R; -import com.android.tv.data.Channel; - -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.Set; - -/** - * A class that manages inputs for PIP. All tuner inputs are represented to one tuner input for PIP. - * Hidden inputs should not be visible to the users. - */ -public class PipInputManager { - private static final String TAG = "PipInputManager"; - - // Tuner inputs aren't distinguished each other in PipInput. They are handled as one input. - // Therefore, we define a fake input id for the unified input. - private static final String TUNER_INPUT_ID = "tuner_input_id"; - - private final Context mContext; - private final TvInputManagerHelper mInputManager; - private final ChannelTuner mChannelTuner; - private boolean mStarted; - private final Map<String, PipInput> mPipInputMap = new HashMap<>(); // inputId -> PipInput - private final Set<Listener> mListeners = new ArraySet<>(); - - private final TvInputCallback mTvInputCallback = new TvInputCallback() { - @Override - public void onInputAdded(String inputId) { - TvInputInfo input = mInputManager.getTvInputInfo(inputId); - if (input.isPassthroughInput()) { - boolean available = mInputManager.getInputState(input) - == TvInputManager.INPUT_STATE_CONNECTED; - mPipInputMap.put(inputId, new PipInput(inputId, available)); - } else if (!mPipInputMap.containsKey(TUNER_INPUT_ID)) { - boolean available = mChannelTuner.getBrowsableChannelCount() != 0; - mPipInputMap.put(TUNER_INPUT_ID, new PipInput(TUNER_INPUT_ID, available)); - } else { - return; - } - for (Listener l : mListeners) { - l.onPipInputListUpdated(); - } - } - - @Override - public void onInputRemoved(String inputId) { - PipInput pipInput = mPipInputMap.remove(inputId); - if (pipInput == null) { - if (!mPipInputMap.containsKey(TUNER_INPUT_ID)) { - Log.w(TAG, "A TV input (" + inputId + ") isn't tracked in PipInputManager"); - return; - } - if (mInputManager.getTunerTvInputSize() > 0) { - return; - } - mPipInputMap.remove(TUNER_INPUT_ID); - } - for (Listener l : mListeners) { - l.onPipInputListUpdated(); - } - } - - @Override - public void onInputStateChanged(String inputId, int state) { - PipInput pipInput = mPipInputMap.get(inputId); - if (pipInput == null) { - // For tuner input, state change is handled in mChannelTunerListener. - return; - } - pipInput.updateAvailability(); - } - }; - - private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() { - @Override - public void onLoadFinished() { } - - @Override - public void onCurrentChannelUnavailable(Channel channel) { } - - @Override - public void onBrowsableChannelListChanged() { - PipInput tunerInput = mPipInputMap.get(TUNER_INPUT_ID); - if (tunerInput == null) { - return; - } - tunerInput.updateAvailability(); - } - - @Override - public void onChannelChanged(Channel previousChannel, Channel currentChannel) { - if (previousChannel != null && currentChannel != null - && !previousChannel.isPassthrough() && !currentChannel.isPassthrough()) { - // Channel change between channels for tuner inputs. - return; - } - PipInput previousMainInput = getPipInput(previousChannel); - if (previousMainInput != null) { - previousMainInput.updateAvailability(); - } - PipInput currentMainInput = getPipInput(currentChannel); - if (currentMainInput != null) { - currentMainInput.updateAvailability(); - } - } - }; - - public PipInputManager(Context context, TvInputManagerHelper inputManager, - ChannelTuner channelTuner) { - mContext = context; - mInputManager = inputManager; - mChannelTuner = channelTuner; - } - - /** - * Starts {@link PipInputManager}. - */ - public void start() { - if (mStarted) { - return; - } - mStarted = true; - mInputManager.addCallback(mTvInputCallback); - mChannelTuner.addListener(mChannelTunerListener); - initializePipInputList(); - } - - /** - * Stops {@link PipInputManager}. - */ - public void stop() { - if (!mStarted) { - return; - } - mStarted = false; - mInputManager.removeCallback(mTvInputCallback); - mChannelTuner.removeListener(mChannelTunerListener); - mPipInputMap.clear(); - } - - /** - * Adds a {@link PipInputManager.Listener}. - */ - public void addListener(Listener listener) { - mListeners.add(listener); - } - - /** - * Removes a {@link PipInputManager.Listener}. - */ - public void removeListener(Listener listener) { - mListeners.remove(listener); - } - - /** - * Gets the size of inputs for PIP. - * - * <p>The hidden inputs are not counted. - * - * @param availableOnly If {@code true}, it counts only available PIP inputs. Please see {@link - * PipInput#isAvailable()} for the details of availability. - */ - public int getPipInputSize(boolean availableOnly) { - int count = 0; - for (PipInput pipInput : mPipInputMap.values()) { - if (!pipInput.isHidden() && (!availableOnly || pipInput.mAvailable)) { - ++count; - } - if (pipInput.isPassthrough()) { - TvInputInfo info = pipInput.getInputInfo(); - // Do not count HDMI ports if a CEC device is directly connected to the port. - if (info.getParentId() != null && !info.isConnectedToHdmiSwitch()) { - --count; - } - } - } - return count; - } - - /** - * Gets the list of inputs for PIP.. - * - * <p>The hidden inputs are excluded. - * - * @param availableOnly If true, it returns only available PIP inputs. Please see {@link - * PipInput#isAvailable()} for the details of availability. - */ - public List<PipInput> getPipInputList(boolean availableOnly) { - List<PipInput> pipInputs = new ArrayList<>(); - List<PipInput> removeInputs = new ArrayList<>(); - for (PipInput pipInput : mPipInputMap.values()) { - if (!pipInput.isHidden() && (!availableOnly || pipInput.mAvailable)) { - pipInputs.add(pipInput); - } - if (pipInput.isPassthrough()) { - TvInputInfo info = pipInput.getInputInfo(); - // Do not show HDMI ports if a CEC device is directly connected to the port. - if (info.getParentId() != null && !info.isConnectedToHdmiSwitch()) { - removeInputs.add(mPipInputMap.get(info.getParentId())); - } - } - } - if (!removeInputs.isEmpty()) { - pipInputs.removeAll(removeInputs); - } - Collections.sort(pipInputs, new Comparator<PipInput>() { - @Override - public int compare(PipInput lhs, PipInput rhs) { - if (!lhs.mIsPassthrough) { - return -1; - } - if (!rhs.mIsPassthrough) { - return 1; - } - String a = lhs.getLabel(); - String b = rhs.getLabel(); - return a.compareTo(b); - } - }); - return pipInputs; - } - - /** - * Returns an PIP input corresponding to {@code channel}. - */ - public PipInput getPipInput(Channel channel) { - if (channel == null) { - return null; - } - if (channel.isPassthrough()) { - return mPipInputMap.get(channel.getInputId()); - } else { - return mPipInputMap.get(TUNER_INPUT_ID); - } - } - - /** - * Returns true, if {@code channel1} and {@code channel2} belong to the same input. For example, - * two channels from different tuner inputs are also in the same input "Tuner" from PIP - * point of view. - */ - public boolean areInSamePipInput(Channel channel1, Channel channel2) { - PipInput input1 = getPipInput(channel1); - PipInput input2 = getPipInput(channel2); - return input1 != null && input2 != null - && getPipInput(channel1).equals(getPipInput(channel2)); - } - - private void initializePipInputList() { - boolean hasTunerInput = false; - for (TvInputInfo input : mInputManager.getTvInputInfos(false, false)) { - if (input.isPassthroughInput()) { - boolean available = mInputManager.getInputState(input) - == TvInputManager.INPUT_STATE_CONNECTED; - mPipInputMap.put(input.getId(), new PipInput(input.getId(), available)); - } else if (!hasTunerInput) { - hasTunerInput = true; - boolean available = mChannelTuner.getBrowsableChannelCount() != 0; - mPipInputMap.put(TUNER_INPUT_ID, new PipInput(TUNER_INPUT_ID, available)); - } - } - PipInput input = getPipInput(mChannelTuner.getCurrentChannel()); - if (input != null) { - input.updateAvailability(); - } - for (Listener l : mListeners) { - l.onPipInputListUpdated(); - } - } - - /** - * Listeners to notify PIP input state changes. - */ - public interface Listener { - /** - * Called when the state (availability) of PIP inputs is changed. - */ - void onPipInputStateUpdated(); - - /** - * Called when the list of PIP inputs is changed. - */ - void onPipInputListUpdated(); - } - - /** - * Input class for PIP. It has useful methods for PIP handling. - */ - public class PipInput { - private final String mInputId; - private final boolean mIsPassthrough; - private final TvInputInfo mInputInfo; - private boolean mAvailable; - - private PipInput(String inputId, boolean available) { - mInputId = inputId; - mIsPassthrough = !mInputId.equals(TUNER_INPUT_ID); - if (mIsPassthrough) { - mInputInfo = mInputManager.getTvInputInfo(mInputId); - } else { - mInputInfo = null; - } - mAvailable = available; - } - - /** - * Returns the {@link TvInputInfo} object that matches to this PIP input. - */ - public TvInputInfo getInputInfo() { - return mInputInfo; - } - - /** - * Returns {@code true}, if the input is available for PIP. If a channel of an input is - * already played or an input is not connected state or there is no browsable channel, the - * input is unavailable. - */ - public boolean isAvailable() { - return mAvailable; - } - - /** - * Returns true, if the input is a passthrough TV input. - */ - public boolean isPassthrough() { - return mIsPassthrough; - } - - /** - * Gets a channel to play in a PIP view. - */ - public Channel getChannel() { - if (mIsPassthrough) { - return Channel.createPassthroughChannel(mInputId); - } else { - return mChannelTuner.findNearestBrowsableChannel( - Utils.getLastWatchedChannelId(mContext)); - } - } - - /** - * Gets a label of the input. - */ - public String getLabel() { - if (mIsPassthrough) { - return mInputInfo.loadLabel(mContext).toString(); - } else { - return mContext.getString(R.string.input_selector_tuner_label); - } - } - - /** - * Gets a long label including a customized label. - */ - public String getLongLabel() { - if (mIsPassthrough) { - String customizedLabel = Utils.loadLabel(mContext, mInputInfo); - String label = getLabel(); - if (label.equals(customizedLabel)) { - return customizedLabel; - } - return customizedLabel + " (" + label + ")"; - } else { - return mContext.getString(R.string.input_long_label_for_tuner); - } - } - - /** - * Updates availability. It returns true, if availability is changed. - */ - private void updateAvailability() { - boolean available; - // current playing input cannot be available for PIP. - Channel currentChannel = mChannelTuner.getCurrentChannel(); - if (mIsPassthrough) { - if (currentChannel != null && currentChannel.getInputId().equals(mInputId)) { - available = false; - } else { - available = mInputManager.getInputState(mInputId) - == TvInputManager.INPUT_STATE_CONNECTED; - } - } else { - if (currentChannel != null && !currentChannel.isPassthrough()) { - available = false; - } else { - available = mChannelTuner.getBrowsableChannelCount() > 0; - } - } - if (mAvailable != available) { - mAvailable = available; - for (Listener l : mListeners) { - l.onPipInputStateUpdated(); - } - } - } - - private boolean isHidden() { - // mInputInfo is null for the tuner input and it's always visible. - return mInputInfo != null && mInputInfo.isHidden(mContext); - } - } -} diff --git a/src/com/android/tv/util/RecurringRunner.java b/src/com/android/tv/util/RecurringRunner.java index 4135bd4e..8b45131b 100644 --- a/src/com/android/tv/util/RecurringRunner.java +++ b/src/com/android/tv/util/RecurringRunner.java @@ -57,12 +57,15 @@ public final class RecurringRunner { mHandler = new Handler(mContext.getMainLooper()); } - public void start() { + public void start(boolean resetNextRunTime) { SoftPreconditions.checkState(!mRunning, TAG, mName + " start is called twice."); if (mRunning) { return; } mRunning = true; + if (resetNextRunTime) { + resetNextRunTime(); + } new AsyncTask<Void, Void, Long>() { @Override protected Long doInBackground(Void... params) { @@ -76,6 +79,10 @@ public final class RecurringRunner { }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } + public void start() { + start(false); + } + public void stop() { mRunning = false; mHandler.removeCallbacksAndMessages(null); diff --git a/src/com/android/tv/util/SearchManagerHelper.java b/src/com/android/tv/util/SearchManagerHelper.java deleted file mode 100644 index b6e34d7a..00000000 --- a/src/com/android/tv/util/SearchManagerHelper.java +++ /dev/null @@ -1,61 +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.util; - -import android.app.SearchManager; -import android.content.Context; -import android.os.Bundle; -import android.os.UserHandle; -import android.util.Log; - -import java.lang.reflect.InvocationTargetException; - -/** - * A convenience class for calling methods in android.app.SearchManager. - */ -public final class SearchManagerHelper { - private static final String TAG = "SearchManagerHelper"; - - private static final Object sLock = new Object(); - private static SearchManagerHelper sInstance; - - private final SearchManager mSearchManager; - - private SearchManagerHelper(Context context) { - mSearchManager = ((android.app.SearchManager) context.getSystemService( - Context.SEARCH_SERVICE)); - } - - public static SearchManagerHelper getInstance(Context context) { - synchronized (sLock) { - if (sInstance == null) { - sInstance = new SearchManagerHelper(context.getApplicationContext()); - } - return sInstance; - } - } - - public void launchAssistAction() { - try { - 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 8223a81c..32e3a81f 100644 --- a/src/com/android/tv/util/SetupUtils.java +++ b/src/com/android/tv/util/SetupUtils.java @@ -37,8 +37,6 @@ 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; @@ -114,7 +112,7 @@ public class SetupUtils { @Override public void onLoadFinished() { manager.removeListener(this); - updateChannelBrowsable(mTvApplication, inputId, postRunnable); + updateChannelsAfterSetup(mTvApplication, inputId, postRunnable); } @Override @@ -124,17 +122,18 @@ public class SetupUtils { public void onChannelBrowsableChanged() { } }); } else { - updateChannelBrowsable(mTvApplication, inputId, postRunnable); + updateChannelsAfterSetup(mTvApplication, inputId, postRunnable); } } - private static void updateChannelBrowsable(Context context, final String inputId, + private static void updateChannelsAfterSetup(Context context, final String inputId, final Runnable postRunnable) { ApplicationSingletons appSingletons = TvApplication.getSingletons(context); final ChannelDataManager manager = appSingletons.getChannelDataManager(); manager.updateChannels(new Runnable() { @Override public void run() { + Channel firstChannelForInput = null; boolean browsableChanged = false; for (Channel channel : manager.getChannelList()) { if (channel.getInputId().equals(inputId)) { @@ -142,8 +141,14 @@ public class SetupUtils { manager.updateBrowsable(channel.getId(), true, true); browsableChanged = true; } + if (firstChannelForInput == null) { + firstChannelForInput = channel; + } } } + if (firstChannelForInput != null) { + Utils.setLastWatchedChannel(context, firstChannelForInput); + } if (browsableChanged) { manager.notifyChannelBrowsableChanged(); manager.applyUpdatedValuesToDb(); @@ -382,13 +387,5 @@ 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(); - } - } } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/tuner/util/StringUtils.java b/src/com/android/tv/util/StringUtils.java index 15571e75..659807e2 100644 --- a/src/com/android/tv/tuner/util/StringUtils.java +++ b/src/com/android/tv/util/StringUtils.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.tv.tuner.util; +package com.android.tv.util; /** * Utility class for handling {@link String}. diff --git a/src/com/android/tv/util/TimeShiftUtils.java b/src/com/android/tv/util/TimeShiftUtils.java index 238d0e74..8038a78f 100644 --- a/src/com/android/tv/util/TimeShiftUtils.java +++ b/src/com/android/tv/util/TimeShiftUtils.java @@ -18,7 +18,6 @@ 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. */ @@ -40,7 +39,7 @@ public class TimeShiftUtils { * 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 speedLevel the valid value is ranged from 0 to {@link #MAX_SPEED_LEVEL}. * @param programDurationMillis the length of program under playing. * @throws IndexOutOfBoundsException if speed level is out of its range. */ @@ -60,4 +59,3 @@ public class TimeShiftUtils { : SHORT_PROGRAM_SPEED_FACTORS[MAX_SPEED_LEVEL]; } } - diff --git a/src/com/android/tv/util/TvInputManagerHelper.java b/src/com/android/tv/util/TvInputManagerHelper.java index 121f56ed..730a985b 100644 --- a/src/com/android/tv/util/TvInputManagerHelper.java +++ b/src/com/android/tv/util/TvInputManagerHelper.java @@ -18,20 +18,26 @@ package com.android.tv.util; import android.content.Context; import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.hardware.hdmi.HdmiDeviceInfo; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvInputManager.TvInputCallback; import android.os.Handler; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; +import android.util.ArrayMap; import android.util.Log; import com.android.tv.Features; import com.android.tv.common.SoftPreconditions; +import com.android.tv.common.TvCommonUtils; import com.android.tv.parental.ContentRatingsManager; import com.android.tv.parental.ParentalControlSettings; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -42,14 +48,64 @@ import java.util.Map; public class TvInputManagerHelper { private static final String TAG = "TvInputManagerHelper"; private static final boolean DEBUG = false; + + /** + * Types of HDMI device and bundled tuner. + */ + public static final int TYPE_CEC_DEVICE = -2; + public static final int TYPE_BUNDLED_TUNER = -3; + public static final int TYPE_CEC_DEVICE_RECORDER = -4; + public static final int TYPE_CEC_DEVICE_PLAYBACK = -5; + public static final int TYPE_MHL_MOBILE = -6; + + private static final String PERMISSION_ACCESS_ALL_EPG_DATA = + "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA"; + private static final String [] mPhysicalTunerBlackList = { + }; + private static final String META_LABEL_SORT_KEY = "input_sort_key"; + + /** + * The default tv input priority to show. + */ + private static final ArrayList<Integer> DEFAULT_TV_INPUT_PRIORITY = new ArrayList<>(); + static { + DEFAULT_TV_INPUT_PRIORITY.add(TYPE_BUNDLED_TUNER); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_TUNER); + DEFAULT_TV_INPUT_PRIORITY.add(TYPE_CEC_DEVICE); + DEFAULT_TV_INPUT_PRIORITY.add(TYPE_CEC_DEVICE_RECORDER); + DEFAULT_TV_INPUT_PRIORITY.add(TYPE_CEC_DEVICE_PLAYBACK); + DEFAULT_TV_INPUT_PRIORITY.add(TYPE_MHL_MOBILE); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_HDMI); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_DVI); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_COMPONENT); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_SVIDEO); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_COMPOSITE); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_DISPLAY_PORT); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_VGA); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_SCART); + DEFAULT_TV_INPUT_PRIORITY.add(TvInputInfo.TYPE_OTHER); + } + private static final String[] PARTNER_TUNER_INPUT_PREFIX_BLACKLIST = { }; + private static final String[] TESTABLE_INPUTS = { + "com.android.tv.testinput/.TestTvInputService" + }; + private final Context mContext; + private final PackageManager mPackageManager; private final TvInputManager mTvInputManager; private final Map<String, Integer> mInputStateMap = new HashMap<>(); private final Map<String, TvInputInfo> mInputMap = new HashMap<>(); + private final Map<String, String> mTvInputLabels = new ArrayMap<>(); + private final Map<String, String> mTvInputCustomLabels = new ArrayMap<>(); private final Map<String, Boolean> mInputIdToPartnerInputMap = new HashMap<>(); + + private final Map<String, CharSequence> mTvInputApplicationLabels = new ArrayMap<>(); + private final Map<String, Drawable> mTvInputApplicationIcons = new ArrayMap<>(); + private final Map<String, Drawable> mTvInputAppliactionBanners = new ArrayMap<>(); + private final TvInputCallback mInternalCallback = new TvInputCallback() { @Override public void onInputStateChanged(String inputId, int state) { @@ -72,6 +128,11 @@ public class TvInputManagerHelper { TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); if (info != null) { mInputMap.put(inputId, info); + mTvInputLabels.put(inputId, info.loadLabel(mContext).toString()); + CharSequence inputCustomLabel = info.loadCustomLabel(mContext); + if (inputCustomLabel != null) { + mTvInputCustomLabels.put(inputId, inputCustomLabel.toString()); + } mInputStateMap.put(inputId, mTvInputManager.getInputState(inputId)); mInputIdToPartnerInputMap.put(inputId, isPartnerInput(info)); } @@ -85,6 +146,11 @@ public class TvInputManagerHelper { public void onInputRemoved(String inputId) { if (DEBUG) Log.d(TAG, "onInputRemoved " + inputId); mInputMap.remove(inputId); + mTvInputLabels.remove(inputId); + mTvInputCustomLabels.remove(inputId); + mTvInputApplicationLabels.remove(inputId); + mTvInputApplicationIcons.remove(inputId); + mTvInputAppliactionBanners.remove(inputId); mInputStateMap.remove(inputId); mInputIdToPartnerInputMap.remove(inputId); mContentRatingsManager.update(); @@ -103,6 +169,14 @@ public class TvInputManagerHelper { } TvInputInfo info = mTvInputManager.getTvInputInfo(inputId); mInputMap.put(inputId, info); + mTvInputLabels.put(inputId, info.loadLabel(mContext).toString()); + CharSequence inputCustomLabel = info.loadCustomLabel(mContext); + if (inputCustomLabel != null) { + mTvInputCustomLabels.put(inputId, inputCustomLabel.toString()); + } + mTvInputApplicationLabels.remove(inputId); + mTvInputApplicationIcons.remove(inputId); + mTvInputAppliactionBanners.remove(inputId); for (TvInputCallback callback : mCallbacks) { callback.onInputUpdated(inputId); } @@ -114,6 +188,11 @@ public class TvInputManagerHelper { public void onTvInputInfoUpdated(TvInputInfo inputInfo) { if (DEBUG) Log.d(TAG, "onTvInputInfoUpdated " + inputInfo); mInputMap.put(inputInfo.getId(), inputInfo); + mTvInputLabels.put(inputInfo.getId(), inputInfo.loadLabel(mContext).toString()); + CharSequence inputCustomLabel = inputInfo.loadCustomLabel(mContext); + if (inputCustomLabel != null) { + mTvInputCustomLabels.put(inputInfo.getId(), inputCustomLabel.toString()); + } for (TvInputCallback callback : mCallbacks) { callback.onTvInputInfoUpdated(inputInfo); } @@ -131,13 +210,18 @@ public class TvInputManagerHelper { public TvInputManagerHelper(Context context) { mContext = context.getApplicationContext(); + mPackageManager = context.getPackageManager(); mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE); mContentRatingsManager = new ContentRatingsManager(context); mParentalControlSettings = new ParentalControlSettings(context); - mTvInputInfoComparator = new TvInputInfoComparator(this); + mTvInputInfoComparator = new InputComparatorInternal(this); } public void start() { + if (!hasTvInputManager()) { + // Not a TV device + return; + } if (mStarted) { return; } @@ -145,6 +229,11 @@ public class TvInputManagerHelper { mStarted = true; mTvInputManager.registerCallback(mInternalCallback, mHandler); mInputMap.clear(); + mTvInputLabels.clear(); + mTvInputCustomLabels.clear(); + mTvInputApplicationLabels.clear(); + mTvInputApplicationIcons.clear(); + mTvInputAppliactionBanners.clear(); mInputStateMap.clear(); mInputIdToPartnerInputMap.clear(); for (TvInputInfo input : mTvInputManager.getTvInputList()) { @@ -171,9 +260,23 @@ public class TvInputManagerHelper { mStarted = false; mInputStateMap.clear(); mInputMap.clear(); + mTvInputLabels.clear(); + mTvInputCustomLabels.clear(); + mTvInputApplicationLabels.clear(); + mTvInputApplicationIcons.clear(); + mTvInputAppliactionBanners.clear();; mInputIdToPartnerInputMap.clear(); } + /** + * Clears the TvInput labels map. + */ + public void clearTvInputLabels() { + mTvInputLabels.clear(); + mTvInputCustomLabels.clear(); + mTvInputApplicationLabels.clear(); + } + public List<TvInputInfo> getTvInputInfos(boolean availableOnly, boolean tunerOnly) { ArrayList<TvInputInfo> list = new ArrayList<>(); for (Map.Entry<String, Integer> pair : mInputStateMap.entrySet()) { @@ -192,7 +295,7 @@ public class TvInputManagerHelper { /** * Returns the default comparator for {@link TvInputInfo}. - * See {@link TvInputInfoComparator} for detail. + * See {@link InputComparatorInternal} for detail. */ public Comparator<TvInputInfo> getDefaultTvInputInfoComparator() { return mTvInputInfoComparator; @@ -237,15 +340,81 @@ public class TvInputManagerHelper { } /** - * Loads label of {@code info}. + * Is (Context.TV_INPUT_SERVICE) available. * - * It's visible for comparator test to mock TvInputInfo. - * Package private is enough for this method, but public is necessary to workaround mockito - * bug. + * <p>This is only available on TV devices. + */ + public boolean hasTvInputManager() { + return mTvInputManager != null; + } + + /** + * Loads label of {@code info}. */ - @VisibleForTesting public String loadLabel(TvInputInfo info) { - return info.loadLabel(mContext).toString(); + String label = mTvInputLabels.get(info.getId()); + if (label == null) { + label = info.loadLabel(mContext).toString(); + mTvInputLabels.put(info.getId(), label); + } + return label; + } + + /** + * Loads custom label of {@code info} + */ + public String loadCustomLabel(TvInputInfo info) { + String customLabel = mTvInputCustomLabels.get(info.getId()); + if (customLabel == null) { + CharSequence customLabelCharSequence = info.loadCustomLabel(mContext); + if (customLabelCharSequence != null) { + customLabel = customLabelCharSequence.toString(); + mTvInputCustomLabels.put(info.getId(), customLabel); + } + } + return customLabel; + } + + /** + * Gets the tv input application's label. + */ + public CharSequence getTvInputApplicationLabel(CharSequence inputId) { + return mTvInputApplicationLabels.get(inputId); + } + + /** + * Stores the tv input application's label. + */ + public void setTvInputApplicationLabel(String inputId, CharSequence label) { + mTvInputApplicationLabels.put(inputId, label); + } + + /** + * Gets the tv input application's icon. + */ + public Drawable getTvInputApplicationIcon(String inputId) { + return mTvInputApplicationIcons.get(inputId); + } + + /** + * Stores the tv input application's icon. + */ + public void setTvInputApplicationIcon(String inputId, Drawable icon) { + mTvInputApplicationIcons.put(inputId, icon); + } + + /** + * Gets the tv input application's banner. + */ + public Drawable getTvInputApplicationBanner(String inputId) { + return mTvInputAppliactionBanners.get(inputId); + } + + /** + * Stores the tv input application's banner. + */ + public void setTvInputApplicationBanner(String inputId, Drawable banner) { + mTvInputAppliactionBanners.put(inputId, banner); } /** @@ -321,14 +490,54 @@ public class TvInputManagerHelper { return mContentRatingsManager; } - private boolean isInBlackList(String inputId) { - if (!Features.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) { + private int getInputSortKey(TvInputInfo input) { + return input.getServiceInfo().metaData.getInt(META_LABEL_SORT_KEY, + Integer.MAX_VALUE); + } + + private boolean isInputPhysicalTuner(TvInputInfo input) { + String packageName = input.getServiceInfo().packageName; + if (Arrays.asList(mPhysicalTunerBlackList).contains(packageName)) { return false; } - for (String disabledTunerInputPrefix : PARTNER_TUNER_INPUT_PREFIX_BLACKLIST) { - if (inputId.contains(disabledTunerInputPrefix)) { - return true; + + if (input.createSetupIntent() == null) { + return false; + } else { + boolean mayBeTunerInput = mPackageManager.checkPermission( + PERMISSION_ACCESS_ALL_EPG_DATA, input.getServiceInfo().packageName) + == PackageManager.PERMISSION_GRANTED; + if (!mayBeTunerInput) { + try { + ApplicationInfo ai = mPackageManager.getApplicationInfo( + input.getServiceInfo().packageName, 0); + if ((ai.flags & (ApplicationInfo.FLAG_SYSTEM + | ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) == 0) { + return false; + } + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + } + return true; + } + + private boolean isInBlackList(String inputId) { + if (Features.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) { + for (String disabledTunerInputPrefix : PARTNER_TUNER_INPUT_PREFIX_BLACKLIST) { + if (inputId.contains(disabledTunerInputPrefix)) { + return true; + } + } + } + if (TvCommonUtils.isRunningInTest()) { + for (String testableInput : TESTABLE_INPUTS) { + if (testableInput.equals(inputId)) { + return false; + } } + return true; } return false; } @@ -342,10 +551,10 @@ public class TvInputManagerHelper { * (i.e. Mockito's spy doesn't work) */ @VisibleForTesting - static class TvInputInfoComparator implements Comparator<TvInputInfo> { + static class InputComparatorInternal implements Comparator<TvInputInfo> { private final TvInputManagerHelper mInputManager; - public TvInputInfoComparator(TvInputManagerHelper inputManager) { + public InputComparatorInternal(TvInputManagerHelper inputManager) { mInputManager = inputManager; } @@ -357,4 +566,123 @@ public class TvInputManagerHelper { return mInputManager.loadLabel(lhs).compareTo(mInputManager.loadLabel(rhs)); } } + + /** + * A comparator used for {@link com.android.tv.ui.SelectInputView} to show the list of + * TV inputs. + */ + public static class HardwareInputComparator implements Comparator<TvInputInfo> { + private Map<Integer, Integer> mTypePriorities = new HashMap<>(); + private final TvInputManagerHelper mTvInputManagerHelper; + private final Context mContext; + + public HardwareInputComparator(Context context, TvInputManagerHelper tvInputManagerHelper) { + mContext = context; + mTvInputManagerHelper = tvInputManagerHelper; + setupDeviceTypePriorities(); + } + + @Override + public int compare(TvInputInfo lhs, TvInputInfo rhs) { + if (lhs == null) { + return (rhs == null) ? 0 : 1; + } + if (rhs == null) { + return -1; + } + + boolean enabledL = (mTvInputManagerHelper.getInputState(lhs) + != TvInputManager.INPUT_STATE_DISCONNECTED); + boolean enabledR = (mTvInputManagerHelper.getInputState(rhs) + != TvInputManager.INPUT_STATE_DISCONNECTED); + if (enabledL != enabledR) { + return enabledL ? -1 : 1; + } + + int priorityL = getPriority(lhs); + int priorityR = getPriority(rhs); + if (priorityL != priorityR) { + return priorityL - priorityR; + } + + if (lhs.getType() == TvInputInfo.TYPE_TUNER + && rhs.getType() == TvInputInfo.TYPE_TUNER) { + boolean isPhysicalL = mTvInputManagerHelper.isInputPhysicalTuner(lhs); + boolean isPhysicalR = mTvInputManagerHelper.isInputPhysicalTuner(rhs); + if (isPhysicalL != isPhysicalR) { + return isPhysicalL ? -1 : 1; + } + } + + int sortKeyL = mTvInputManagerHelper.getInputSortKey(lhs); + int sortKeyR = mTvInputManagerHelper.getInputSortKey(rhs); + if (sortKeyL != sortKeyR) { + return sortKeyR - sortKeyL; + } + + String parentLabelL = lhs.getParentId() != null + ? getLabel(mTvInputManagerHelper.getTvInputInfo(lhs.getParentId())) + : getLabel(mTvInputManagerHelper.getTvInputInfo(lhs.getId())); + String parentLabelR = rhs.getParentId() != null + ? getLabel(mTvInputManagerHelper.getTvInputInfo(rhs.getParentId())) + : getLabel(mTvInputManagerHelper.getTvInputInfo(rhs.getId())); + + if (!TextUtils.equals(parentLabelL, parentLabelR)) { + return parentLabelL.compareToIgnoreCase(parentLabelR); + } + return getLabel(lhs).compareToIgnoreCase(getLabel(rhs)); + } + + private String getLabel(TvInputInfo input) { + if (input == null) { + return ""; + } + String label = mTvInputManagerHelper.loadCustomLabel(input); + if (TextUtils.isEmpty(label)) { + label = mTvInputManagerHelper.loadLabel(input); + } + return label; + } + + private int getPriority(TvInputInfo info) { + Integer priority = null; + if (mTypePriorities != null) { + priority = mTypePriorities.get(getTvInputTypeForPriority(info)); + } + if (priority != null) { + return priority; + } + return Integer.MAX_VALUE; + } + + private void setupDeviceTypePriorities() { + mTypePriorities = Partner.getInstance(mContext).getInputsOrderMap(); + + // Fill in any missing priorities in the map we got from the OEM + int priority = mTypePriorities.size(); + for (int type : DEFAULT_TV_INPUT_PRIORITY) { + if (!mTypePriorities.containsKey(type)) { + mTypePriorities.put(type, priority++); + } + } + } + + private int getTvInputTypeForPriority(TvInputInfo info) { + if (info.getHdmiDeviceInfo() != null) { + if (info.getHdmiDeviceInfo().isCecDevice()) { + switch (info.getHdmiDeviceInfo().getDeviceType()) { + case HdmiDeviceInfo.DEVICE_RECORDER: + return TYPE_CEC_DEVICE_RECORDER; + case HdmiDeviceInfo.DEVICE_PLAYBACK: + return TYPE_CEC_DEVICE_PLAYBACK; + default: + return TYPE_CEC_DEVICE; + } + } else if (info.getHdmiDeviceInfo().isMhlDevice()) { + return TYPE_MHL_MOBILE; + } + } + return info.getType(); + } + } } diff --git a/src/com/android/tv/util/TvSettings.java b/src/com/android/tv/util/TvSettings.java index 97ff59d6..c5fde317 100644 --- a/src/com/android/tv/util/TvSettings.java +++ b/src/com/android/tv/util/TvSettings.java @@ -17,6 +17,8 @@ package com.android.tv.util; import android.content.Context; +import android.content.SharedPreferences; +import android.media.tv.TvTrackInfo; import android.preference.PreferenceManager; import android.support.annotation.IntDef; @@ -26,53 +28,27 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; - /** * A class about the constants for TV settings. * Objects that are returned from the various {@code get} methods must be treated as immutable. */ public final class TvSettings { - private TvSettings() {} - 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 public static final String PREF_PIN = "pin"; // 4-digit string value. Otherwise, it's not set. - // PIP sounds - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - PIP_SOUND_MAIN, PIP_SOUND_PIP_WINDOW }) - public @interface PipSound {} - public static final int PIP_SOUND_MAIN = 0; - public static final int PIP_SOUND_PIP_WINDOW = PIP_SOUND_MAIN + 1; - - // PIP layouts - @Retention(RetentionPolicy.SOURCE) - @IntDef({ - PIP_LAYOUT_BOTTOM_RIGHT, PIP_LAYOUT_TOP_RIGHT, PIP_LAYOUT_TOP_LEFT, - PIP_LAYOUT_BOTTOM_LEFT, PIP_LAYOUT_SIDE_BY_SIDE }) - public @interface PipLayout {} - public static final int PIP_LAYOUT_BOTTOM_RIGHT = 0; - public static final int PIP_LAYOUT_TOP_RIGHT = PIP_LAYOUT_BOTTOM_RIGHT + 1; - public static final int PIP_LAYOUT_TOP_LEFT = PIP_LAYOUT_TOP_RIGHT + 1; - public static final int PIP_LAYOUT_BOTTOM_LEFT = PIP_LAYOUT_TOP_LEFT + 1; - public static final int PIP_LAYOUT_SIDE_BY_SIDE = PIP_LAYOUT_BOTTOM_LEFT + 1; - public static final int PIP_LAYOUT_LAST = PIP_LAYOUT_SIDE_BY_SIDE; - - // PIP sizes - @Retention(RetentionPolicy.SOURCE) - @IntDef({ PIP_SIZE_SMALL, PIP_SIZE_BIG }) - public @interface PipSize {} - public static final int PIP_SIZE_SMALL = 0; - public static final int PIP_SIZE_BIG = PIP_SIZE_SMALL + 1; - public static final int PIP_SIZE_LAST = PIP_SIZE_BIG; - // Multi-track audio settings private static final String PREF_MULTI_AUDIO_ID = "pref.multi_audio_id"; private static final String PREF_MULTI_AUDIO_LANGUAGE = "pref.multi_audio_language"; private static final String PREF_MULTI_AUDIO_CHANNEL_COUNT = "pref.multi_audio_channel_count"; + // DVR Multi-audio and subtitle settings + private static final String PREF_DVR_MULTI_AUDIO_ID = "pref.dvr_multi_audio_id"; + private static final String PREF_DVR_MULTI_AUDIO_LANGUAGE = "pref.dvr_multi_audio_language"; + private static final String PREF_DVR_MULTI_AUDIO_CHANNEL_COUNT = + "pref.dvr_multi_audio_channel_count"; + private static final String PREF_DVR_SUBTITLE_ID = "pref.dvr_subtitle_id"; + private static final String PREF_DVR_SUBTITLE_LANGUAGE = "pref.dvr_subtitle_language"; + // Parental Control settings private static final String PREF_CONTENT_RATING_SYSTEMS = "pref.content_rating_systems"; private static final String PREF_CONTENT_RATING_LEVEL = "pref.content_rating_level"; @@ -89,58 +65,7 @@ public final class TvSettings { public static final int CONTENT_RATING_LEVEL_LOW = 3; public static final int CONTENT_RATING_LEVEL_CUSTOM = 4; - // PIP settings - /** - * Returns the layout of the PIP window stored in the shared preferences. - * - * @return the saved layout of the PIP window. This value is one of - * {@link #PIP_LAYOUT_TOP_LEFT}, {@link #PIP_LAYOUT_TOP_RIGHT}, - * {@link #PIP_LAYOUT_BOTTOM_LEFT}, {@link #PIP_LAYOUT_BOTTOM_RIGHT} and - * {@link #PIP_LAYOUT_SIDE_BY_SIDE}. If the preference value does not exist, - * {@link #PIP_LAYOUT_BOTTOM_RIGHT} is returned. - */ - @SuppressWarnings("ResourceType") - @PipLayout - public static int getPipLayout(Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getInt( - PREF_PIP_LAYOUT, PIP_LAYOUT_BOTTOM_RIGHT); - } - - /** - * Stores the layout of PIP window to the shared preferences. - * - * @param pipLayout This value should be one of {@link #PIP_LAYOUT_TOP_LEFT}, - * {@link #PIP_LAYOUT_TOP_RIGHT}, {@link #PIP_LAYOUT_BOTTOM_LEFT}, - * {@link #PIP_LAYOUT_BOTTOM_RIGHT} and {@link #PIP_LAYOUT_SIDE_BY_SIDE}. - */ - public static void setPipLayout(Context context, @PipLayout int pipLayout) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putInt( - PREF_PIP_LAYOUT, pipLayout).apply(); - } - - /** - * Returns the size of the PIP view stored in the shared preferences. - * - * @return the saved size of the PIP view. This value is one of - * {@link #PIP_SIZE_SMALL} and {@link #PIP_SIZE_BIG}. If the preference value does not - * exist, {@link #PIP_SIZE_SMALL} is returned. - */ - @SuppressWarnings("ResourceType") - @PipSize - public static int getPipSize(Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getInt( - PREF_PIP_SIZE, PIP_SIZE_SMALL); - } - - /** - * Stores the size of PIP view to the shared preferences. - * - * @param pipSize This value should be one of {@link #PIP_SIZE_SMALL} and {@link #PIP_SIZE_BIG}. - */ - public static void setPipSize(Context context, @PipSize int pipSize) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putInt( - PREF_PIP_SIZE, pipSize).apply(); - } + private TvSettings() {} // Multi-track audio settings public static String getMultiAudioId(Context context) { @@ -173,26 +98,61 @@ public final class TvSettings { PREF_MULTI_AUDIO_CHANNEL_COUNT, channelCount).apply(); } - // Parental Control settings - public static void addContentRatingSystems(Context context, Set<String> ids) { - Set<String> contentRatingSystemSet = getContentRatingSystemSet(context); - if (contentRatingSystemSet.addAll(ids)) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putStringSet(PREF_CONTENT_RATING_SYSTEMS, contentRatingSystemSet).apply(); + public static void setDvrPlaybackTrackSettings(Context context, int trackType, + TvTrackInfo info) { + if (trackType == TvTrackInfo.TYPE_AUDIO) { + if (info == null) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .remove(PREF_DVR_MULTI_AUDIO_ID).apply(); + } else { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PREF_DVR_MULTI_AUDIO_LANGUAGE, info.getLanguage()) + .putInt(PREF_DVR_MULTI_AUDIO_CHANNEL_COUNT, info.getAudioChannelCount()) + .putString(PREF_DVR_MULTI_AUDIO_ID, info.getId()).apply(); + } + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + if (info == null) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .remove(PREF_DVR_SUBTITLE_ID).apply(); + } else { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PREF_DVR_SUBTITLE_LANGUAGE, info.getLanguage()) + .putString(PREF_DVR_SUBTITLE_ID, info.getId()).apply(); + } } } - public static void addContentRatingSystem(Context context, String id) { - Set<String> contentRatingSystemSet = getContentRatingSystemSet(context); - if (contentRatingSystemSet.add(id)) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putStringSet(PREF_CONTENT_RATING_SYSTEMS, contentRatingSystemSet).apply(); + public static TvTrackInfo getDvrPlaybackTrackSettings(Context context, + int trackType) { + String language; + String trackId; + int channelCount; + SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context); + if (trackType == TvTrackInfo.TYPE_AUDIO) { + trackId = pref.getString(PREF_DVR_MULTI_AUDIO_ID, null); + if (trackId == null) { + return null; + } + language = pref.getString(PREF_DVR_MULTI_AUDIO_LANGUAGE, null); + channelCount = pref.getInt(PREF_DVR_MULTI_AUDIO_CHANNEL_COUNT, 0); + return new TvTrackInfo.Builder(trackType, trackId) + .setLanguage(language).setAudioChannelCount(channelCount).build(); + } else if (trackType == TvTrackInfo.TYPE_SUBTITLE) { + trackId = pref.getString(PREF_DVR_SUBTITLE_ID, null); + if (trackId == null) { + return null; + } + language = pref.getString(PREF_DVR_SUBTITLE_LANGUAGE, null); + return new TvTrackInfo.Builder(trackType, trackId).setLanguage(language).build(); + } else { + return null; } } - public static void removeContentRatingSystems(Context context, Set<String> ids) { + // Parental Control settings + public static void addContentRatingSystem(Context context, String id) { Set<String> contentRatingSystemSet = getContentRatingSystemSet(context); - if (contentRatingSystemSet.removeAll(ids)) { + if (contentRatingSystemSet.add(id)) { PreferenceManager.getDefaultSharedPreferences(context).edit() .putStringSet(PREF_CONTENT_RATING_SYSTEMS, contentRatingSystemSet).apply(); } @@ -254,4 +214,4 @@ public final class TvSettings { PreferenceManager.getDefaultSharedPreferences(context).edit().putLong( PREF_DISABLE_PIN_UNTIL, timeMillis).apply(); } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/util/TvTrackInfoUtils.java b/src/com/android/tv/util/TvTrackInfoUtils.java index c004f001..667cc9bf 100644 --- a/src/com/android/tv/util/TvTrackInfoUtils.java +++ b/src/com/android/tv/util/TvTrackInfoUtils.java @@ -52,35 +52,22 @@ public class TvTrackInfoUtils { } // 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; - boolean lhsCountMatch = lhs.getAudioChannelCount() == channelCount; - if (rhsCountMatch) { - if (lhsCountMatch) { - boolean rhsIdMatch = rhs.getId().equals(id); - boolean lhsIdMatch = lhs.getId().equals(id); - if (rhsIdMatch) { - return lhsIdMatch ? 0 : -1; - } else { - return lhsIdMatch ? 1 : 0; - } - - } else { - return -1; - } - } else { - return lhsCountMatch ? 1 : 0; - } + boolean rhsLangMatch = language == null || Utils.isEqualLanguage(rhs.getLanguage(), + language); + if (lhsLangMatch && rhsLangMatch) { + boolean lhsCountMatch = lhs.getType() != TvTrackInfo.TYPE_AUDIO + || lhs.getAudioChannelCount() == channelCount; + boolean rhsCountMatch = rhs.getType() != TvTrackInfo.TYPE_AUDIO + || rhs.getAudioChannelCount() == channelCount; + if (lhsCountMatch && rhsCountMatch) { + return Boolean.compare(lhs.getId().equals(id), rhs.getId().equals(id)); } else { - return -1; + return Boolean.compare(lhsCountMatch, rhsCountMatch); } } else { - return lhsLangMatch ? 1 : 0; + return Boolean.compare(lhsLangMatch, rhsLangMatch); } } }; @@ -112,4 +99,4 @@ public class TvTrackInfoUtils { private TvTrackInfoUtils() { } -} +}
\ No newline at end of file diff --git a/src/com/android/tv/util/TvProviderUriMatcher.java b/src/com/android/tv/util/TvUriMatcher.java index 749e4aa3..3d91cdad 100644 --- a/src/com/android/tv/util/TvProviderUriMatcher.java +++ b/src/com/android/tv/util/TvUriMatcher.java @@ -16,23 +16,27 @@ package com.android.tv.util; +import android.app.SearchManager; import android.content.UriMatcher; import android.media.tv.TvContract; import android.net.Uri; import android.support.annotation.IntDef; +import com.android.tv.search.LocalSearchProvider; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Utility class to aid in matching URIs in TvProvider. */ -public class TvProviderUriMatcher { +public class TvUriMatcher { 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}) + MATCH_RECORDED_PROGRAM, MATCH_RECORDED_PROGRAM_ID, MATCH_WATCHED_PROGRAM_ID, + MATCH_ON_DEVICE_SEARCH}) private @interface TvProviderUriMatchCode {} /** The code for the channels URI. */ public static final int MATCH_CHANNEL = 1; @@ -48,6 +52,8 @@ public class TvProviderUriMatcher { 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; + /** The code for the on-device search URI. */ + public static final int MATCH_ON_DEVICE_SEARCH = 8; static { URI_MATCHER.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL); URI_MATCHER.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID); @@ -56,9 +62,11 @@ public class TvProviderUriMatcher { 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); + URI_MATCHER.addURI(LocalSearchProvider.AUTHORITY, + SearchManager.SUGGEST_URI_PATH_QUERY + "/*", MATCH_ON_DEVICE_SEARCH); } - private TvProviderUriMatcher() { } + private TvUriMatcher() { } /** * Try to match against the path in a url. diff --git a/src/com/android/tv/util/Utils.java b/src/com/android/tv/util/Utils.java index 99d34431..d11bab3c 100644 --- a/src/com/android/tv/util/Utils.java +++ b/src/com/android/tv/util/Utils.java @@ -31,6 +31,7 @@ import android.media.tv.TvContract.Programs.Genres; import android.media.tv.TvInputInfo; import android.media.tv.TvTrackInfo; import android.net.Uri; +import android.os.Looper; import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; @@ -44,11 +45,13 @@ import android.view.View; import com.android.tv.ApplicationSingletons; import com.android.tv.R; import com.android.tv.TvApplication; +import com.android.tv.common.BuildConfig; 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 com.android.tv.experiments.Experiments; import java.io.File; import java.text.SimpleDateFormat; @@ -62,6 +65,8 @@ import java.util.List; import java.util.Locale; import java.util.Set; import java.util.TimeZone; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; /** @@ -74,7 +79,6 @@ public class Utils { 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"; @@ -83,11 +87,9 @@ public class Utils { 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"; - private static final String PATH_CHANNEL = "channel"; private static final String PATH_PROGRAM = "program"; + private static final String PATH_RECORDED_PROGRAM = "recorded_program"; private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID = "last_watched_channel_id"; private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT = @@ -97,6 +99,8 @@ public class Utils { "last_watched_tuner_input_id"; private static final String PREF_KEY_RECORDING_FAILED_REASONS = "recording_failed_reasons"; + private static final String PREF_KEY_FAILED_SCHEDULED_RECORDING_INFO_SET = + "failed_scheduled_recording_info_set"; private static final int VIDEO_SD_WIDTH = 704; private static final int VIDEO_SD_HEIGHT = 480; @@ -114,6 +118,7 @@ public class Utils { private static final int AUDIO_CHANNEL_SURROUND_8 = 8; private static final long RECORDING_FAILED_REASON_NONE = 0; + private static final long HALF_MINUTE_MS = TimeUnit.SECONDS.toMillis(30); private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1); // Hardcoded list for known bundled inputs not written by OEM/SOCs. @@ -207,6 +212,28 @@ public class Utils { } /** + * Adds the info of failed scheduled recording. + */ + public static void addFailedScheduledRecordingInfo(Context context, + String scheduledRecordingInfo) { + Set<String> failedScheduledRecordingInfoSet = getFailedScheduledRecordingInfoSet(context); + failedScheduledRecordingInfoSet.add(scheduledRecordingInfo); + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putStringSet(PREF_KEY_FAILED_SCHEDULED_RECORDING_INFO_SET, + failedScheduledRecordingInfoSet) + .apply(); + } + + /** + * Clears the failed scheduled recording info set. + */ + public static void clearFailedScheduledRecordingInfoSet(Context context) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .remove(PREF_KEY_FAILED_SCHEDULED_RECORDING_INFO_SET) + .apply(); + } + + /** * Clears recording failed reason. */ public static void clearRecordingFailedReason(Context context, int reason) { @@ -246,6 +273,14 @@ public class Utils { } /** + * Returns the failed scheduled recordings info set. + */ + public static Set<String> getFailedScheduledRecordingInfoSet(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getStringSet(PREF_KEY_FAILED_SCHEDULED_RECORDING_INFO_SET, new HashSet<>()); + } + + /** * Checks do recording failed reason exist. */ public static boolean hasRecordingFailedReason(Context context, int reason) { @@ -296,6 +331,13 @@ public class Utils { } /** + * Returns {@code true}, if {@code uri} is a programs URI. + */ + public static boolean isRecordedProgramsUri(Uri uri) { + return isTvUri(uri) && PATH_RECORDED_PROGRAM.equals(uri.getPathSegments().get(0)); + } + + /** * Gets the info of the program on particular time. */ @WorkerThread @@ -333,6 +375,14 @@ public class Utils { } /** + * Returns the round off minutes when convert milliseconds to minutes. + */ + public static int getRoundOffMinsFromMs(long millis) { + // Round off the result by adding half minute to the original ms. + return (int) TimeUnit.MILLISECONDS.toMinutes(millis + HALF_MINUTE_MS); + } + + /** * Returns duration string according to the date & time format. * If {@code startUtcMillis} and {@code endUtcMills} are equal, * formatted time will be returned instead. @@ -392,16 +442,18 @@ public class Utils { : DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis + 1, flag); } - @VisibleForTesting + /** + * Checks if two given time (in milliseconds) are in the same day with regard to the + * locale timezone. + */ public static boolean isInGivenDay(long dayToMatchInMillis, long subjectTimeInMillis) { - final long DAY_IN_MS = TimeUnit.DAYS.toMillis(1); TimeZone timeZone = Calendar.getInstance().getTimeZone(); long offset = timeZone.getRawOffset(); if (timeZone.inDaylightTime(new Date(dayToMatchInMillis))) { offset += timeZone.getDSTSavings(); } - return Utils.floorTime(dayToMatchInMillis + offset, DAY_IN_MS) - == Utils.floorTime(subjectTimeInMillis + offset, DAY_IN_MS); + return Utils.floorTime(dayToMatchInMillis + offset, ONE_DAY_MS) + == Utils.floorTime(subjectTimeInMillis + offset, ONE_DAY_MS); } /** @@ -523,7 +575,7 @@ public class Utils { if (track.getType() != TvTrackInfo.TYPE_AUDIO) { throw new IllegalArgumentException("Not an audio track: " + track); } - String language = context.getString(R.string.default_language); + String language = context.getString(R.string.multi_audio_unknown_language); if (!TextUtils.isEmpty(track.getLanguage())) { language = new Locale(track.getLanguage()).getDisplayName(); } else { @@ -606,10 +658,12 @@ public class Utils { if (input == null) { return null; } - CharSequence customLabel = input.loadCustomLabel(context); + TvInputManagerHelper inputManager = + TvApplication.getSingletons(context).getTvInputManagerHelper(); + CharSequence customLabel = inputManager.loadCustomLabel(input); String label = (customLabel == null) ? null : customLabel.toString(); if (TextUtils.isEmpty(label)) { - label = input.loadLabel(context).toString(); + label = inputManager.loadLabel(input).toString(); } return label; } @@ -860,4 +914,28 @@ public class Utils { } return Genres.encode(genres); } + + /** + * Returns true if the current user is a developer. + */ + public static boolean isDeveloper() { + return BuildConfig.ENG || Experiments.ENABLE_DEVELOPER_FEATURES.get(); + } + + /** + * Runs the method in main thread. If the current thread is not main thread, block it util + * the method is finished. + */ + public static void runInMainThreadAndWait(Runnable runnable) { + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable.run(); + } else { + Future<?> temp = MainThreadExecutor.getInstance().submit(runnable); + try { + temp.get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "failed to finish the execution", e); + } + } + } } diff --git a/src/com/android/tv/util/ViewCache.java b/src/com/android/tv/util/ViewCache.java new file mode 100644 index 00000000..ed9a8ff6 --- /dev/null +++ b/src/com/android/tv/util/ViewCache.java @@ -0,0 +1,100 @@ +package com.android.tv.util; + +import android.content.Context; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import java.util.ArrayList; + +/** + * A cache for the views. + */ +public class ViewCache { + private final static SparseArray<ArrayList<View>> mViews = new SparseArray(); + + private static ViewCache sViewCache; + + private ViewCache() { } + + /** + * Returns an instance of the view cache. + */ + public static ViewCache getInstance() { + if (sViewCache == null) { + sViewCache = new ViewCache(); + } + return sViewCache; + } + + /** + * Returns if the view cache is empty. + */ + public boolean isEmpty() { + return mViews.size() == 0; + } + + /** + * Stores a view into this view cache. + */ + public void putView(int resId, View view) { + ArrayList<View> views = mViews.get(resId); + if (views == null) { + views = new ArrayList(); + mViews.put(resId, views); + } + views.add(view); + } + + /** + * Stores multi specific views into the view cache. + */ + public void putView(Context context, int resId, ViewGroup fakeParent, int num) { + LayoutInflater inflater = + (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + ArrayList<View> views = mViews.get(resId); + if (views == null) { + views = new ArrayList<>(); + mViews.put(resId, views); + } + for (int i = 0; i < num; i++) { + View view = inflater.inflate(resId, fakeParent, false); + views.add(view); + } + } + + /** + * Returns the view for specific resource id. + */ + public View getView(int resId) { + ArrayList<View> views = mViews.get(resId); + if (views != null && !views.isEmpty()) { + View view = views.remove(views.size() - 1); + if (views.isEmpty()) { + mViews.remove(resId); + } + return view; + } else { + return null; + } + } + + /** + * Returns the view if exists, or create a new view for the specific resource id. + */ + public View getOrCreateView(LayoutInflater inflater, int resId, ViewGroup container) { + View view = getView(resId); + if (view == null) { + view = inflater.inflate(resId, container, false); + } + return view; + } + + /** + * Clears the view cache. + */ + public void clear() { + mViews.clear(); + } +} |